Merge branch 'develop' into feature/tracking
This commit is contained in:
@@ -19,6 +19,8 @@ DESIGN_SYSTEM_ACCESS_TOKEN=""
|
||||
NEXTAUTH_REDIRECT_PROXY_URL="http://localhost:3000/api/web/auth"
|
||||
NEXTAUTH_SECRET=""
|
||||
REVALIDATE_SECRET=""
|
||||
SALESFORCE_PREFERENCE_BASE_URL="https://cloud.emails.scandichotels.com/preference-center"
|
||||
|
||||
SEAMLESS_LOGIN_DA="http://www.example.dk/updatelogin"
|
||||
SEAMLESS_LOGIN_DE="http://www.example.de/updatelogin"
|
||||
SEAMLESS_LOGIN_EN="http://www.example.com/updatelogin"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import ManagePreferencesButton from "@/components/Profile/ManagePreferencesButton"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
@@ -27,12 +28,7 @@ export default async function CommunicationSlot({
|
||||
})}
|
||||
</Body>
|
||||
</article>
|
||||
<Link href="#" variant="icon">
|
||||
<ArrowRightIcon color="burgundy" />
|
||||
<Body color="burgundy" textTransform="underlined">
|
||||
{formatMessage({ id: "Manage preferences" })}
|
||||
</Body>
|
||||
</Link>
|
||||
<ManagePreferencesButton />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AddCreditCardButton from "@/components/Profile/AddCreditCardButton"
|
||||
@@ -17,8 +16,6 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const creditCards = await serverClient().user.creditCards()
|
||||
|
||||
const { lang } = params
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<article className={styles.content}>
|
||||
|
||||
@@ -15,8 +15,7 @@ export default function ProfileLayout({
|
||||
{profile}
|
||||
<Divider color="burgundy" opacity={8} />
|
||||
{creditCards}
|
||||
{/* TODO: Implement communication preferences flow. Hidden until decided on where to send user. */}
|
||||
{/* {communication} */}
|
||||
{communication}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import { getHotelDataSchema } from "@/server/routers/hotels/output"
|
||||
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
|
||||
|
||||
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
|
||||
import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection"
|
||||
import BreakfastSelection from "@/components/HotelReservation/SelectRate/BreakfastSelection"
|
||||
import Details from "@/components/HotelReservation/SelectRate/Details"
|
||||
import Payment from "@/components/HotelReservation/SelectRate/Payment"
|
||||
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
|
||||
import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion"
|
||||
import Summary from "@/components/HotelReservation/SelectRate/Summary"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { SectionPageProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
const bedAlternatives = [
|
||||
{
|
||||
value: "queen",
|
||||
name: "Queen bed",
|
||||
payment: "160 cm",
|
||||
pricePerNight: 0,
|
||||
membersPricePerNight: 0,
|
||||
currency: "SEK",
|
||||
},
|
||||
{
|
||||
value: "king",
|
||||
name: "King bed",
|
||||
payment: "160 cm",
|
||||
pricePerNight: 0,
|
||||
membersPricePerNight: 0,
|
||||
currency: "SEK",
|
||||
},
|
||||
{
|
||||
value: "twin",
|
||||
name: "Twin bed",
|
||||
payment: "90 cm + 90 cm",
|
||||
pricePerNight: 82,
|
||||
membersPricePerNight: 67,
|
||||
currency: "SEK",
|
||||
},
|
||||
]
|
||||
|
||||
const breakfastAlternatives = [
|
||||
{
|
||||
value: "no",
|
||||
name: "No breakfast",
|
||||
payment: "Always cheeper to get it online",
|
||||
pricePerNight: 0,
|
||||
currency: "SEK",
|
||||
},
|
||||
{
|
||||
value: "buffe",
|
||||
name: "Breakfast buffé",
|
||||
payment: "Always cheeper to get it online",
|
||||
pricePerNight: 150,
|
||||
currency: "SEK",
|
||||
},
|
||||
]
|
||||
|
||||
const getFlexibilityMessage = (value: string) => {
|
||||
switch (value) {
|
||||
case "non-refundable":
|
||||
return "Non refundable"
|
||||
case "free-rebooking":
|
||||
return "Free rebooking"
|
||||
case "free-cancellation":
|
||||
return "Free cancellation"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default async function SectionsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams & { section: string }, SectionPageProps>) {
|
||||
setLang(params.lang)
|
||||
|
||||
// TODO: Use real endpoint.
|
||||
const hotel = getHotelDataSchema.parse(tempHotelData)
|
||||
|
||||
const rooms = await serverClient().hotel.rates.get({
|
||||
// TODO: pass the correct hotel ID and all other parameters that should be included in the search
|
||||
hotelId: "1",
|
||||
})
|
||||
const intl = await getIntl()
|
||||
|
||||
const selectedBed = searchParams.bed
|
||||
? bedAlternatives.find((a) => a.value === searchParams.bed)?.name
|
||||
: undefined
|
||||
|
||||
const selectedBreakfast = searchParams.breakfast
|
||||
? breakfastAlternatives.find((a) => a.value === searchParams.breakfast)
|
||||
?.name
|
||||
: undefined
|
||||
|
||||
const selectedRoom = searchParams.roomClass
|
||||
? rooms.find((room) => room.id.toString() === searchParams.roomClass)?.name
|
||||
: undefined
|
||||
const selectedFlexibility = searchParams.flexibility
|
||||
? getFlexibilityMessage(searchParams.flexibility)
|
||||
: undefined
|
||||
|
||||
const currentSearchParams = new URLSearchParams(searchParams).toString()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HotelSelectionHeader hotel={hotel.data.attributes} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.main}>
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Room & Terms" })}
|
||||
selection={
|
||||
selectedRoom
|
||||
? [
|
||||
selectedRoom,
|
||||
intl.formatMessage({ id: selectedFlexibility }),
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
path={`select-rate?${currentSearchParams}`}
|
||||
>
|
||||
{params.section === "select-rate" && (
|
||||
<RoomSelection
|
||||
alternatives={rooms}
|
||||
nextPath="select-bed"
|
||||
// TODO: Get real value
|
||||
nrOfNights={1}
|
||||
// TODO: Get real value
|
||||
nrOfAdults={1}
|
||||
/>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Bed type" })}
|
||||
selection={selectedBed}
|
||||
path={`select-bed?${currentSearchParams}`}
|
||||
>
|
||||
{params.section === "select-bed" && (
|
||||
<BedSelection
|
||||
nextPath="breakfast"
|
||||
alternatives={bedAlternatives}
|
||||
/>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Breakfast" })}
|
||||
selection={selectedBreakfast}
|
||||
path={`breakfast?${currentSearchParams}`}
|
||||
>
|
||||
{params.section === "breakfast" && (
|
||||
<BreakfastSelection
|
||||
alternatives={breakfastAlternatives}
|
||||
nextPath="details"
|
||||
/>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Your details" })}
|
||||
path={`details?${currentSearchParams}`}
|
||||
>
|
||||
{params.section === "details" && <Details nextPath="payment" />}
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Payment info" })}
|
||||
path={`payment?${currentSearchParams}`}
|
||||
>
|
||||
{params.section === "payment" && <Payment />}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
<div className={styles.summary}>
|
||||
<Summary />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
padding-top: var(--Spacing-x6);
|
||||
padding-left: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1134px;
|
||||
margin-top: var(--Spacing-x5);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x7);
|
||||
}
|
||||
|
||||
.section {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.summary {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
125
app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx
Normal file
125
app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { notFound } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
|
||||
import Payment from "@/components/HotelReservation/SelectRate/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion"
|
||||
import Summary from "@/components/HotelReservation/SelectRate/Summary"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
enum StepEnum {
|
||||
selectBed = "select-bed",
|
||||
breakfast = "breakfast",
|
||||
details = "details",
|
||||
payment = "payment",
|
||||
}
|
||||
|
||||
function isValidStep(step: string): step is StepEnum {
|
||||
return Object.values(StepEnum).includes(step as StepEnum)
|
||||
}
|
||||
|
||||
export default function StepPage({
|
||||
params,
|
||||
}: PageArgs<LangParams & { step: StepEnum }>) {
|
||||
const { step } = params
|
||||
const [activeStep, setActiveStep] = useState<StepEnum>(step)
|
||||
const intl = useIntl()
|
||||
|
||||
if (!isValidStep(activeStep)) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const { data: hotel, isLoading: loadingHotel } =
|
||||
trpc.hotel.hotelData.get.useQuery({
|
||||
hotelId: "811",
|
||||
language: params.lang,
|
||||
})
|
||||
|
||||
if (loadingHotel) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
if (!hotel) {
|
||||
// TODO: handle case with hotel missing
|
||||
return notFound()
|
||||
}
|
||||
|
||||
switch (activeStep) {
|
||||
case StepEnum.breakfast:
|
||||
//return <div>Select BREAKFAST</div>
|
||||
case StepEnum.details:
|
||||
//return <div>Select DETAILS</div>
|
||||
case StepEnum.payment:
|
||||
//return <div>Select PAYMENT</div>
|
||||
case StepEnum.selectBed:
|
||||
// return <div>Select BED</div>
|
||||
}
|
||||
|
||||
function onNav(step: StepEnum) {
|
||||
setActiveStep(step)
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.pushState({}, "", step)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<HotelSelectionHeader hotel={hotel.data.attributes} />
|
||||
<div className={styles.content}>
|
||||
<section className={styles.section}>
|
||||
<SectionAccordion
|
||||
header="Select bed"
|
||||
isCompleted={true}
|
||||
isOpen={activeStep === StepEnum.selectBed}
|
||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||
path="/select-bed"
|
||||
>
|
||||
<BedType />
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header="Food options"
|
||||
isCompleted={true}
|
||||
isOpen={activeStep === StepEnum.breakfast}
|
||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||
path="/breakfast"
|
||||
>
|
||||
<Breakfast />
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header="Details"
|
||||
isCompleted={false}
|
||||
isOpen={activeStep === StepEnum.details}
|
||||
label={intl.formatMessage({ id: "Enter your details" })}
|
||||
path="/details"
|
||||
>
|
||||
<Details user={null} />
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header="Payment"
|
||||
isCompleted={false}
|
||||
isOpen={activeStep === StepEnum.payment}
|
||||
label={intl.formatMessage({ id: "Select payment method" })}
|
||||
path="/hotelreservation/select-bed"
|
||||
>
|
||||
<Payment hotel={hotel.data.attributes} />
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
<aside className={styles.summary}>
|
||||
<Summary />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -1,20 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
import {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
BookingStatusEnum,
|
||||
} from "@/constants/booking"
|
||||
|
||||
import IntroSection from "@/components/HotelReservation/BookingConfirmation/IntroSection"
|
||||
import StaySection from "@/components/HotelReservation/BookingConfirmation/StaySection"
|
||||
import SummarySection from "@/components/HotelReservation/BookingConfirmation/SummarySection"
|
||||
import { tempConfirmationData } from "@/components/HotelReservation/BookingConfirmation/tempConfirmationData"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
const maxRetries = 10
|
||||
const retryInterval = 2000
|
||||
|
||||
export default function BookingConfirmationPage() {
|
||||
const { email, hotel, stay, summary } = tempConfirmationData
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<section className={styles.section}>
|
||||
<IntroSection email={email} />
|
||||
<StaySection hotel={hotel} stay={stay} />
|
||||
<SummarySection summary={summary} />
|
||||
</section>
|
||||
</main>
|
||||
const confirmationNumber = useMemo(() => {
|
||||
if (typeof window === "undefined") return ""
|
||||
|
||||
const storedConfirmationNumber = sessionStorage.getItem(
|
||||
BOOKING_CONFIRMATION_NUMBER
|
||||
)
|
||||
// TODO: cleanup stored values
|
||||
// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER)
|
||||
return storedConfirmationNumber
|
||||
}, [])
|
||||
|
||||
const bookingStatus = useHandleBookingStatus(
|
||||
confirmationNumber,
|
||||
BookingStatusEnum.BookingCompleted,
|
||||
maxRetries,
|
||||
retryInterval
|
||||
)
|
||||
|
||||
if (
|
||||
confirmationNumber === null ||
|
||||
bookingStatus.isError ||
|
||||
(bookingStatus.isFetched && !bookingStatus.data)
|
||||
) {
|
||||
// TODO: handle error
|
||||
throw new Error("Error fetching booking status")
|
||||
}
|
||||
|
||||
if (
|
||||
bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted
|
||||
) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<section className={styles.section}>
|
||||
<IntroSection email={email} />
|
||||
<StaySection hotel={hotel} stay={stay} />
|
||||
<SummarySection summary={summary} />
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.layout {
|
||||
min-height: 100dvh;
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFil
|
||||
export async function fetchAvailableHotels(
|
||||
input: AvailabilityInput
|
||||
): Promise<HotelData[]> {
|
||||
const availableHotels = await serverClient().hotel.availability.get(input)
|
||||
const availableHotels = await serverClient().hotel.availability.hotels(input)
|
||||
|
||||
if (!availableHotels) throw new Error()
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
|
||||
|
||||
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectRatePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
|
||||
setLang(params.lang)
|
||||
|
||||
// TODO: Use real endpoint.
|
||||
const hotel = tempHotelData.data.attributes
|
||||
|
||||
const rates = await serverClient().hotel.rates.get({
|
||||
// TODO: pass the correct hotel ID and all other parameters that should be included in the search
|
||||
hotelId: searchParams.hotel,
|
||||
})
|
||||
|
||||
// const rates = await serverClient().hotel.availability.getForHotel({
|
||||
// hotelId: 811,
|
||||
// roomStayStartDate: "2024-11-02",
|
||||
// roomStayEndDate: "2024-11-03",
|
||||
// adults: 1,
|
||||
// })
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* TODO: Add Hotel Listing Card */}
|
||||
<div>Hotel Listing Card TBI</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.main}>
|
||||
<RoomSelection
|
||||
rates={rates}
|
||||
// TODO: Get real value
|
||||
nrOfNights={1}
|
||||
// TODO: Get real value
|
||||
nrOfAdults={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
app/[lang]/(live)/@bookingwidget/loading.module.css
Normal file
4
app/[lang]/(live)/@bookingwidget/loading.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.container {
|
||||
height: 76px;
|
||||
width: 100%;
|
||||
}
|
||||
11
app/[lang]/(live)/@bookingwidget/loading.tsx
Normal file
11
app/[lang]/(live)/@bookingwidget/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import styles from "./loading.module.css"
|
||||
|
||||
export default function LoadingBookingWidget() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
app/api/web/payment-callback/[lang]/[status]/route.ts
Normal file
37
app/api/web/payment-callback/[lang]/[status]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { env } from "process"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import {
|
||||
bookingConfirmation,
|
||||
payment,
|
||||
} from "@/constants/routes/hotelReservation"
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { lang: string; status: string } }
|
||||
): Promise<NextResponse> {
|
||||
console.log(`[payment-callback] callback started`)
|
||||
const lang = params.lang as Lang
|
||||
const status = params.status
|
||||
const returnUrl = new URL(`${env.PUBLIC_URL}/${payment[lang]}`)
|
||||
|
||||
if (status === "success") {
|
||||
const confirmationUrl = new URL(
|
||||
`${env.PUBLIC_URL}/${bookingConfirmation[lang]}`
|
||||
)
|
||||
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
|
||||
return NextResponse.redirect(confirmationUrl)
|
||||
}
|
||||
|
||||
if (status === "cancel") {
|
||||
returnUrl.searchParams.set("cancel", "true")
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
returnUrl.searchParams.set("error", "true")
|
||||
}
|
||||
|
||||
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
|
||||
return NextResponse.redirect(returnUrl)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export default function BookingWidgetClient({
|
||||
locations,
|
||||
type,
|
||||
}: BookingWidgetClientProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
@@ -99,8 +100,9 @@ export default function BookingWidgetClient({
|
||||
>
|
||||
<CloseLarge />
|
||||
</button>
|
||||
<Form locations={locations} />
|
||||
<Form locations={locations} type={type} />
|
||||
</section>
|
||||
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
||||
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
||||
</FormProvider>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Spacing-x2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.complete {
|
||||
@@ -13,7 +17,7 @@
|
||||
}
|
||||
|
||||
.partial {
|
||||
grid-template-columns: min(1fr, 150px) min-content min(1fr, 150px) 1fr;
|
||||
grid-template-columns: minmax(auto, 150px) min-content minmax(auto, 150px) auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
@media screen and (max-width: 1366px) {
|
||||
@media screen and (max-width: 767px) {
|
||||
.container {
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
bottom: -100%;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-template-rows: 36px 1fr;
|
||||
height: 100dvh;
|
||||
height: calc(100dvh - 20px);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: fixed;
|
||||
transition: bottom 300ms ease;
|
||||
width: 100%;
|
||||
z-index: 10000;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.container[data-open="true"] {
|
||||
@@ -23,13 +24,26 @@
|
||||
cursor: pointer;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.container[data-open="true"] + .backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
display: block;
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10000;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.close {
|
||||
|
||||
@@ -2,16 +2,18 @@ import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BookingWidgetClient from "./Client"
|
||||
|
||||
import type { BookingWidgetProps } from "@/types/components/bookingWidget"
|
||||
|
||||
export function preload() {
|
||||
void getLocations()
|
||||
}
|
||||
|
||||
export default async function BookingWidget() {
|
||||
export default async function BookingWidget({ type }: BookingWidgetProps) {
|
||||
const locations = await getLocations()
|
||||
|
||||
if (!locations || "error" in locations) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <BookingWidgetClient locations={locations.data} />
|
||||
return <BookingWidgetClient locations={locations.data} type={type} />
|
||||
}
|
||||
|
||||
@@ -10,13 +10,11 @@ import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./amenitiesList.module.css"
|
||||
|
||||
import { HotelData } from "@/types/hotel"
|
||||
import type { AmenitiesListProps } from "@/types/components/hotelPage/amenities"
|
||||
|
||||
export default async function AmenitiesList({
|
||||
detailedFacilities,
|
||||
}: {
|
||||
detailedFacilities: HotelData["data"]["attributes"]["detailedFacilities"]
|
||||
}) {
|
||||
}: AmenitiesListProps) {
|
||||
const intl = await getIntl()
|
||||
const sortedAmenities = detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { activities } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import Card from "@/components/TempDesignSystem/Card"
|
||||
import CardImage from "@/components/TempDesignSystem/Card/CardImage"
|
||||
import Grids from "@/components/TempDesignSystem/Grids"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./cardGrid.module.css"
|
||||
|
||||
import type { ActivityCard } from "@/types/trpc/routers/contentstack/hotelPage"
|
||||
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
|
||||
|
||||
export default function ActivitiesCardGrid(activitiesCard: ActivityCard) {
|
||||
const lang = getLang()
|
||||
const hasImage = activitiesCard.backgroundImage
|
||||
|
||||
const updatedCard: CardProps = {
|
||||
...activitiesCard,
|
||||
id: activities[lang],
|
||||
theme: hasImage ? "image" : "primaryDark",
|
||||
primaryButton: hasImage
|
||||
? {
|
||||
href: activitiesCard.contentPage.href,
|
||||
title: activitiesCard.ctaText,
|
||||
isExternal: false,
|
||||
}
|
||||
: undefined,
|
||||
secondaryButton: hasImage
|
||||
? undefined
|
||||
: {
|
||||
href: activitiesCard.contentPage.href,
|
||||
title: activitiesCard.ctaText,
|
||||
isExternal: false,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<section id={updatedCard.id}>
|
||||
<Grids.Stackable className={styles.desktopGrid}>
|
||||
<Card {...updatedCard} className={styles.spanThree} />
|
||||
</Grids.Stackable>
|
||||
<Grids.Stackable className={styles.mobileGrid}>
|
||||
<CardImage card={updatedCard} />
|
||||
</Grids.Stackable>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,32 @@
|
||||
.one {
|
||||
.spanOne {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.two {
|
||||
.spanTwo {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.three {
|
||||
grid-column: 1/-1;
|
||||
.spanThree {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.desktopGrid {
|
||||
section .desktopGrid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileGrid {
|
||||
section .mobileGrid {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.desktopGrid {
|
||||
section .desktopGrid {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.mobileGrid {
|
||||
section .mobileGrid {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import Card from "@/components/TempDesignSystem/Card"
|
||||
import CardImage from "@/components/TempDesignSystem/Card/CardImage"
|
||||
import Grids from "@/components/TempDesignSystem/Grids"
|
||||
import { sortCards } from "@/utils/imageCard"
|
||||
import { filterFacilityCards, isFacilityCard } from "@/utils/facilityCards"
|
||||
|
||||
import styles from "./cardGrid.module.css"
|
||||
|
||||
import type { CardGridProps } from "@/types/components/hotelPage/facilities"
|
||||
import type {
|
||||
CardGridProps,
|
||||
FacilityCardType,
|
||||
} from "@/types/components/hotelPage/facilities"
|
||||
|
||||
export default function FacilitiesCardGrid({
|
||||
facilitiesCardGrid,
|
||||
}: CardGridProps) {
|
||||
const imageCard = filterFacilityCards(facilitiesCardGrid)
|
||||
const nrCards = facilitiesCardGrid.length
|
||||
|
||||
function getCardClassName(card: FacilityCardType): string {
|
||||
if (nrCards === 1) {
|
||||
return styles.spanThree
|
||||
} else if (nrCards === 2 && !isFacilityCard(card)) {
|
||||
return styles.spanTwo
|
||||
}
|
||||
return styles.spanOne
|
||||
}
|
||||
|
||||
export default async function CardGrid({ facility }: CardGridProps) {
|
||||
const imageCard = sortCards(facility)
|
||||
return (
|
||||
<section id={imageCard.card?.id}>
|
||||
<section id={imageCard.card.id}>
|
||||
<Grids.Stackable className={styles.desktopGrid}>
|
||||
{facility.map((card: any, idx: number) => (
|
||||
<Card
|
||||
theme={card.theme || "primaryDark"}
|
||||
key={idx}
|
||||
scriptedTopTitle={card.scriptedTopTitle}
|
||||
heading={card.heading}
|
||||
bodyText={card.bodyText}
|
||||
secondaryButton={card.secondaryButton}
|
||||
primaryButton={card.primaryButton}
|
||||
backgroundImage={card.backgroundImage}
|
||||
className={styles[card.columnSpan]}
|
||||
/>
|
||||
{facilitiesCardGrid.map((card: FacilityCardType) => (
|
||||
<Card {...card} key={card.id} className={getCardClassName(card)} />
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
<Grids.Stackable className={styles.mobileGrid}>
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { isFacilityCard, setFacilityCardGrids } from "@/utils/facilityCards"
|
||||
|
||||
import CardGrid from "./CardGrid"
|
||||
import ActivitiesCardGrid from "./CardGrid/ActivitiesCardGrid"
|
||||
import FacilitiesCardGrid from "./CardGrid"
|
||||
|
||||
import styles from "./facilities.module.css"
|
||||
|
||||
import type { FacilityProps } from "@/types/components/hotelPage/facilities"
|
||||
import type {
|
||||
Facilities,
|
||||
FacilitiesProps,
|
||||
FacilityCardType,
|
||||
FacilityGrid,
|
||||
} from "@/types/components/hotelPage/facilities"
|
||||
|
||||
export default async function Facilities({
|
||||
facilities,
|
||||
activitiesCard,
|
||||
}: FacilitiesProps) {
|
||||
const intl = await getIntl()
|
||||
|
||||
const facilityCardGrids = setFacilityCardGrids(facilities)
|
||||
|
||||
const translatedFacilityGrids: Facilities = facilityCardGrids.map(
|
||||
(cardGrid: FacilityGrid) => {
|
||||
return cardGrid.map((card: FacilityCardType) => {
|
||||
if (isFacilityCard(card)) {
|
||||
return {
|
||||
...card,
|
||||
heading: intl.formatMessage({ id: card.heading }),
|
||||
secondaryButton: {
|
||||
...card.secondaryButton,
|
||||
title: intl.formatMessage({
|
||||
id: card.secondaryButton.title,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
return card
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export default async function Facilities({ facilities }: FacilityProps) {
|
||||
return (
|
||||
<SectionContainer className={styles.grid}>
|
||||
{facilities.map((facility: any, idx: number) => (
|
||||
<CardGrid key={`grid_${idx}`} facility={facility} />
|
||||
{translatedFacilityGrids.map((cardGrid: FacilityGrid) => (
|
||||
<FacilitiesCardGrid
|
||||
key={cardGrid[0].id}
|
||||
facilitiesCardGrid={cardGrid}
|
||||
/>
|
||||
))}
|
||||
{activitiesCard && <ActivitiesCardGrid {...activitiesCard} />}
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
activities,
|
||||
meetingsAndConferences,
|
||||
restaurantAndBar,
|
||||
wellnessAndExercise,
|
||||
} from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { Facilities } from "@/types/components/hotelPage/facilities"
|
||||
|
||||
const lang = getLang()
|
||||
/*
|
||||
Most of this will be available from the api. Some will need to come from Contentstack. "Activities" will most likely come from Contentstack, which is prepped for.
|
||||
*/
|
||||
export const MOCK_FACILITIES: Facilities = [
|
||||
[
|
||||
{
|
||||
id: "restaurant-and-bar",
|
||||
theme: "primaryDark",
|
||||
scriptedTopTitle: "Restaurant & Bar",
|
||||
heading: "Enjoy relaxed restaurant experience",
|
||||
secondaryButton: {
|
||||
href: `?s=${restaurantAndBar[lang]}`,
|
||||
title: "Read more & book a table",
|
||||
isExternal: false,
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
{
|
||||
backgroundImage: {
|
||||
url: "https://imagevault.scandichotels.com/publishedmedia/79xttlmnum0kjbwhyh18/scandic-helsinki-hub-restaurant-food-tuna.jpg",
|
||||
title: "scandic-helsinki-hub-restaurant-food-tuna.jpg",
|
||||
meta: {
|
||||
alt: "food in restaurant at scandic helsinki hub",
|
||||
caption: "food in restaurant at scandic helsinki hub",
|
||||
},
|
||||
id: 81751,
|
||||
dimensions: {
|
||||
width: 5935,
|
||||
height: 3957,
|
||||
aspectRatio: 1.499873641647713,
|
||||
},
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
{
|
||||
backgroundImage: {
|
||||
url: "https://imagevault.scandichotels.com/publishedmedia/48sb3eyhhzj727l2j1af/Scandic-helsinki-hub-II-centro-41.jpg",
|
||||
meta: {
|
||||
alt: "restaurant il centro at scandic helsinki hu",
|
||||
caption: "restaurant il centro at scandic helsinki hub",
|
||||
},
|
||||
id: 82457,
|
||||
title: "Scandic-helsinki-hub-II-centro-41.jpg",
|
||||
dimensions: {
|
||||
width: 4200,
|
||||
height: 2800,
|
||||
aspectRatio: 1.5,
|
||||
},
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
backgroundImage: {
|
||||
url: "https://imagevault.scandichotels.com/publishedmedia/csef06n329hjfiet1avj/Scandic-spectrum-8.jpg",
|
||||
meta: {
|
||||
alt: "man with a laptop",
|
||||
caption: "man with a laptop",
|
||||
},
|
||||
id: 82713,
|
||||
title: "Scandic-spectrum-8.jpg",
|
||||
dimensions: {
|
||||
width: 7499,
|
||||
height: 4999,
|
||||
aspectRatio: 1.500100020004001,
|
||||
},
|
||||
},
|
||||
columnSpan: "two",
|
||||
},
|
||||
{
|
||||
id: "meetings-and-conferences",
|
||||
theme: "primaryDim",
|
||||
scriptedTopTitle: "Meetings & Conferences",
|
||||
heading: "Events that make an impression",
|
||||
secondaryButton: {
|
||||
href: `?s=${meetingsAndConferences[lang]}`,
|
||||
title: "About meetings & conferences",
|
||||
isExternal: false,
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "wellness-and-exercise",
|
||||
theme: "one",
|
||||
scriptedTopTitle: "Wellness & Exercise",
|
||||
heading: "Sauna and gym",
|
||||
secondaryButton: {
|
||||
href: `?s=${wellnessAndExercise[lang]}`,
|
||||
title: "Read more about wellness & exercise",
|
||||
isExternal: false,
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
{
|
||||
backgroundImage: {
|
||||
url: "https://imagevault.scandichotels.com/publishedmedia/69acct5i3pk5be7d6ub0/scandic-helsinki-hub-sauna.jpg",
|
||||
meta: {
|
||||
alt: "sauna at scandic helsinki hub",
|
||||
caption: "sauna at scandic helsinki hub",
|
||||
},
|
||||
id: 81814,
|
||||
title: "scandic-helsinki-hub-sauna.jpg",
|
||||
dimensions: {
|
||||
width: 4000,
|
||||
height: 2667,
|
||||
aspectRatio: 1.4998125234345707,
|
||||
},
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
{
|
||||
backgroundImage: {
|
||||
url: "https://imagevault.scandichotels.com/publishedmedia/eu70o6z85idy24r92ysf/Scandic-Helsinki-Hub-gym-22.jpg",
|
||||
meta: {
|
||||
alt: "Gym at hotel Scandic Helsinki Hub",
|
||||
caption: "Gym at hotel Scandic Helsinki Hub",
|
||||
},
|
||||
id: 81867,
|
||||
title: "Scandic-Helsinki-Hub-gym-22.jpg",
|
||||
dimensions: {
|
||||
width: 4000,
|
||||
height: 2667,
|
||||
aspectRatio: 1.4998125234345707,
|
||||
},
|
||||
},
|
||||
columnSpan: "one",
|
||||
},
|
||||
],
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Facility } from "@/types/components/hotelPage/facilities"
|
||||
import type { ActivityCard } from "@/types/trpc/routers/contentstack/hotelPage"
|
||||
|
||||
export function setActivityCard(activitiesCard: ActivityCard): Facility {
|
||||
const hasImage = !!activitiesCard.background_image
|
||||
return [
|
||||
{
|
||||
id: "activities",
|
||||
theme: hasImage ? "image" : "primaryDark",
|
||||
scriptedTopTitle: activitiesCard.scripted_title,
|
||||
heading: activitiesCard.heading,
|
||||
bodyText: activitiesCard.body_text,
|
||||
backgroundImage: hasImage ? activitiesCard.background_image : undefined,
|
||||
primaryButton: hasImage
|
||||
? {
|
||||
href: activitiesCard.contentPage.href,
|
||||
title: activitiesCard.cta_text,
|
||||
isExternal: false,
|
||||
}
|
||||
: undefined,
|
||||
secondaryButton: hasImage
|
||||
? undefined
|
||||
: {
|
||||
href: activitiesCard.contentPage.href,
|
||||
title: activitiesCard.cta_text,
|
||||
isExternal: false,
|
||||
},
|
||||
columnSpan: "three",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getCardTheme() {
|
||||
// TODO
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import { RoomCardProps } from "@/types/components/hotelPage/roomCard"
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/roomCard"
|
||||
|
||||
export function RoomCard({
|
||||
badgeTextTransKey,
|
||||
|
||||
@@ -10,10 +10,11 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
import Grids from "@/components/TempDesignSystem/Grids"
|
||||
|
||||
import { RoomCard } from "./RoomCard"
|
||||
import { RoomsProps } from "./types"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import type { RoomsProps } from "./types"
|
||||
|
||||
export function Rooms({ rooms }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(false)
|
||||
|
||||
@@ -6,21 +6,38 @@ import useHash from "@/hooks/useHash"
|
||||
|
||||
import styles from "./tabNavigation.module.css"
|
||||
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
import {
|
||||
HotelHashValues,
|
||||
type TabNavigationProps,
|
||||
} from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export default function TabNavigation() {
|
||||
export default function TabNavigation({ restaurantTitle }: TabNavigationProps) {
|
||||
const hash = useHash()
|
||||
const intl = useIntl()
|
||||
|
||||
const hotelTabLinks: { href: HotelHashValues; text: string }[] = [
|
||||
// TODO these titles will need to reflect the facility card titles, which will vary between hotels
|
||||
{ href: HotelHashValues.overview, text: "Overview" },
|
||||
{ href: HotelHashValues.rooms, text: "Rooms" },
|
||||
{ href: HotelHashValues.restaurant, text: "Restaurant & Bar" },
|
||||
{ href: HotelHashValues.meetings, text: "Meetings & Conferences" },
|
||||
{ href: HotelHashValues.wellness, text: "Wellness & Exercise" },
|
||||
{ href: HotelHashValues.activities, text: "Activities" },
|
||||
{ href: HotelHashValues.faq, text: "FAQ" },
|
||||
const hotelTabLinks: { href: HotelHashValues | string; text: string }[] = [
|
||||
{
|
||||
href: HotelHashValues.overview,
|
||||
text: intl.formatMessage({ id: "Overview" }),
|
||||
},
|
||||
{ href: HotelHashValues.rooms, text: intl.formatMessage({ id: "Rooms" }) },
|
||||
{
|
||||
href: HotelHashValues.restaurant,
|
||||
text: intl.formatMessage({ id: restaurantTitle }, { count: 1 }),
|
||||
},
|
||||
{
|
||||
href: HotelHashValues.meetings,
|
||||
text: intl.formatMessage({ id: "Meetings & Conferences" }),
|
||||
},
|
||||
{
|
||||
href: HotelHashValues.wellness,
|
||||
text: intl.formatMessage({ id: "Wellness & Exercise" }),
|
||||
},
|
||||
{
|
||||
href: HotelHashValues.activities,
|
||||
text: intl.formatMessage({ id: "Activities" }),
|
||||
},
|
||||
{ href: HotelHashValues.faq, text: intl.formatMessage({ id: "FAQ" }) },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,9 +6,8 @@ import SidePeekProvider from "@/components/SidePeekProvider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { getRestaurantHeading } from "@/utils/facilityCards"
|
||||
|
||||
import { MOCK_FACILITIES } from "./Facilities/mockData"
|
||||
import { setActivityCard } from "./Facilities/utils"
|
||||
import DynamicMap from "./Map/DynamicMap"
|
||||
import MapCard from "./Map/MapCard"
|
||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||
@@ -45,10 +44,9 @@ export default async function HotelPage() {
|
||||
roomCategories,
|
||||
activitiesCard,
|
||||
pointsOfInterest,
|
||||
facilities,
|
||||
} = hotelData
|
||||
|
||||
const facilities = [...MOCK_FACILITIES]
|
||||
activitiesCard && facilities.push(setActivityCard(activitiesCard))
|
||||
const topThreePois = pointsOfInterest.slice(0, 3)
|
||||
|
||||
const coordinates = {
|
||||
@@ -61,7 +59,9 @@ export default async function HotelPage() {
|
||||
<div className={styles.hotelImages}>
|
||||
<PreviewImages images={hotelImages} hotelName={hotelName} />
|
||||
</div>
|
||||
<TabNavigation />
|
||||
<TabNavigation
|
||||
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
|
||||
/>
|
||||
<main className={styles.mainSection}>
|
||||
<div className={styles.introContainer}>
|
||||
<IntroSection
|
||||
@@ -119,7 +119,7 @@ export default async function HotelPage() {
|
||||
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
</main>
|
||||
{googleMapsApiKey ? (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
}
|
||||
|
||||
.container[data-isopen="true"] .hideWrapper {
|
||||
top: 0;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
components/Forms/BookingWidget/FormContent/Input/index.tsx
Normal file
18
components/Forms/BookingWidget/FormContent/Input/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React, { forwardRef, InputHTMLAttributes } from "react"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./input.module.css"
|
||||
|
||||
const Input = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputHTMLAttributes<HTMLInputElement>
|
||||
>(function InputComponent(props, ref) {
|
||||
return (
|
||||
<Body asChild>
|
||||
<input {...props} ref={ref} className={styles.input} />
|
||||
</Body>
|
||||
)
|
||||
})
|
||||
|
||||
export default Input
|
||||
@@ -0,0 +1,22 @@
|
||||
.input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("/_static/icons/close.svg");
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input:disabled::placeholder {
|
||||
color: var(--Base-Text-Disabled);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { ClearSearchButtonProps } from "@/types/components/search"
|
||||
|
||||
export default function ClearSearchButton({
|
||||
getItemProps,
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
index,
|
||||
}: ClearSearchButtonProps) {
|
||||
@@ -18,13 +19,6 @@ export default function ClearSearchButton({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
// noop
|
||||
// the click bubbles to handleOnSelect
|
||||
// where selectedItem = "clear-search"
|
||||
// which is the value for item below
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...getItemProps({
|
||||
@@ -34,7 +28,7 @@ export default function ClearSearchButton({
|
||||
item: "clear-search",
|
||||
role: "button",
|
||||
})}
|
||||
onClick={handleClick}
|
||||
onClick={handleClearSearchHistory}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { SearchListProps } from "@/types/components/search"
|
||||
export default function SearchList({
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
locations,
|
||||
@@ -125,6 +126,7 @@ export default function SearchList({
|
||||
<Divider className={styles.divider} color="beige" />
|
||||
<ClearSearchButton
|
||||
getItemProps={getItemProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={searchHistory.length}
|
||||
/>
|
||||
@@ -161,6 +163,7 @@ export default function SearchList({
|
||||
<Divider className={styles.divider} color="beige" />
|
||||
<ClearSearchButton
|
||||
getItemProps={getItemProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={searchHistory.length}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useIntl } from "react-intl"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Input from "../Input"
|
||||
import { init, localStorageKey, reducer, sessionStorageKey } from "./reducer"
|
||||
import SearchList from "./SearchList"
|
||||
|
||||
@@ -43,6 +44,11 @@ export default function Search({ locations }: SearchProps) {
|
||||
[locations]
|
||||
)
|
||||
|
||||
function handleClearSearchHistory() {
|
||||
localStorage.removeItem(localStorageKey)
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
}
|
||||
|
||||
function handleOnBlur() {
|
||||
if (!value && state.searchData?.name) {
|
||||
setValue(name, state.searchData.name)
|
||||
@@ -79,11 +85,8 @@ export default function Search({ locations }: SearchProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnSelect(selectedItem: Location | null | "clear-search") {
|
||||
if (selectedItem === "clear-search") {
|
||||
localStorage.removeItem(localStorageKey)
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
} else if (selectedItem) {
|
||||
function handleOnSelect(selectedItem: Location | null) {
|
||||
if (selectedItem) {
|
||||
const stringified = JSON.stringify(selectedItem)
|
||||
setValue("location", encodeURIComponent(stringified))
|
||||
sessionStorage.setItem(sessionStorageKey, stringified)
|
||||
@@ -140,33 +143,31 @@ export default function Search({ locations }: SearchProps) {
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<Body asChild>
|
||||
<input
|
||||
{...getInputProps({
|
||||
className: styles.input,
|
||||
id: name,
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
openMenu()
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: name,
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Destinations & hotels",
|
||||
}),
|
||||
...register(name, {
|
||||
onBlur: function () {
|
||||
handleOnBlur()
|
||||
closeMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Destinations & hotels",
|
||||
}),
|
||||
...register(name, {
|
||||
onBlur: function () {
|
||||
handleOnBlur()
|
||||
closeMenu()
|
||||
},
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
</Body>
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<SearchList
|
||||
getItemProps={getItemProps}
|
||||
getMenuProps={getMenuProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isOpen={isOpen}
|
||||
locations={state.locations}
|
||||
|
||||
@@ -7,42 +7,24 @@
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
.container:has(.input:active, .input:focus, .input:focus-within) {
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.container:has(.input:active, .input:focus, .input:focus-within) {
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
|
||||
}
|
||||
|
||||
.label:has(
|
||||
~ .inputContainer .input:active,
|
||||
~ .inputContainer .input:focus,
|
||||
~ .inputContainer .input:focus-within
|
||||
~ .inputContainer input:active,
|
||||
~ .inputContainer input:focus,
|
||||
~ .inputContainer input:focus-within
|
||||
)
|
||||
p {
|
||||
color: var(--UI-Text-Active);
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("/_static/icons/close.svg");
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.container:hover:has(.input:not(:active, :focus, :focus-within))
|
||||
.input::-webkit-search-cancel-button {
|
||||
.container:hover:has(input:not(:active, :focus, :focus-within))
|
||||
input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
71
components/Forms/BookingWidget/FormContent/Voucher/index.tsx
Normal file
71
components/Forms/BookingWidget/FormContent/Voucher/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
|
||||
|
||||
import Input from "../Input"
|
||||
|
||||
import styles from "./voucher.module.css"
|
||||
|
||||
export default function Voucher() {
|
||||
const intl = useIntl()
|
||||
|
||||
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const useVouchers = intl.formatMessage({ id: "Use code/voucher" })
|
||||
const addVouchers = intl.formatMessage({ id: "Add code" })
|
||||
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book reward night" })
|
||||
const disabledBookingOptionsHeader = intl.formatMessage({
|
||||
id: "Disabled booking options header",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Disabled booking options text",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={styles.optionsContainer}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<div className={styles.vouchers}>
|
||||
<label>
|
||||
<Caption color="disabled" textTransform="bold">
|
||||
{vouchers}
|
||||
</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<Input type="text" placeholder={addVouchers} disabled />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<div className={styles.options}>
|
||||
<label className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<input type="checkbox" disabled className={styles.checkbox} />
|
||||
<Caption color="disabled">{useVouchers}</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" disabled className={styles.checkbox} />
|
||||
<Caption color="disabled">{bonus}</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" disabled className={styles.checkbox} />
|
||||
<Caption color="disabled">{reward}</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-top: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
}
|
||||
.vouchers {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.checkboxVoucher {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.vouchers {
|
||||
display: none;
|
||||
}
|
||||
.options {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
.option {
|
||||
margin-top: 0;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
.checkboxVoucher {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.vouchers {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.vouchers {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
}
|
||||
.options {
|
||||
flex-direction: column;
|
||||
max-width: 190px;
|
||||
gap: 0;
|
||||
}
|
||||
.vouchers:hover,
|
||||
.option:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.optionsContainer {
|
||||
flex-direction: row;
|
||||
}
|
||||
.checkboxVoucher {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
.infoIcon {
|
||||
stroke: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
.option {
|
||||
.vouchersHeader {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.input {
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.icon,
|
||||
.voucherRow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.voucherContainer {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1367px) {
|
||||
.inputContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -29,52 +42,85 @@
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.options {
|
||||
gap: var(--Spacing-x2);
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.option {
|
||||
gap: var(--Spacing-x2);
|
||||
.button {
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@media screen and (min-width: 768px) {
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
flex: 2;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
.voucherContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when,
|
||||
.where {
|
||||
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input input[type="text"] {
|
||||
.inputContainer input[type="text"] {
|
||||
border: none;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
max-width: 240px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.vouchers {
|
||||
max-width: 200px;
|
||||
padding: var(--Spacing-x1) 0;
|
||||
.when:hover,
|
||||
.rooms:hover,
|
||||
.rooms:has(.input:active, .input:focus, .input:focus-within) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.where {
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.options {
|
||||
max-width: 158px;
|
||||
.button {
|
||||
justify-content: center;
|
||||
width: 118px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1366px) {
|
||||
.inputContainer {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2);
|
||||
}
|
||||
.buttonContainer {
|
||||
padding-right: var(--Spacing-x2);
|
||||
}
|
||||
.input .buttonContainer .button {
|
||||
padding: var(--Spacing-x1);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.buttonText {
|
||||
display: none;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.voucherRow {
|
||||
display: flex;
|
||||
background: var(--Base-Surface-Primary-light-Hover);
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
.voucherContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import { useIntl } from "react-intl"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import DatePicker from "@/components/DatePicker"
|
||||
import { SearchIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Input from "./Input"
|
||||
import Search from "./Search"
|
||||
import Voucher from "./Voucher"
|
||||
|
||||
import styles from "./formContent.module.css"
|
||||
|
||||
@@ -15,53 +20,69 @@ import type { BookingWidgetFormContentProps } from "@/types/components/form/book
|
||||
|
||||
export default function FormContent({
|
||||
locations,
|
||||
formId,
|
||||
formState,
|
||||
}: BookingWidgetFormContentProps) {
|
||||
const intl = useIntl()
|
||||
const selectedDate = useWatch({ name: "date" })
|
||||
|
||||
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
|
||||
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book reward night" })
|
||||
|
||||
const nights = dt(selectedDate.to).diff(dt(selectedDate.from), "days")
|
||||
|
||||
return (
|
||||
<div className={styles.input}>
|
||||
<div className={styles.where}>
|
||||
<Search locations={locations} />
|
||||
<>
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<Search locations={locations} />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<DatePicker />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<label>
|
||||
<Caption color="red" textTransform="bold">
|
||||
{rooms}
|
||||
</Caption>
|
||||
</label>
|
||||
<Input type="text" placeholder={rooms} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.voucherContainer}>
|
||||
<Voucher />
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!formState.isValid}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
<Caption
|
||||
color="white"
|
||||
textTransform="bold"
|
||||
className={styles.buttonText}
|
||||
>
|
||||
{intl.formatMessage({ id: "Search" })}
|
||||
</Caption>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" width={28} height={28} />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<DatePicker />
|
||||
<div className={styles.voucherRow}>
|
||||
<Voucher />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<Caption color="red" textTransform="bold">
|
||||
{rooms}
|
||||
</Caption>
|
||||
<input type="text" placeholder={rooms} />
|
||||
</div>
|
||||
<div className={styles.vouchers}>
|
||||
<Caption color="uiTextMediumContrast" textTransform="bold">
|
||||
{vouchers}
|
||||
</Caption>
|
||||
<input type="text" placeholder={vouchers} />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" />
|
||||
<Caption color="textMediumContrast">{bonus}</Caption>
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" />
|
||||
<Caption color="textMediumContrast">{reward}</Caption>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,29 +8,31 @@
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
@media screen and (max-width: 767px) {
|
||||
.form {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@media screen and (min-width: 768px) {
|
||||
.section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
width: 118px;
|
||||
.default {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.default {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
|
||||
var(--Spacing-x-one-and-half) var(--Spacing-x1);
|
||||
}
|
||||
.full {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
import { bookingWidgetVariants } from "./variants"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
@@ -15,10 +12,13 @@ import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidg
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
export default function Form({ locations }: BookingWidgetFormProps) {
|
||||
const intl = useIntl()
|
||||
export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const classNames = bookingWidgetVariants({
|
||||
type,
|
||||
})
|
||||
|
||||
const { formState, handleSubmit, register } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
|
||||
@@ -31,28 +31,19 @@ export default function Form({ locations }: BookingWidgetFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<section className={classNames}>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id={formId}
|
||||
>
|
||||
<input {...register("location")} type="hidden" />
|
||||
<FormContent locations={locations} />
|
||||
<FormContent
|
||||
locations={locations}
|
||||
formId={formId}
|
||||
formState={formState}
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!formState.isValid}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
size="small"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
<Caption color="white" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Search" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
15
components/Forms/BookingWidget/variants.ts
Normal file
15
components/Forms/BookingWidget/variants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
export const bookingWidgetVariants = cva(styles.section, {
|
||||
variants: {
|
||||
type: {
|
||||
default: styles.default,
|
||||
full: styles.full,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "full",
|
||||
},
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import useDropdownStore from "@/stores/main-menu"
|
||||
import { GiftIcon, SearchIcon, ServiceIcon } from "@/components/Icons"
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import useMediaQuery from "@/hooks/useMediaQuery"
|
||||
|
||||
import HeaderLink from "../../HeaderLink"
|
||||
|
||||
@@ -37,6 +38,13 @@ export default function MobileMenu({
|
||||
isHeaderLanguageSwitcherMobileOpen ||
|
||||
isFooterLanguageSwitcherOpen
|
||||
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
useEffect(() => {
|
||||
if (isAboveMobile && isHamburgerMenuOpen) {
|
||||
toggleDropdown(DropdownTypeEnum.HamburgerMenu)
|
||||
}
|
||||
}, [isAboveMobile, isHamburgerMenuOpen, toggleDropdown])
|
||||
|
||||
useHandleKeyUp((event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isHamburgerMenuOpen) {
|
||||
toggleDropdown(DropdownTypeEnum.HamburgerMenu)
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.hamburger {
|
||||
.hamburger,
|
||||
.modal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { ChevronDownIcon } from "@/components/Icons"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import { getInitials } from "@/utils/user"
|
||||
|
||||
@@ -22,8 +24,10 @@ export default function MyPagesMenu({
|
||||
membership,
|
||||
navigation,
|
||||
user,
|
||||
membershipLevel,
|
||||
}: MyPagesMenuProps) {
|
||||
const intl = useIntl()
|
||||
const myPagesMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { toggleDropdown, isMyPagesMenuOpen } = useDropdownStore()
|
||||
|
||||
@@ -33,8 +37,12 @@ export default function MyPagesMenu({
|
||||
}
|
||||
})
|
||||
|
||||
useClickOutside(myPagesMenuRef, isMyPagesMenuOpen, () => {
|
||||
toggleDropdown(DropdownTypeEnum.MyPagesMenu)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={styles.myPagesMenu}>
|
||||
<div className={styles.myPagesMenu} ref={myPagesMenuRef}>
|
||||
<MainMenuButton
|
||||
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
|
||||
>
|
||||
@@ -50,6 +58,7 @@ export default function MyPagesMenu({
|
||||
{isMyPagesMenuOpen ? (
|
||||
<div className={styles.dropdown}>
|
||||
<MyPagesMenuContent
|
||||
membershipLevel={membershipLevel}
|
||||
navigation={navigation}
|
||||
user={user}
|
||||
membership={membership}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import { logout } from "@/constants/routes/handleAuth"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
@@ -23,18 +21,12 @@ export default function MyPagesMenuContent({
|
||||
navigation,
|
||||
toggleOpenStateFn,
|
||||
user,
|
||||
membershipLevel,
|
||||
}: MyPagesMenuContentProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const myPagesMenuContentRef = useTrapFocus()
|
||||
|
||||
const membershipLevel = trpc.contentstack.loyaltyLevels.byLevel.useQuery(
|
||||
{
|
||||
level: MembershipLevelEnum[membership?.membershipLevel!],
|
||||
},
|
||||
{ enabled: !!membership?.membershipLevel }
|
||||
).data
|
||||
|
||||
const membershipPoints = membership?.currentPoints
|
||||
const introClassName =
|
||||
membershipLevel && membershipPoints
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import { myPages } from "@/constants/routes/myPages"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
@@ -20,16 +21,24 @@ export default async function MyPagesMenuWrapper() {
|
||||
serverClient().user.safeMembershipLevel(),
|
||||
])
|
||||
|
||||
const membershipLevel = membership?.membershipLevel
|
||||
? await serverClient().contentstack.loyaltyLevels.byLevel({
|
||||
level: MembershipLevelEnum[membership.membershipLevel],
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
{user ? (
|
||||
<>
|
||||
<MyPagesMenu
|
||||
membershipLevel={membershipLevel}
|
||||
membership={membership}
|
||||
navigation={myPagesNavigation}
|
||||
user={user}
|
||||
/>
|
||||
<MyPagesMobileMenu
|
||||
membershipLevel={membershipLevel}
|
||||
membership={membership}
|
||||
navigation={myPagesNavigation}
|
||||
user={user}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useIntl } from "react-intl"
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import useMediaQuery from "@/hooks/useMediaQuery"
|
||||
import { getInitials } from "@/utils/user"
|
||||
|
||||
import Avatar from "../Avatar"
|
||||
@@ -19,6 +20,7 @@ import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
|
||||
import type { MyPagesMenuProps } from "@/types/components/header/myPagesMenu"
|
||||
|
||||
export default function MyPagesMobileMenu({
|
||||
membershipLevel,
|
||||
membership,
|
||||
navigation,
|
||||
user,
|
||||
@@ -32,6 +34,13 @@ export default function MyPagesMobileMenu({
|
||||
}
|
||||
})
|
||||
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
useEffect(() => {
|
||||
if (isAboveMobile && isMyPagesMobileMenuOpen) {
|
||||
toggleDropdown(DropdownTypeEnum.MyPagesMobileMenu)
|
||||
}
|
||||
}, [isAboveMobile, isMyPagesMobileMenuOpen, toggleDropdown])
|
||||
|
||||
// Making sure the menu is always opened at the top of the page, just below the header.
|
||||
useEffect(() => {
|
||||
if (isMyPagesMobileMenuOpen) {
|
||||
@@ -54,6 +63,7 @@ export default function MyPagesMobileMenu({
|
||||
aria-label={intl.formatMessage({ id: "My pages menu" })}
|
||||
>
|
||||
<MyPagesMenuContent
|
||||
membershipLevel={membershipLevel}
|
||||
membership={membership}
|
||||
navigation={navigation}
|
||||
user={user}
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function MegaMenu({
|
||||
scriptedTopTitle={card.scripted_top_title}
|
||||
onPrimaryButtonClick={handleNavigate}
|
||||
onSecondaryButtonClick={handleNavigate}
|
||||
imageGradient
|
||||
theme="image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
|
||||
import MainMenuButton from "../../MainMenuButton"
|
||||
@@ -15,8 +18,10 @@ import type { NavigationMenuItemProps } from "@/types/components/header/navigati
|
||||
|
||||
export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
|
||||
const { openMegaMenu, toggleMegaMenu } = useDropdownStore()
|
||||
const megaMenuRef = useRef<HTMLDivElement>(null)
|
||||
const { submenu, title, link, seeAllLink, card } = item
|
||||
const isMegaMenuOpen = openMegaMenu === title
|
||||
const megaMenuTitle = `${title}-${isMobile ? "mobile" : "desktop"}`
|
||||
const isMegaMenuOpen = openMegaMenu === megaMenuTitle
|
||||
|
||||
useHandleKeyUp((event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isMegaMenuOpen) {
|
||||
@@ -24,10 +29,14 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
|
||||
}
|
||||
})
|
||||
|
||||
useClickOutside(megaMenuRef, isMegaMenuOpen && !isMobile, () => {
|
||||
toggleMegaMenu(false)
|
||||
})
|
||||
|
||||
return submenu.length ? (
|
||||
<>
|
||||
<MainMenuButton
|
||||
onClick={() => toggleMegaMenu(title)}
|
||||
onClick={() => toggleMegaMenu(megaMenuTitle)}
|
||||
className={`${styles.navigationMenuItem} ${isMobile ? styles.mobile : styles.desktop}`}
|
||||
>
|
||||
{title}
|
||||
@@ -41,6 +50,7 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
|
||||
)}
|
||||
</MainMenuButton>
|
||||
<div
|
||||
ref={megaMenuRef}
|
||||
className={`${styles.dropdown} ${isMegaMenuOpen ? styles.isExpanded : ""}`}
|
||||
>
|
||||
{isMegaMenuOpen ? (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./introSection.module.css"
|
||||
|
||||
import { IntroSectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function IntroSection({ email }: IntroSectionProps) {
|
||||
const intl = await getIntl()
|
||||
export default function IntroSection({ email }: IntroSectionProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ArrowRightIcon, ScandicLogoIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./staySection.module.css"
|
||||
|
||||
import { StaySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function StaySection({ hotel, stay }: StaySectionProps) {
|
||||
const intl = await getIntl()
|
||||
export default function StaySection({ hotel, stay }: StaySectionProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const nightsText =
|
||||
stay.nights > 1
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./summarySection.module.css"
|
||||
|
||||
import { SummarySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function SummarySection({ summary }: SummarySectionProps) {
|
||||
const intl = await getIntl()
|
||||
export default function SummarySection({ summary }: SummarySectionProps) {
|
||||
const intl = useIntl()
|
||||
const roomType = `${intl.formatMessage({ id: "Type of room" })}: ${summary.roomType}`
|
||||
const bedType = `${intl.formatMessage({ id: "Type of bed" })}: ${summary.bedType}`
|
||||
const breakfast = `${intl.formatMessage({ id: "Breakfast" })}: ${summary.breakfast}`
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
72
components/HotelReservation/EnterDetails/BedType/index.tsx
Normal file
72
components/HotelReservation/EnterDetails/BedType/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { KingBedIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio"
|
||||
|
||||
import { bedTypeSchema } from "./schema"
|
||||
|
||||
import styles from "./bedOptions.module.css"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/enterDetails/bedType"
|
||||
import { bedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export default function BedType() {
|
||||
const intl = useIntl()
|
||||
|
||||
const methods = useForm<BedTypeSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
// @ts-expect-error - Types mismatch docs as this is
|
||||
// a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage
|
||||
const text = intl.formatMessage(
|
||||
{ id: "<b>Included</b> (based on availability)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form}>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={bedTypeEnum.KING}
|
||||
name="bed"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
length: "210",
|
||||
width: "180",
|
||||
}
|
||||
)}
|
||||
text={text}
|
||||
title={intl.formatMessage({ id: "King bed" })}
|
||||
value={bedTypeEnum.KING}
|
||||
/>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={bedTypeEnum.QUEEN}
|
||||
name="bed"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
length: "200",
|
||||
width: "160",
|
||||
}
|
||||
)}
|
||||
text={text}
|
||||
title={intl.formatMessage({ id: "Queen bed" })}
|
||||
value={bedTypeEnum.QUEEN}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { bedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bed: z.nativeEnum(bedTypeEnum),
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
70
components/HotelReservation/EnterDetails/Breakfast/index.tsx
Normal file
70
components/HotelReservation/EnterDetails/Breakfast/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio"
|
||||
|
||||
import { breakfastSchema } from "./schema"
|
||||
|
||||
import styles from "./breakfast.module.css"
|
||||
|
||||
import type { BreakfastSchema } from "@/types/components/enterDetails/breakfast"
|
||||
import { breakfastEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Breakfast() {
|
||||
const intl = useIntl()
|
||||
|
||||
const methods = useForm<BreakfastSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form}>
|
||||
<RadioCard
|
||||
Icon={BreakfastIcon}
|
||||
id={breakfastEnum.BREAKFAST}
|
||||
name="breakfast"
|
||||
// @ts-expect-error - Types mismatch docs as this is
|
||||
// a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "<b>{amount} {currency}</b>/night per adult" },
|
||||
{
|
||||
amount: "150",
|
||||
b: (str) => <b>{str}</b>,
|
||||
currency: "SEK",
|
||||
}
|
||||
)}
|
||||
text={intl.formatMessage({
|
||||
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
value={breakfastEnum.BREAKFAST}
|
||||
/>
|
||||
<RadioCard
|
||||
Icon={NoBreakfastIcon}
|
||||
id={breakfastEnum.NO_BREAKFAST}
|
||||
name="breakfast"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: "0",
|
||||
currency: "SEK",
|
||||
}
|
||||
)}
|
||||
text={intl.formatMessage({
|
||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "No breakfast" })}
|
||||
value={breakfastEnum.NO_BREAKFAST}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { breakfastEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export const breakfastSchema = z.object({
|
||||
breakfast: z.nativeEnum(breakfastEnum),
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) 0px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
|
||||
.country,
|
||||
.email,
|
||||
.phone {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
justify-items: flex-start;
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
118
components/HotelReservation/EnterDetails/Details/index.tsx
Normal file
118
components/HotelReservation/EnterDetails/Details/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CheckboxCard from "@/components/TempDesignSystem/Form/Card/Checkbox"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { detailsSchema, signedInDetailsSchema } from "./schema"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type {
|
||||
DetailsProps,
|
||||
DetailsSchema,
|
||||
} from "@/types/components/enterDetails/details"
|
||||
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const list = [
|
||||
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
||||
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
||||
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
||||
]
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
defaultValues: {
|
||||
countryCode: user?.address?.countryCode ?? "",
|
||||
email: user?.email ?? "",
|
||||
firstname: user?.firstName ?? "",
|
||||
lastname: user?.lastName ?? "",
|
||||
phoneNumber: user?.phoneNumber ?? "",
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : detailsSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section className={styles.container}>
|
||||
<header>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Body>
|
||||
</header>
|
||||
<form className={styles.form}>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Firstname" })}
|
||||
name="firstname"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Lastname" })}
|
||||
name="lastname"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.country}
|
||||
label={intl.formatMessage({ id: "Country" })}
|
||||
name="countryCode"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
className={styles.email}
|
||||
label={intl.formatMessage({ id: "Email address" })}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Phone
|
||||
className={styles.phone}
|
||||
label={intl.formatMessage({ id: "Phone number" })}
|
||||
name="phoneNumber"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</form>
|
||||
<footer className={styles.footer}>
|
||||
{user ? null : (
|
||||
<CheckboxCard
|
||||
list={list}
|
||||
saving
|
||||
subtitle={intl.formatMessage(
|
||||
{
|
||||
id: "{difference}{amount} {currency}",
|
||||
},
|
||||
{
|
||||
amount: "491",
|
||||
currency: "SEK",
|
||||
difference: "-",
|
||||
}
|
||||
)}
|
||||
title={intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={!methods.formState.isValid}
|
||||
intent="secondary"
|
||||
size="small"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Proceed to payment method" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
19
components/HotelReservation/EnterDetails/Details/schema.ts
Normal file
19
components/HotelReservation/EnterDetails/Details/schema.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
export const detailsSchema = z.object({
|
||||
countryCode: z.string(),
|
||||
email: z.string().email(),
|
||||
firstname: z.string(),
|
||||
lastname: z.string(),
|
||||
phoneNumber: phoneValidator(),
|
||||
})
|
||||
|
||||
export const signedInDetailsSchema = z.object({
|
||||
countryCode: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
firstname: z.string().optional(),
|
||||
lastname: z.string().optional(),
|
||||
phoneNumber: phoneValidator().optional(),
|
||||
})
|
||||
@@ -48,10 +48,10 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
<Title as="h4" textTransform="capitalize">
|
||||
{hotelData.name}
|
||||
</Title>
|
||||
<Footnote color="textMediumContrast">
|
||||
<Footnote color="uiTextMediumContrast">
|
||||
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
|
||||
</Footnote>
|
||||
<Footnote color="textMediumContrast">
|
||||
<Footnote color="uiTextMediumContrast">
|
||||
{`${hotelData.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
</Footnote>
|
||||
</section>
|
||||
@@ -79,7 +79,7 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
{price?.regularAmount} {price?.currency} /
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</Caption>
|
||||
<Footnote color="textMediumContrast">approx 280 eur</Footnote>
|
||||
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
|
||||
</div>
|
||||
<div>
|
||||
<Chip intent="primary" className={styles.member}>
|
||||
@@ -90,7 +90,7 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
{price?.memberAmount} {price?.currency} /
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</Caption>
|
||||
<Footnote color="textMediumContrast">approx 280 eur</Footnote>
|
||||
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import HotelDetailSidePeek from "./HotelDetailSidePeek"
|
||||
|
||||
@@ -10,10 +12,10 @@ import styles from "./hotelSelectionHeader.module.css"
|
||||
|
||||
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
|
||||
|
||||
export default async function HotelSelectionHeader({
|
||||
export default function HotelSelectionHeader({
|
||||
hotel,
|
||||
}: HotelSelectionHeaderProps) {
|
||||
const intl = await getIntl()
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<header className={styles.hotelSelectionHeader}>
|
||||
|
||||
@@ -1,62 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
BookingStatusEnum,
|
||||
} from "@/constants/booking"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 40
|
||||
const retryInterval = 2000
|
||||
|
||||
export default function Payment({ hotel }: PaymentProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>("")
|
||||
|
||||
export default function Payment() {
|
||||
const initiateBooking = trpc.booking.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
// TODO: Handle success, poll for payment link and redirect the user to the payment
|
||||
console.log("Res", result)
|
||||
if (result?.confirmationNumber) {
|
||||
// Planet doesn't support query params so we have to store values in session storage
|
||||
sessionStorage.setItem(
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
result.confirmationNumber
|
||||
)
|
||||
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// TODO: Handle error
|
||||
console.log("Error")
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
// TODO: Use real values
|
||||
initiateBooking.mutate({
|
||||
hotelId: "811",
|
||||
checkInDate: "2024-12-10",
|
||||
checkOutDate: "2024-12-11",
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
children: 0,
|
||||
rateCode: "SAVEEU",
|
||||
roomTypeCode: "QC",
|
||||
guest: {
|
||||
title: "Mr",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "test.user@scandichotels.com",
|
||||
phoneCountryCodePrefix: "string",
|
||||
phoneNumber: "string",
|
||||
countryCode: "string",
|
||||
},
|
||||
smsConfirmationRequested: true,
|
||||
},
|
||||
],
|
||||
payment: {
|
||||
cardHolder: {
|
||||
Email: "test.user@scandichotels.com",
|
||||
Name: "Test User",
|
||||
PhoneCountryCode: "",
|
||||
PhoneSubscriber: "",
|
||||
},
|
||||
success: "success/handle",
|
||||
error: "error/handle",
|
||||
cancel: "cancel/handle",
|
||||
const bookingStatus = useHandleBookingStatus(
|
||||
confirmationNumber,
|
||||
BookingStatusEnum.PaymentRegistered,
|
||||
maxRetries,
|
||||
retryInterval
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
}
|
||||
}, [bookingStatus, router])
|
||||
|
||||
function handleSubmit() {
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel.operaId,
|
||||
checkInDate: "2024-12-10",
|
||||
checkOutDate: "2024-12-11",
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
childrenAges: [],
|
||||
rateCode: "SAVEEU",
|
||||
roomTypeCode: "QC",
|
||||
guest: {
|
||||
title: "Mr",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "test.user@scandichotels.com",
|
||||
phoneCountryCodePrefix: "string",
|
||||
phoneNumber: "string",
|
||||
countryCode: "string",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
packages: {
|
||||
breakfast: true,
|
||||
allergyFriendly: true,
|
||||
petFriendly: true,
|
||||
accessibility: true,
|
||||
},
|
||||
smsConfirmationRequested: true,
|
||||
},
|
||||
],
|
||||
payment: {
|
||||
paymentMethod: selectedPaymentMethod,
|
||||
cardHolder: {
|
||||
email: "test.user@scandichotels.com",
|
||||
name: "Test User",
|
||||
phoneCountryCode: "",
|
||||
phoneSubscriber: "",
|
||||
},
|
||||
success: `api/web/payment-callback/${lang}/success`,
|
||||
error: `api/web/payment-callback/${lang}/error`,
|
||||
cancel: `api/web/payment-callback/${lang}/cancel`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className={styles.paymentItemContainer}>
|
||||
<button
|
||||
className={styles.paymentItem}
|
||||
onClick={() => setSelectedPaymentMethod("card")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
id="card"
|
||||
value="card"
|
||||
checked={selectedPaymentMethod === "card"}
|
||||
/>
|
||||
<label htmlFor="card">card</label>
|
||||
</button>
|
||||
{hotel.merchantInformationData.alternatePaymentOptions.map(
|
||||
(paymentOption) => (
|
||||
<button
|
||||
key={paymentOption}
|
||||
className={styles.paymentItem}
|
||||
onClick={() => setSelectedPaymentMethod(paymentOption)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
id={paymentOption}
|
||||
value={paymentOption}
|
||||
checked={selectedPaymentMethod === paymentOption}
|
||||
/>
|
||||
<label htmlFor={paymentOption}>{paymentOption}</label>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button disabled={!selectedPaymentMethod} onClick={handleSubmit}>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.paymentItemContainer {
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.paymentItem {
|
||||
background-color: var(--Base-Background-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import RoomCard from "./RoomCard"
|
||||
|
||||
import styles from "./roomSelection.module.css"
|
||||
|
||||
import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
|
||||
export default function RoomSelection({
|
||||
alternatives,
|
||||
nextPath,
|
||||
rates,
|
||||
nrOfNights,
|
||||
nrOfAdults,
|
||||
}: RoomSelectionProps) {
|
||||
@@ -21,17 +20,17 @@ export default function RoomSelection({
|
||||
const queryParams = new URLSearchParams(searchParams)
|
||||
queryParams.set("roomClass", e.currentTarget.roomClass?.value)
|
||||
queryParams.set("flexibility", e.currentTarget.flexibility?.value)
|
||||
router.push(`${nextPath}?${queryParams}`)
|
||||
router.push(`select-bed?${queryParams}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<ul className={styles.roomList}>
|
||||
{alternatives.map((room) => (
|
||||
{rates.map((room) => (
|
||||
<li key={room.id}>
|
||||
<form
|
||||
method="GET"
|
||||
action={`${nextPath}?${searchParams}`}
|
||||
action={`select-bed?${searchParams}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
@@ -50,6 +49,7 @@ export default function RoomSelection({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.summary}>This is summary</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,3 +21,10 @@
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,91 @@
|
||||
import { CheckCircleIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
"use client"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
|
||||
export default async function SectionAccordion({
|
||||
export default function SectionAccordion({
|
||||
header,
|
||||
selection,
|
||||
isOpen,
|
||||
isCompleted,
|
||||
label,
|
||||
path,
|
||||
children,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = await getIntl()
|
||||
const intl = useIntl()
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const circleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const content = contentRef.current
|
||||
const circle = circleRef.current
|
||||
if (content) {
|
||||
if (isOpen) {
|
||||
content.style.maxHeight = `${content.scrollHeight}px`
|
||||
} else {
|
||||
content.style.maxHeight = "0"
|
||||
}
|
||||
}
|
||||
|
||||
if (circle) {
|
||||
if (isOpen) {
|
||||
circle.style.backgroundColor = `var(--UI-Text-Placeholder);`
|
||||
} else {
|
||||
circle.style.backgroundColor = `var(--Base-Surface-Subtle-Hover);`
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.top}>
|
||||
<div>
|
||||
<CheckCircleIcon color={selection ? "green" : "pale"} />
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
<Caption color={"burgundy"} asChild>
|
||||
<h2>{header}</h2>
|
||||
</Caption>
|
||||
{(Array.isArray(selection) ? selection : [selection]).map((s) => (
|
||||
<Body key={s} className={styles.selection} color={"burgundy"}>
|
||||
{s}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
{selection && (
|
||||
<Button intent="secondary" size="small" asChild>
|
||||
<Link href={path}>{intl.formatMessage({ id: "Modify" })}</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<ChevronDownIcon />
|
||||
<section className={styles.wrapper} data-open={isOpen}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div
|
||||
className={styles.circle}
|
||||
data-checked={isCompleted}
|
||||
ref={circleRef}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckIcon color="white" height="16" width="16" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<header className={styles.headerContainer}>
|
||||
<div>
|
||||
<Footnote
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
color="uiTextPlaceholder"
|
||||
>
|
||||
<h2>{header}</h2>
|
||||
</Footnote>
|
||||
<Subtitle
|
||||
type="two"
|
||||
className={styles.selection}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{label}
|
||||
</Subtitle>
|
||||
</div>
|
||||
{isCompleted && !isOpen && (
|
||||
<Link href={path} color="burgundy" variant="icon">
|
||||
{intl.formatMessage({ id: "Modify" })}{" "}
|
||||
<ChevronDownIcon color="burgundy" />
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
<div className={styles.content} ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,73 @@
|
||||
.wrapper {
|
||||
border-bottom: 1px solid var(--Base-Border-Normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x3);
|
||||
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.top {
|
||||
.wrapper:not(:last-child)::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x5);
|
||||
height: 100%;
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.selection {
|
||||
font-weight: 450;
|
||||
font-size: var(--typography-Title-4-fontSize);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
top: var(--Spacing-x1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100px;
|
||||
transition: background-color 0.4s;
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.circle[data-checked="true"] {
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .circle[data-checked="false"] {
|
||||
background-color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.wrapper[data-open="false"] .circle[data-checked="false"] {
|
||||
background-color: var(--Base-Surface-Subtle-Hover);
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease-out;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
40
components/Icons/Breakfast.tsx
Normal file
40
components/Icons/Breakfast.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function BreakfastIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="33"
|
||||
viewBox="0 0 32 33"
|
||||
width="32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
height="33"
|
||||
id="mask0_5171_13483"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style={{ maskType: "alpha" }}
|
||||
width="32"
|
||||
x="0"
|
||||
y="0"
|
||||
>
|
||||
<rect y="0.5" width="32" height="32" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_5171_13483)">
|
||||
<path
|
||||
d="M3 27H21.2167V28.25C21.2167 28.5944 21.0944 28.8889 20.85 29.1333C20.6056 29.3778 20.3111 29.5 19.9667 29.5H4.25C3.90556 29.5 3.61111 29.3778 3.36667 29.1333C3.12222 28.8889 3 28.5944 3 28.25V27ZM3 24.4333V23.1833C3 22.8389 3.12222 22.5444 3.36667 22.3C3.61111 22.0556 3.90556 21.9333 4.25 21.9333H9.53333V20.75C9.53333 20.4056 9.65556 20.1111 9.9 19.8667C10.1444 19.6222 10.4389 19.5 10.7833 19.5H13.4333C13.7778 19.5 14.0722 19.6222 14.3167 19.8667C14.5611 20.1111 14.6833 20.4056 14.6833 20.75V21.9333H19.9667C20.3111 21.9333 20.6056 22.0556 20.85 22.3C21.0944 22.5444 21.2167 22.8389 21.2167 23.1833V24.4333H3ZM23.9167 21.4667C23.15 20.6444 22.5278 19.7889 22.05 18.9C21.5722 18.0111 21.3333 16.9556 21.3333 15.7333V4.75C21.3333 4.40556 21.4556 4.11111 21.7 3.86667C21.9444 3.62222 22.2389 3.5 22.5833 3.5H27.75C28.0944 3.5 28.3889 3.62222 28.6333 3.86667C28.8778 4.11111 29 4.40556 29 4.75V15.7333C29 16.9556 28.7639 18.0139 28.2917 18.9083C27.8194 19.8028 27.1944 20.6556 26.4167 21.4667V27H27.7167C28.0611 27 28.3556 27.1222 28.6 27.3667C28.8444 27.6111 28.9667 27.9056 28.9667 28.25C28.9667 28.5944 28.8444 28.8889 28.6 29.1333C28.3556 29.3778 28.0611 29.5 27.7167 29.5H25.1667C24.8222 29.5 24.5278 29.3778 24.2833 29.1333C24.0389 28.8889 23.9167 28.5944 23.9167 28.25V21.4667ZM23.8333 11.3H26.5V6H23.8333V11.3ZM25.1667 19.1667C25.5667 18.7111 25.8889 18.1806 26.1333 17.575C26.3778 16.9694 26.5 16.3556 26.5 15.7333V13.8H23.8333V15.7333C23.8333 16.3556 23.95 16.9694 24.1833 17.575C24.4167 18.1806 24.7444 18.7111 25.1667 19.1667Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
36
components/Icons/Heart.tsx
Normal file
36
components/Icons/Heart.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function HeartIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
height="24"
|
||||
id="mask0_69_3298"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style={{ maskType: "alpha" }}
|
||||
width="24"
|
||||
x="0"
|
||||
y="0"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_69_3298)">
|
||||
<path
|
||||
d="M12 20.0875C11.775 20.0875 11.5521 20.0479 11.3313 19.9687C11.1104 19.8896 10.9125 19.7666 10.7375 19.6L9.1625 18.1625C7.3625 16.5208 5.76042 14.9125 4.35625 13.3375C2.95208 11.7625 2.25 10.0416 2.25 8.17498C2.25 6.65816 2.75865 5.39145 3.77595 4.37485C4.79327 3.35827 6.06087 2.84998 7.57875 2.84998C8.43458 2.84998 9.24792 3.03539 10.0188 3.40623C10.7896 3.77706 11.45 4.29998 12 4.97498C12.5667 4.29998 13.231 3.77706 13.993 3.40623C14.755 3.03539 15.5657 2.84998 16.425 2.84998C17.9418 2.84998 19.2085 3.35827 20.2251 4.37485C21.2417 5.39145 21.75 6.65816 21.75 8.17498C21.75 10.0416 21.05 11.7646 19.65 13.3437C18.25 14.9229 16.6458 16.5291 14.8375 18.1625L13.2625 19.6C13.0875 19.7666 12.8896 19.8896 12.6687 19.9687C12.4479 20.0479 12.225 20.0875 12 20.0875ZM11.107 6.89753C10.6773 6.20749 10.1729 5.67289 9.59375 5.29373C9.01458 4.91456 8.33962 4.72498 7.56885 4.72498C6.5849 4.72498 5.76493 5.05206 5.10895 5.70623C4.45298 6.36039 4.125 7.18202 4.125 8.1711C4.125 9.0283 4.42917 9.93908 5.0375 10.9035C5.64583 11.8678 6.37083 12.8041 7.2125 13.7125C8.05417 14.6208 8.92083 15.4687 9.8125 16.2562C10.7042 17.0437 11.4333 17.6916 12 18.2C12.5667 17.6916 13.2958 17.0437 14.1875 16.2562C15.0792 15.4687 15.9458 14.6208 16.7875 13.7125C17.6292 12.8041 18.3542 11.8678 18.9625 10.9035C19.5708 9.93908 19.875 9.0283 19.875 8.1711C19.875 7.18202 19.547 6.36039 18.8911 5.70623C18.2351 5.05206 17.4151 4.72498 16.4311 4.72498C15.6604 4.72498 14.9833 4.91456 14.4 5.29373C13.8167 5.67289 13.3102 6.20749 12.8805 6.89753C12.7768 7.06583 12.6466 7.18956 12.4899 7.26873C12.3331 7.34789 12.1685 7.38748 11.9961 7.38748C11.8237 7.38748 11.6583 7.34789 11.5 7.26873C11.3417 7.18956 11.2107 7.06583 11.107 6.89753Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
27
components/Icons/KingBed.tsx
Normal file
27
components/Icons/KingBed.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function KingBedIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="33"
|
||||
viewBox="0 0 46 33"
|
||||
width="46"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g id="bed king">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
fillRule="evenodd"
|
||||
id="Shape"
|
||||
d="M43.4073 15.8263C44.9964 17.3421 46 19.4474 46 22.2263V22.3947V22.4789V30.9C46 31.7421 45.2473 32.5 44.3273 32.5H42.2364C41.3164 32.5 40.5636 31.7421 40.5636 30.9V27.1105H5.52V30.9C5.52 31.7421 4.76727 32.5 3.84727 32.5H1.67273C0.752727 32.5 0 31.7421 0 30.9V22.3947C0 20.2053 0.501818 18.5211 1.25455 17.0895V17.0053V1.34211C1.25455 0.921053 1.67273 0.5 2.09091 0.5H42.5709C43.0727 0.5 43.4073 0.836842 43.4073 1.34211V15.8263ZM1.67273 21.5526H44.3273C44.0764 18.1842 42.0691 16.1632 39.0582 14.8158C38.9745 14.8158 38.8909 14.8158 38.8073 14.7316C34.4582 13.0474 28.1018 13.0474 22.1636 13.0474C10.5382 13.0474 2.17455 13.7211 1.67273 21.5526ZM5.93818 13.3V11.9526C5.93818 6.81579 11.2909 6.22632 13.6327 6.22632C15.8909 6.22632 20.9927 6.73158 21.3273 11.4474C15.8909 11.4474 10.12 11.5316 5.93818 13.3ZM23 11.3632V11.4474C28.1018 11.4474 33.8727 11.5316 38.3891 13.0474V11.3632C38.3891 6.22632 33.0364 5.63684 30.6945 5.63684C28.3527 5.63684 23 6.22632 23 11.3632ZM41.7345 2.18421V14.4789C41.2327 14.1421 40.6473 13.8895 40.0618 13.6368V11.2789C40.0618 6.73158 36.5491 3.95263 30.6945 3.95263C26.3455 3.95263 23.2509 5.46842 21.9964 8.16316C20.5745 5.97368 17.7309 4.71053 13.7164 4.71053C7.86182 4.71053 4.34909 7.40526 4.34909 12.0368V14.1421C3.84727 14.4789 3.42909 14.8158 3.01091 15.1526V2.18421H41.7345ZM1.67273 30.9H3.84727V27.1105H1.67273V30.9ZM1.67273 25.5105V23.1526H44.3273V25.5105H1.67273ZM42.1527 27.1105V30.9H44.3273V27.1105H42.1527Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
40
components/Icons/NoBreakfast.tsx
Normal file
40
components/Icons/NoBreakfast.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function NoBreakfastIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="33"
|
||||
viewBox="0 0 32 33"
|
||||
width="32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_5171_21315"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="33"
|
||||
>
|
||||
<rect y="0.5" width="32" height="32" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_5171_21315)">
|
||||
<path
|
||||
d="M28.4824 25.3833L26.1824 23.0833L27.4991 10H15.0324L14.8991 8.9C14.8546 8.52223 14.9546 8.19445 15.1991 7.91667C15.4435 7.63889 15.7546 7.5 16.1324 7.5H21.2324V3.48334C21.2324 3.13889 21.3546 2.84445 21.5991 2.6C21.8435 2.35556 22.138 2.23334 22.4824 2.23334C22.8269 2.23334 23.1213 2.35556 23.3658 2.6C23.6102 2.84445 23.7324 3.13889 23.7324 3.48334V7.5H28.8824C29.2602 7.5 29.5741 7.63334 29.8241 7.9C30.0741 8.16667 30.1769 8.48889 30.1324 8.86667L28.4824 25.3833ZM26.3324 30.35L2.18242 6.21667C1.92687 5.96111 1.79909 5.66389 1.79909 5.325C1.79909 4.98612 1.92687 4.68889 2.18242 4.43334C2.43798 4.17778 2.7352 4.05 3.07409 4.05C3.41298 4.05 3.7102 4.17778 3.96576 4.43334L28.1158 28.5833C28.3713 28.8389 28.4991 29.1333 28.4991 29.4667C28.4991 29.8 28.3713 30.0944 28.1158 30.35C27.8602 30.6056 27.563 30.7333 27.2241 30.7333C26.8852 30.7333 26.588 30.6056 26.3324 30.35ZM2.98242 25.75C2.63798 25.75 2.34353 25.6278 2.09909 25.3833C1.85464 25.1389 1.73242 24.8444 1.73242 24.5C1.73242 24.1556 1.85464 23.8611 2.09909 23.6167C2.34353 23.3722 2.63798 23.25 2.98242 23.25H19.9491C20.2935 23.25 20.588 23.3722 20.8324 23.6167C21.0769 23.8611 21.1991 24.1556 21.1991 24.5C21.1991 24.8444 21.0769 25.1389 20.8324 25.3833C20.588 25.6278 20.2935 25.75 19.9491 25.75H2.98242ZM2.98242 30.8333C2.63798 30.8333 2.34353 30.7111 2.09909 30.4667C1.85464 30.2222 1.73242 29.9278 1.73242 29.5833C1.73242 29.2389 1.85464 28.9444 2.09909 28.7C2.34353 28.4556 2.63798 28.3333 2.98242 28.3333H19.9491C20.2935 28.3333 20.588 28.4556 20.8324 28.7C21.0769 28.9444 21.1991 29.2389 21.1991 29.5833C21.1991 29.9278 21.0769 30.2222 20.8324 30.4667C20.588 30.7111 20.2935 30.8333 19.9491 30.8333H2.98242ZM12.7491 13.2167V15.7167C12.538 15.6944 12.3241 15.675 12.1074 15.6583C11.8908 15.6417 11.6769 15.6333 11.4658 15.6333C10.1546 15.6333 8.91853 15.8444 7.75742 16.2667C6.59631 16.6889 5.69909 17.3333 5.06575 18.2H17.7324L20.2324 20.7H3.26576C2.86576 20.7 2.54076 20.55 2.29076 20.25C2.04076 19.95 1.95464 19.6 2.03242 19.2C2.25464 18.1111 2.68242 17.1806 3.31576 16.4083C3.94909 15.6361 4.69909 15.0056 5.56575 14.5167C6.43242 14.0278 7.37964 13.675 8.40742 13.4583C9.4352 13.2417 10.4546 13.1333 11.4658 13.1333C11.6769 13.1333 11.8908 13.1417 12.1074 13.1583C12.3241 13.175 12.538 13.1944 12.7491 13.2167Z"
|
||||
fill="#787472"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.baseIconLowContrast,
|
||||
.baseIconLowContrast * {
|
||||
fill: var(--Base-Icon-Low-contrast);
|
||||
}
|
||||
|
||||
.black,
|
||||
.black * {
|
||||
fill: #000;
|
||||
@@ -46,3 +51,18 @@
|
||||
.white * {
|
||||
fill: var(--UI-Opacity-White-100);
|
||||
}
|
||||
|
||||
.uiTextHighContrast,
|
||||
.uiTextHighContrast * {
|
||||
fill: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
.uiTextMediumContrast,
|
||||
.uiTextMediumContrast * {
|
||||
fill: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.blue,
|
||||
.blue * {
|
||||
fill: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export { default as AirplaneIcon } from "./Airplane"
|
||||
export { default as ArrowRightIcon } from "./ArrowRight"
|
||||
export { default as BarIcon } from "./Bar"
|
||||
export { default as BikingIcon } from "./Biking"
|
||||
export { default as BreakfastIcon } from "./Breakfast"
|
||||
export { default as BusinessIcon } from "./Business"
|
||||
export { default as CalendarIcon } from "./Calendar"
|
||||
export { default as CameraIcon } from "./Camera"
|
||||
@@ -30,14 +31,17 @@ export { default as ErrorCircleIcon } from "./ErrorCircle"
|
||||
export { default as FitnessIcon } from "./Fitness"
|
||||
export { default as GiftIcon } from "./Gift"
|
||||
export { default as GlobeIcon } from "./Globe"
|
||||
export { default as HeartIcon } from "./Heart"
|
||||
export { default as HouseIcon } from "./House"
|
||||
export { default as ImageIcon } from "./Image"
|
||||
export { default as InfoCircleIcon } from "./InfoCircle"
|
||||
export { default as KingBedIcon } from "./KingBed"
|
||||
export { default as LocationIcon } from "./Location"
|
||||
export { default as LockIcon } from "./Lock"
|
||||
export { default as MapIcon } from "./Map"
|
||||
export { default as MinusIcon } from "./Minus"
|
||||
export { default as MuseumIcon } from "./Museum"
|
||||
export { default as NoBreakfastIcon } from "./NoBreakfast"
|
||||
export { default as ParkingIcon } from "./Parking"
|
||||
export { default as People2Icon } from "./People2"
|
||||
export { default as PersonIcon } from "./Person"
|
||||
|
||||
@@ -5,6 +5,7 @@ import styles from "./icon.module.css"
|
||||
const config = {
|
||||
variants: {
|
||||
color: {
|
||||
baseIconLowContrast: styles.baseIconLowContrast,
|
||||
black: styles.black,
|
||||
burgundy: styles.burgundy,
|
||||
grey80: styles.grey80,
|
||||
@@ -14,6 +15,9 @@ const config = {
|
||||
red: styles.red,
|
||||
green: styles.green,
|
||||
white: styles.white,
|
||||
uiTextHighContrast: styles.uiTextHighContrast,
|
||||
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||
blue: styles.blue,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { languages } from "@/constants/languages"
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { ChevronDownIcon, GlobeIcon } from "@/components/Icons"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
@@ -28,18 +29,13 @@ export default function LanguageSwitcher({
|
||||
}: LanguageSwitcherProps) {
|
||||
const intl = useIntl()
|
||||
const currentLanguage = useLang()
|
||||
const toggleDropdown = useDropdownStore((state) => state.toggleDropdown)
|
||||
const {
|
||||
toggleDropdown,
|
||||
isFooterLanguageSwitcherOpen,
|
||||
isHeaderLanguageSwitcherMobileOpen,
|
||||
isHeaderLanguageSwitcherOpen,
|
||||
} = useDropdownStore()
|
||||
const languageSwitcherRef = useRef<HTMLDivElement>(null)
|
||||
const isFooterLanguageSwitcherOpen = useDropdownStore(
|
||||
(state) => state.isFooterLanguageSwitcherOpen
|
||||
)
|
||||
const isHeaderLanguageSwitcherOpen = useDropdownStore(
|
||||
(state) => state.isHeaderLanguageSwitcherOpen
|
||||
)
|
||||
const isHeaderLanguageSwitcherMobileOpen = useDropdownStore(
|
||||
(state) => state.isHeaderLanguageSwitcherMobileOpen
|
||||
)
|
||||
|
||||
const isFooter = type === LanguageSwitcherTypesEnum.Footer
|
||||
const isHeader = !isFooter
|
||||
|
||||
@@ -71,33 +67,11 @@ export default function LanguageSwitcher({
|
||||
window.scrollTo(0, scrollPosition)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(evt: Event) {
|
||||
const target = evt.target as HTMLElement
|
||||
if (
|
||||
languageSwitcherRef.current &&
|
||||
target &&
|
||||
!languageSwitcherRef.current.contains(target) &&
|
||||
isLanguageSwitcherOpen &&
|
||||
!isHeaderLanguageSwitcherMobileOpen
|
||||
) {
|
||||
toggleDropdown(dropdownType)
|
||||
}
|
||||
}
|
||||
|
||||
if (languageSwitcherRef.current) {
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [
|
||||
dropdownType,
|
||||
toggleDropdown,
|
||||
isLanguageSwitcherOpen,
|
||||
isHeaderLanguageSwitcherMobileOpen,
|
||||
])
|
||||
useClickOutside(
|
||||
languageSwitcherRef,
|
||||
isLanguageSwitcherOpen && !isHeaderLanguageSwitcherMobileOpen,
|
||||
() => toggleDropdown(dropdownType)
|
||||
)
|
||||
|
||||
const classNames = languageSwitcherVariants({ color, position })
|
||||
|
||||
|
||||
51
components/Profile/ManagePreferencesButton/index.tsx
Normal file
51
components/Profile/ManagePreferencesButton/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import ArrowRight from "@/components/Icons/ArrowRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import styles from "./managePreferencesButton.module.css"
|
||||
|
||||
export default function ManagePreferencesButton() {
|
||||
const intl = useIntl()
|
||||
const generatePreferencesLink = trpc.user.generatePreferencesLink.useMutation(
|
||||
{
|
||||
onSuccess: (preferencesLink) => {
|
||||
if (preferencesLink) {
|
||||
window.open(preferencesLink, "_blank")
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "An error occurred trying to manage your preferences, please try again later.",
|
||||
})
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.managePreferencesButton}
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
onClick={() => generatePreferencesLink.mutate()}
|
||||
wrapping
|
||||
>
|
||||
<ArrowRight color="burgundy" />
|
||||
{intl.formatMessage({ id: "Manage preferences" })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.managePreferencesButton {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function CardImage({
|
||||
return (
|
||||
<article className={`${styles.container} ${className}`}>
|
||||
<div className={styles.imageContainer}>
|
||||
{imageCards.map(
|
||||
{imageCards?.map(
|
||||
({ backgroundImage }) =>
|
||||
backgroundImage && (
|
||||
<Image
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cardVariants } from "./variants"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
import type { ApiImage } from "@/types/components/image"
|
||||
import type { ImageVaultAsset } from "@/types/components/imageVault"
|
||||
|
||||
export interface CardProps
|
||||
@@ -22,9 +23,10 @@ export interface CardProps
|
||||
scriptedTopTitle?: string | null
|
||||
heading?: string | null
|
||||
bodyText?: string | null
|
||||
backgroundImage?: ImageVaultAsset
|
||||
imageHeight?: number
|
||||
imageWidth?: number
|
||||
imageGradient?: boolean
|
||||
onPrimaryButtonClick?: () => void
|
||||
onSecondaryButtonClick?: () => void
|
||||
backgroundImage?: ImageVaultAsset | ApiImage
|
||||
}
|
||||
|
||||
@@ -24,15 +24,17 @@ export default function Card({
|
||||
backgroundImage,
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
imageGradient,
|
||||
onPrimaryButtonClick,
|
||||
onSecondaryButtonClick,
|
||||
}: CardProps) {
|
||||
const buttonTheme = getTheme(theme)
|
||||
|
||||
imageHeight = imageHeight || 320
|
||||
|
||||
imageWidth =
|
||||
imageWidth ||
|
||||
(backgroundImage
|
||||
(backgroundImage && "dimensions" in backgroundImage
|
||||
? backgroundImage.dimensions.aspectRatio * imageHeight
|
||||
: 420)
|
||||
|
||||
@@ -44,7 +46,7 @@ export default function Card({
|
||||
})}
|
||||
>
|
||||
{backgroundImage && (
|
||||
<div className={styles.imageWrapper}>
|
||||
<div className={imageGradient ? styles.imageWrapper : ""}>
|
||||
<Image
|
||||
src={backgroundImage.url}
|
||||
className={styles.image}
|
||||
|
||||
7
components/TempDesignSystem/Form/Card/Checkbox.tsx
Normal file
7
components/TempDesignSystem/Form/Card/Checkbox.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Card from "."
|
||||
|
||||
import type { CheckboxProps } from "./card"
|
||||
|
||||
export default function CheckboxCard(props: CheckboxProps) {
|
||||
return <Card {...props} type="checkbox" />
|
||||
}
|
||||
7
components/TempDesignSystem/Form/Card/Radio.tsx
Normal file
7
components/TempDesignSystem/Form/Card/Radio.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Card from "."
|
||||
|
||||
import type { RadioProps } from "./card"
|
||||
|
||||
export default function RadioCard(props: RadioProps) {
|
||||
return <Card {...props} type="radio" />
|
||||
}
|
||||
72
components/TempDesignSystem/Form/Card/card.module.css
Normal file
72
components/TempDesignSystem/Form/Card/card.module.css
Normal file
@@ -0,0 +1,72 @@
|
||||
.label {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
transition: all 200ms ease;
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
|
||||
.label:hover {
|
||||
background-color: var(--Base-Surface-Secondary-light-Hover);
|
||||
}
|
||||
|
||||
.label:has(:checked) {
|
||||
background-color: var(--Primary-Light-Surface-Normal);
|
||||
border-color: var(--Base-Border-Hover);
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-self: center;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1/3;
|
||||
justify-self: flex-end;
|
||||
transition: fill 200ms ease;
|
||||
}
|
||||
|
||||
.label:hover .icon,
|
||||
.label:hover .icon *,
|
||||
.label:has(:checked) .icon,
|
||||
.label:has(:checked) .icon * {
|
||||
fill: var(--Base-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.label[data-declined="true"]:hover .icon,
|
||||
.label[data-declined="true"]:hover .icon *,
|
||||
.label[data-declined="true"]:has(:checked) .icon,
|
||||
.label[data-declined="true"]:has(:checked) .icon * {
|
||||
fill: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
|
||||
.label .text {
|
||||
margin-top: var(--Spacing-x1);
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.listItem:first-of-type {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.listItem:nth-of-type(n + 2) {
|
||||
margin-top: var(--Spacing-x-quarter);
|
||||
}
|
||||
35
components/TempDesignSystem/Form/Card/card.ts
Normal file
35
components/TempDesignSystem/Form/Card/card.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
interface BaseCardProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
Icon?: (props: IconProps) => JSX.Element
|
||||
declined?: boolean
|
||||
iconHeight?: number
|
||||
iconWidth?: number
|
||||
name?: string
|
||||
saving?: boolean
|
||||
subtitle?: string
|
||||
title: string
|
||||
type: "checkbox" | "radio"
|
||||
value?: string
|
||||
}
|
||||
|
||||
interface ListCardProps extends BaseCardProps {
|
||||
list: {
|
||||
title: string
|
||||
}[]
|
||||
text?: never
|
||||
}
|
||||
|
||||
interface TextCardProps extends BaseCardProps {
|
||||
list?: never
|
||||
text: string
|
||||
}
|
||||
|
||||
export type CardProps = ListCardProps | TextCardProps
|
||||
|
||||
export type CheckboxProps =
|
||||
| Omit<ListCardProps, "type">
|
||||
| Omit<TextCardProps, "type">
|
||||
export type RadioProps =
|
||||
| Omit<ListCardProps, "type">
|
||||
| Omit<TextCardProps, "type">
|
||||
77
components/TempDesignSystem/Form/Card/index.tsx
Normal file
77
components/TempDesignSystem/Form/Card/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { CheckIcon, CloseIcon, HeartIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./card.module.css"
|
||||
|
||||
import type { CardProps } from "./card"
|
||||
|
||||
export default function Card({
|
||||
Icon = HeartIcon,
|
||||
iconHeight = 32,
|
||||
iconWidth = 32,
|
||||
declined = false,
|
||||
id,
|
||||
list,
|
||||
name = "join",
|
||||
saving = false,
|
||||
subtitle,
|
||||
text,
|
||||
title,
|
||||
type,
|
||||
value,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<label className={styles.label} data-declined={declined}>
|
||||
<Caption className={styles.title} textTransform="bold" uppercase>
|
||||
{title}
|
||||
</Caption>
|
||||
{subtitle ? (
|
||||
<Caption
|
||||
className={styles.subtitle}
|
||||
color={saving ? "baseTextAccent" : "uiTextHighContrast"}
|
||||
textTransform="bold"
|
||||
>
|
||||
{subtitle}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Icon
|
||||
className={styles.icon}
|
||||
color="uiTextHighContrast"
|
||||
height={iconHeight}
|
||||
width={iconWidth}
|
||||
/>
|
||||
{list
|
||||
? list.map((listItem) => (
|
||||
<span key={listItem.title} className={styles.listItem}>
|
||||
{declined ? (
|
||||
<CloseIcon
|
||||
color="uiTextMediumContrast"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon color="baseIconLowContrast" height={20} width={20} />
|
||||
)}
|
||||
<Footnote color="uiTextMediumContrast">{listItem.title}</Footnote>
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
{text ? (
|
||||
<Footnote className={styles.text} color="uiTextMediumContrast">
|
||||
{text}
|
||||
</Footnote>
|
||||
) : null}
|
||||
<input
|
||||
aria-hidden
|
||||
id={id || name}
|
||||
hidden
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export type CountryProps = {
|
||||
className?: string
|
||||
label: string
|
||||
name?: string
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,10 @@ import type {
|
||||
} from "./country"
|
||||
|
||||
export default function CountrySelect({
|
||||
className = "",
|
||||
label,
|
||||
name = "country",
|
||||
readOnly = false,
|
||||
registerOptions = {},
|
||||
}: CountryProps) {
|
||||
const { formatMessage } = useIntl()
|
||||
@@ -54,12 +56,13 @@ export default function CountrySelect({
|
||||
const selectCountryLabel = formatMessage({ id: "Select a country" })
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={setRef}>
|
||||
<div className={`${styles.container} ${className}`} ref={setRef}>
|
||||
<ComboBox
|
||||
aria-label={formatMessage({ id: "Select country of residence" })}
|
||||
className={styles.select}
|
||||
isRequired={!!registerOptions?.required}
|
||||
isInvalid={fieldState.invalid}
|
||||
isReadOnly={readOnly}
|
||||
isRequired={!!registerOptions?.required}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
onSelectionChange={handleChange}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type ForwardedRef, forwardRef } from "react"
|
||||
import { Input as AriaInput, Label as AriaLabel } from "react-aria-components"
|
||||
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./input.module.css"
|
||||
|
||||
import type { AriaInputWithLabelProps } from "./input"
|
||||
|
||||
const AriaInputWithLabel = forwardRef(function AriaInputWithLabelComponent(
|
||||
{ label, ...props }: AriaInputWithLabelProps,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
return (
|
||||
<AriaLabel className={styles.container} htmlFor={props.name}>
|
||||
<Body asChild fontOnly>
|
||||
<AriaInput {...props} className={styles.input} ref={ref} />
|
||||
</Body>
|
||||
<Label required={!!props.required}>{label}</Label>
|
||||
</AriaLabel>
|
||||
)
|
||||
})
|
||||
|
||||
export default AriaInputWithLabel
|
||||
@@ -0,0 +1,55 @@
|
||||
.container {
|
||||
align-content: center;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Scandic-Beige-40);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
height: 60px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.container:has(.input:active, .input:focus) {
|
||||
border-color: var(--Scandic-Blue-90);
|
||||
}
|
||||
|
||||
.container:has(.input:disabled) {
|
||||
background-color: var(--Main-Grey-10);
|
||||
border: none;
|
||||
color: var(--Main-Grey-40);
|
||||
}
|
||||
|
||||
.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
|
||||
border-color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
.input {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--Main-Grey-100);
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
order: 2;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input:not(:active, :focus):placeholder-shown {
|
||||
height: 0px;
|
||||
transition: height 150ms ease;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.input:focus:placeholder-shown,
|
||||
.input:active,
|
||||
.input:active:placeholder-shown {
|
||||
height: 18px;
|
||||
transition: height 150ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
color: var(--Main-Grey-40);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface AriaInputWithLabelProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
"use client"
|
||||
import {
|
||||
Input as AriaInput,
|
||||
Label as AriaLabel,
|
||||
Text,
|
||||
TextField,
|
||||
} from "react-aria-components"
|
||||
import { Text, TextField } from "react-aria-components"
|
||||
import { Controller, useFormContext } from "react-hook-form"
|
||||
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./input.module.css"
|
||||
@@ -20,11 +14,13 @@ import type { InputProps } from "./input"
|
||||
|
||||
export default function Input({
|
||||
"aria-label": ariaLabel,
|
||||
className = "",
|
||||
disabled = false,
|
||||
helpText = "",
|
||||
label,
|
||||
name,
|
||||
placeholder = "",
|
||||
readOnly = false,
|
||||
registerOptions = {},
|
||||
type = "text",
|
||||
}: InputProps) {
|
||||
@@ -44,6 +40,7 @@ export default function Input({
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
isDisabled={field.disabled}
|
||||
isInvalid={fieldState.invalid}
|
||||
isRequired={!!registerOptions.required}
|
||||
@@ -53,19 +50,16 @@ export default function Input({
|
||||
validationBehavior="aria"
|
||||
value={field.value}
|
||||
>
|
||||
<AriaLabel className={styles.container} htmlFor={field.name}>
|
||||
<Body asChild fontOnly>
|
||||
<AriaInput
|
||||
{...numberAttributes}
|
||||
aria-labelledby={field.name}
|
||||
className={styles.input}
|
||||
id={field.name}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
/>
|
||||
</Body>
|
||||
<Label required={!!registerOptions.required}>{label}</Label>
|
||||
</AriaLabel>
|
||||
<AriaInputWithLabel
|
||||
{...field}
|
||||
aria-labelledby={field.name}
|
||||
id={field.name}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
required={!!registerOptions.required}
|
||||
type={type}
|
||||
/>
|
||||
{helpText && !fieldState.error ? (
|
||||
<Caption asChild color="black">
|
||||
<Text className={styles.helpText} slot="description">
|
||||
|
||||
@@ -1,59 +1,3 @@
|
||||
.container {
|
||||
align-content: center;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Scandic-Beige-40);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
height: 60px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.container:has(.input:active, .input:focus) {
|
||||
border-color: var(--Scandic-Blue-90);
|
||||
}
|
||||
|
||||
.container:has(.input:disabled) {
|
||||
background-color: var(--Main-Grey-10);
|
||||
border: none;
|
||||
color: var(--Main-Grey-40);
|
||||
}
|
||||
|
||||
.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
|
||||
border-color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
.input {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--Main-Grey-100);
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
order: 2;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input:not(:active, :focus):placeholder-shown {
|
||||
height: 0px;
|
||||
transition: height 150ms ease;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.input:focus:placeholder-shown,
|
||||
.input:active,
|
||||
.input:active:placeholder-shown {
|
||||
height: 18px;
|
||||
transition: height 150ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
color: var(--Main-Grey-40);
|
||||
}
|
||||
|
||||
.helpText {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RegisterOptions, UseFormRegister } from "react-hook-form"
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user