diff --git a/actions/registerUserBookingFlow.ts b/actions/registerUserBookingFlow.ts new file mode 100644 index 000000000..a0539d351 --- /dev/null +++ b/actions/registerUserBookingFlow.ts @@ -0,0 +1,67 @@ +"use server" + +import { parsePhoneNumber } from "libphonenumber-js" +import { z } from "zod" + +import { serviceServerActionProcedure } from "@/server/trpc" + +import { phoneValidator } from "@/utils/phoneValidator" + +const registerUserPayload = z.object({ + firstName: z.string(), + lastName: z.string(), + dateOfBirth: z.string(), + address: z.object({ + countryCode: z.string(), + zipCode: z.string(), + }), + email: z.string(), + phoneNumber: phoneValidator("Phone is required"), +}) + +export const registerUserBookingFlow = serviceServerActionProcedure + .input(registerUserPayload) + .mutation(async function ({ ctx, input }) { + const payload = { + ...input, + language: ctx.lang, + phoneNumber: parsePhoneNumber(input.phoneNumber) + .formatNational() + .replace(/\s+/g, ""), + } + + // TODO: Consume the API to register the user as soon as passwordless signup is enabled. + // let apiResponse + // try { + // apiResponse = await api.post(api.endpoints.v1.profile, { + // body: payload, + // headers: { + // Authorization: `Bearer ${ctx.serviceToken}`, + // }, + // }) + // } catch (error) { + // console.error("Unexpected error", error) + // return { success: false, error: "Unexpected error" } + // } + + // if (!apiResponse.ok) { + // const text = await apiResponse.text() + // console.error(text) + // console.error( + // "registerUserBookingFlow api error", + // JSON.stringify({ + // query: input, + // error: { + // status: apiResponse.status, + // statusText: apiResponse.statusText, + // error: text, + // }, + // }) + // ) + // return { success: false, error: "API error" } + // } + // const json = await apiResponse.json() + // console.log("registerUserBookingFlow: json", json) + + return { success: true, data: payload } + }) diff --git a/components/GuestsRoomsPicker/AdultSelector/index.tsx b/components/GuestsRoomsPicker/AdultSelector/index.tsx index e0119c45d..72d60ebaf 100644 --- a/components/GuestsRoomsPicker/AdultSelector/index.tsx +++ b/components/GuestsRoomsPicker/AdultSelector/index.tsx @@ -56,7 +56,7 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) { return (
- + {adultsLabel}
- + {childrenLabel} { + // In order to avoid hydration errors the state needs to be set as side effect, + // since the join value can come from search params + setIsJoinChecked(joinValue) + }, [joinValue]) + + 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" }) }, + ] + + return ( +
+ + {isJoinChecked ? ( +
+
+
+ + {intl.formatMessage({ id: "Birth date" })} * + +
+ + +
+
+ + + {intl.formatMessage({ + id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with", + })}{" "} + + {intl.formatMessage({ id: "Scandic's Privacy Policy." })} + + + +
+
+ ) : null} +
+ ) +} diff --git a/components/HotelReservation/EnterDetails/Details/Signup/signup.module.css b/components/HotelReservation/EnterDetails/Details/Signup/signup.module.css new file mode 100644 index 000000000..1db11dfb8 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/Signup/signup.module.css @@ -0,0 +1,15 @@ +.container { + display: grid; + grid-column: 1/-1; + gap: var(--Spacing-x3); +} + +.additionalFormData { + display: grid; + gap: var(--Spacing-x4); +} + +.dateField { + display: grid; + gap: var(--Spacing-x1); +} diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 8f034f501..35c16304b 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -6,14 +6,16 @@ import { useIntl } from "react-intl" import { useEnterDetailsStore } from "@/stores/enter-details" +import { registerUserBookingFlow } from "@/actions/registerUserBookingFlow" import Button from "@/components/TempDesignSystem/Button" -import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/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 { toast } from "@/components/TempDesignSystem/Toasts" import { detailsSchema, signedInDetailsSchema } from "./schema" +import Signup from "./Signup" import styles from "./details.module.css" @@ -25,28 +27,30 @@ import type { const formID = "enter-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 initialData = useEnterDetailsStore((state) => ({ countryCode: state.data.countryCode, email: state.data.email, - firstname: state.data.firstname, - lastname: state.data.lastname, + firstName: state.data.firstName, + lastName: state.data.lastName, phoneNumber: state.data.phoneNumber, + join: state.data.join, + dateOfBirth: state.data.dateOfBirth, + zipCode: state.data.zipCode, + termsAccepted: state.data.termsAccepted, })) const methods = useForm({ defaultValues: { countryCode: user?.address?.countryCode ?? initialData.countryCode, email: user?.email ?? initialData.email, - firstname: user?.firstName ?? initialData.firstname, - lastname: user?.lastName ?? initialData.lastname, + firstName: user?.firstName ?? initialData.firstName, + lastName: user?.lastName ?? initialData.lastName, phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, + //@ts-expect-error: We use a literal for join to be true or false, which does not convert to a boolean + join: initialData.join, + dateOfBirth: initialData.dateOfBirth, + zipCode: initialData.zipCode, + termsAccepted: initialData.termsAccepted, }, criteriaMode: "all", mode: "all", @@ -56,10 +60,39 @@ export default function Details({ user }: DetailsProps) { const completeStep = useEnterDetailsStore((state) => state.completeStep) + // const errorMessage = intl.formatMessage({ + // id: "An error occurred. Please try again.", + // }) + const onSubmit = useCallback( - (values: DetailsSchema) => { + async function (values: DetailsSchema) { + if (values.join) { + const signupVals = { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + phoneNumber: values.phoneNumber, + address: { + zipCode: values.zipCode, + countryCode: values.countryCode, + }, + dateOfBirth: values.dateOfBirth, + } + + const res = await registerUserBookingFlow(signupVals) + if (!res.success) { + // if (res.error) { + // toast.error(res.error) + // } else { + // toast.error(errorMessage) + // } + return + } + console.log("Signed up user: ", res) + } completeStep(values) }, + [completeStep] ) @@ -77,14 +110,14 @@ export default function Details({ user }: DetailsProps) { onSubmit={methods.handleSubmit(onSubmit)} > @@ -109,26 +142,9 @@ export default function Details({ user }: DetailsProps) { readOnly={!!user} registerOptions={{ required: true }} /> + {user ? null : }
- {user ? null : ( - - )} )} -
- {children} -
+
{children}
) diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index 11598c8bd..3680f5628 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -22,12 +22,14 @@ } .main { - display: flex; - flex-direction: column; + display: grid; gap: var(--Spacing-x3); width: 100%; border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); padding-bottom: var(--Spacing-x3); + + transition: 0.4s ease-out; + grid-template-rows: 2em 0fr; } .headerContainer { @@ -70,12 +72,23 @@ background-color: var(--Base-Surface-Subtle-Hover); } +.wrapper[data-open="true"] .main { + grid-template-rows: 2em 1fr; +} + .content { overflow: hidden; - transition: max-height 0.4s ease-out; - max-height: 0; +} + +@keyframes allowOverflow { + 0% { + overflow: hidden; + } + 100% { + overflow: visible; + } } .wrapper[data-open="true"] .content { - max-height: 1000px; + animation: allowOverflow 0.4s 0.4s ease; } diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index 243c6bd3d..222bbd1c1 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -20,8 +20,8 @@ import type { DateProps } from "./date" export default function DateSelect({ name, registerOptions = {} }: DateProps) { const intl = useIntl() - const d = useWatch({ name }) - const { control, setValue } = useFormContext() + const currentValue = useWatch({ name }) + const { control, setValue, trigger } = useFormContext() const { field } = useController({ control, name, @@ -47,7 +47,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { })) const years = rangeArray(1900, currentYear - 18) .reverse() - .map((year) => ({ value: year, label: `${year}` })) + .map((year) => ({ value: year, label: year.toString() })) function createOnSelect(selector: DateName) { /** @@ -68,6 +68,8 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { const month = selector === DateName.month ? value : newSegments.month if (year !== null && month !== null) { newSegments.daysInMonth = dt().year(year).month(month).daysInMonth() + } else if (month !== null) { + newSegments.daysInMonth = dt().month(month).daysInMonth() } } @@ -79,6 +81,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { .set("date", Math.min(newSegments.date!, newSegments.daysInMonth)) setValue(name, newDate.format("YYYY-MM-DD")) + trigger(name) } setDateSegment(newSegments) } @@ -95,9 +98,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { * date, but we can't check isNan since * we recieve the date as "1999-01-01" */ - dateValue = dt(d).isValid() ? parseDate(d) : null + dateValue = dt(currentValue).isValid() ? parseDate(currentValue) : null } catch (error) { - console.error(error) + console.warn("Known error for parse date in DateSelect: ", error) } return ( @@ -133,6 +136,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { defaultSelectedKey={ segment.isPlaceholder ? undefined : segment.value } + value={segment.isPlaceholder ? undefined : segment.value} /> ) @@ -151,6 +155,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { defaultSelectedKey={ segment.isPlaceholder ? undefined : segment.value } + value={segment.isPlaceholder ? undefined : segment.value} /> ) @@ -169,6 +174,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { defaultSelectedKey={ segment.isPlaceholder ? undefined : segment.value } + value={segment.isPlaceholder ? undefined : segment.value} /> ) diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 97f15cd5a..24675f021 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -15,6 +15,7 @@ "Already a friend?": "Allerede en ven?", "Amenities": "Faciliteter", "Amusement park": "Forlystelsespark", + "An error occurred. Please try again.": "Der opstod en fejl. Prøv venligst igen.", "An error occurred trying to manage your preferences, please try again later.": "Der opstod en fejl under forsøget på at administrere dine præferencer. Prøv venligst igen senere.", "An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.", "An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.", @@ -105,7 +106,7 @@ "Fair": "Messe", "Find booking": "Find booking", "Find hotels": "Find hotel", - "Firstname": "Fornavn", + "First name": "Fornavn", "Flexibility": "Fleksibilitet", "Follow us": "Følg os", "Former Scandic Hotel": "Tidligere Scandic Hotel", @@ -138,7 +139,7 @@ "Join at no cost": "Tilmeld dig uden omkostninger", "King bed": "Kingsize-seng", "Language": "Sprog", - "Lastname": "Efternavn", + "Last name": "Efternavn", "Latest searches": "Seneste søgninger", "Left": "tilbage", "Level": "Niveau", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 46afd15a8..785c93f89 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -15,6 +15,7 @@ "Already a friend?": "Sind wir schon Freunde?", "Amenities": "Annehmlichkeiten", "Amusement park": "Vergnügungspark", + "An error occurred. Please try again.": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "An error occurred trying to manage your preferences, please try again later.": "Beim Versuch, Ihre Einstellungen zu verwalten, ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "An error occurred when adding a credit card, please try again later.": "Beim Hinzufügen einer Kreditkarte ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "An error occurred when trying to update profile.": "Beim Versuch, das Profil zu aktualisieren, ist ein Fehler aufgetreten.", @@ -105,7 +106,7 @@ "Fair": "Messe", "Find booking": "Buchung finden", "Find hotels": "Hotels finden", - "Firstname": "Vorname", + "First name": "Vorname", "Flexibility": "Flexibilität", "Follow us": "Folgen Sie uns", "Former Scandic Hotel": "Ehemaliges Scandic Hotel", @@ -138,7 +139,7 @@ "Join at no cost": "Kostenlos beitreten", "King bed": "Kingsize-Bett", "Language": "Sprache", - "Lastname": "Nachname", + "Last name": "Nachname", "Latest searches": "Letzte Suchanfragen", "Left": "übrig", "Level": "Level", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index d18d98526..a05170e00 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -16,6 +16,7 @@ "Already a friend?": "Already a friend?", "Amenities": "Amenities", "Amusement park": "Amusement park", + "An error occurred. Please try again.": "An error occurred. Please try again.", "An error occurred trying to manage your preferences, please try again later.": "An error occurred trying to manage your preferences, please try again later.", "An error occurred when adding a credit card, please try again later.": "An error occurred when adding a credit card, please try again later.", "An error occurred when trying to update profile.": "An error occurred when trying to update profile.", @@ -108,7 +109,7 @@ "Fair": "Fair", "Find booking": "Find booking", "Find hotels": "Find hotels", - "Firstname": "Firstname", + "First name": "First name", "Flexibility": "Flexibility", "Follow us": "Follow us", "Former Scandic Hotel": "Former Scandic Hotel", @@ -141,7 +142,7 @@ "Join at no cost": "Join at no cost", "King bed": "King bed", "Language": "Language", - "Lastname": "Lastname", + "Last name": "Last name", "Latest searches": "Latest searches", "Left": "left", "Level": "Level", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 23a7ac2ea..1c2b11a26 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -15,6 +15,7 @@ "Already a friend?": "Oletko jo ystävä?", "Amenities": "Mukavuudet", "Amusement park": "Huvipuisto", + "An error occurred. Please try again.": "Tapahtui virhe. Yritä uudelleen.", "An error occurred trying to manage your preferences, please try again later.": "Asetusten hallinnassa tapahtui virhe. Yritä myöhemmin uudelleen.", "An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.", "An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.", @@ -105,7 +106,7 @@ "Fair": "Messukeskus", "Find booking": "Etsi varaus", "Find hotels": "Etsi hotelleja", - "Firstname": "Etunimi", + "First name": "Etunimi", "Flexibility": "Joustavuus", "Follow us": "Seuraa meitä", "Former Scandic Hotel": "Entinen Scandic-hotelli", @@ -138,7 +139,7 @@ "Join at no cost": "Liity maksutta", "King bed": "King-vuode", "Language": "Kieli", - "Lastname": "Sukunimi", + "Last name": "Sukunimi", "Latest searches": "Viimeisimmät haut", "Left": "jäljellä", "Level": "Level", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index bf0216d1a..eafdab517 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -15,6 +15,7 @@ "Already a friend?": "Allerede Friend?", "Amenities": "Fasiliteter", "Amusement park": "Tivoli", + "An error occurred. Please try again.": "Det oppsto en feil. Vennligst prøv igjen.", "An error occurred trying to manage your preferences, please try again later.": "Det oppstod en feil under forsøket på å administrere innstillingene dine. Prøv igjen senere.", "An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.", "An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.", @@ -104,7 +105,7 @@ "Fair": "Messe", "Find booking": "Finn booking", "Find hotels": "Finn hotell", - "Firstname": "Fornavn", + "First name": "Fornavn", "Flexibility": "Fleksibilitet", "Follow us": "Følg oss", "Former Scandic Hotel": "Tidligere Scandic-hotell", @@ -136,7 +137,7 @@ "Join at no cost": "Bli med uten kostnad", "King bed": "King-size-seng", "Language": "Språk", - "Lastname": "Etternavn", + "Last name": "Etternavn", "Latest searches": "Siste søk", "Left": "igjen", "Level": "Nivå", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 51caf41a0..9eda58171 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -15,6 +15,7 @@ "Already a friend?": "Är du redan en vän?", "Amenities": "Bekvämligheter", "Amusement park": "Nöjespark", + "An error occurred. Please try again.": "Ett fel uppstod. Försök igen.", "An error occurred trying to manage your preferences, please try again later.": "Ett fel uppstod när du försökte hantera dina inställningar, försök igen senare.", "An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.", "An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.", @@ -104,7 +105,7 @@ "Fair": "Mässa", "Find booking": "Hitta bokning", "Find hotels": "Hitta hotell", - "Firstname": "Förnamn", + "First name": "Förnamn", "Flexibility": "Flexibilitet", "Follow us": "Följ oss", "Former Scandic Hotel": "Tidigare Scandichotell", @@ -136,7 +137,7 @@ "Join at no cost": "Gå med utan kostnad", "King bed": "King size-säng", "Language": "Språk", - "Lastname": "Efternamn", + "Last name": "Efternamn", "Latest searches": "Senaste sökningarna", "Left": "kvar", "Level": "Nivå", diff --git a/stores/enter-details.ts b/stores/enter-details.ts index 360d2d5b7..16dd96f96 100644 --- a/stores/enter-details.ts +++ b/stores/enter-details.ts @@ -22,7 +22,10 @@ interface EnterDetailsState { activeSidePeek: SidePeekEnum | null isValid: Record completeStep: (updatedData: Partial) => void - navigate: (step: StepEnum, searchParams?: Record) => void + navigate: ( + step: StepEnum, + searchParams?: Record + ) => void openSidePeek: (key: SidePeekEnum | null) => void closeSidePeek: () => void } @@ -37,26 +40,34 @@ export function initEditDetailsState(currentStep: StepEnum) { breakfast: undefined, countryCode: "", email: "", - firstname: "", - lastname: "", + firstName: "", + lastName: "", phoneNumber: "", + join: false, + zipCode: "", + dateOfBirth: undefined, + termsAccepted: false, } let inputData = {} if (search?.size) { - const searchParams: Record = {} + const searchParams: Record = {} search.forEach((value, key) => { - searchParams[key] = value + // Handle boolean values + + if (value === "true" || value === "false") { + searchParams[key] = JSON.parse(value) as true | false + } else { + searchParams[key] = value + } }) inputData = searchParams - } else if (sessionData) { - inputData = JSON.parse(sessionData) } const validPaths = [StepEnum.selectBed] - let initialData = defaultData + let initialData: EnterDetailsState["data"] = defaultData const isValid = { [StepEnum.selectBed]: false, @@ -100,7 +111,7 @@ export function initEditDetailsState(currentStep: StepEnum) { const query = new URLSearchParams(window.location.search) if (searchParams) { Object.entries(searchParams).forEach(([key, value]) => { - query.set(key, value) + query.set(key, value ? value.toString() : "") }) } diff --git a/types/components/enterDetails/details.ts b/types/components/enterDetails/details.ts index dbd2ecb7e..55e6864f0 100644 --- a/types/components/enterDetails/details.ts +++ b/types/components/enterDetails/details.ts @@ -4,7 +4,7 @@ import { detailsSchema } from "@/components/HotelReservation/EnterDetails/Detail import type { SafeUser } from "@/types/user" -export interface DetailsSchema extends z.output {} +export type DetailsSchema = z.output export interface DetailsProps { user: SafeUser