From c9684dee11322fd2fe840ae200d375cf496ac7d3 Mon Sep 17 00:00:00 2001 From: Christel Westerberg Date: Thu, 10 Oct 2024 07:40:34 +0200 Subject: [PATCH] feat: add validation to enter details flow --- .../hotelreservation/[step]/layout.tsx | 26 ++-- .../(public)/hotelreservation/[step]/page.tsx | 92 +++--------- .../EnterDetails/BedType/index.tsx | 32 +++- .../EnterDetails/BedType/schema.ts | 2 +- .../EnterDetails/Breakfast/index.tsx | 25 ++- .../EnterDetails/Details/index.tsx | 39 ++++- .../EnterDetails/Provider/index.tsx | 26 ++++ .../SectionAccordion/index.tsx | 43 ++++-- .../sectionAccordion.module.css | 4 + .../TempDesignSystem/Button/button.module.css | 1 + .../Form/ChoiceCard/_Card/index.tsx | 6 +- server/routers/user/query.ts | 6 +- stores/enter-details.ts | 142 ++++++++++++++++++ types/components/enterDetails/details.ts | 4 +- types/components/enterDetails/step.ts | 6 + .../selectRate/sectionAccordion.ts | 6 +- types/user.ts | 14 +- 17 files changed, 357 insertions(+), 117 deletions(-) create mode 100644 components/HotelReservation/EnterDetails/Provider/index.tsx rename components/HotelReservation/{SelectRate => EnterDetails}/SectionAccordion/index.tsx (69%) rename components/HotelReservation/{SelectRate => EnterDetails}/SectionAccordion/sectionAccordion.module.css (95%) create mode 100644 stores/enter-details.ts create mode 100644 types/components/enterDetails/step.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx index 8ced3a7ce..0e075b594 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation" import { serverClient } from "@/lib/trpc/server" +import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider" import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom" import Summary from "@/components/HotelReservation/EnterDetails/Summary" import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" @@ -9,12 +10,13 @@ import { setLang } from "@/i18n/serverContext" import styles from "./layout.module.css" +import { StepEnum } from "@/types/components/enterDetails/step" import type { LangParams, LayoutArgs } from "@/types/params" export default async function StepLayout({ children, params, -}: React.PropsWithChildren>) { +}: React.PropsWithChildren>) { setLang(params.lang) const hotel = await serverClient().hotel.hotelData.get({ hotelId: "811", @@ -26,15 +28,17 @@ export default async function StepLayout({ } return ( -
- -
- - {children} - -
-
+ +
+ +
+ + {children} + +
+
+
) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx index 8ad0463d8..8718c8db7 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx @@ -1,117 +1,67 @@ -"use client" - import { notFound } from "next/navigation" -import { useState } from "react" -import { useIntl } from "react-intl" -import { trpc } from "@/lib/trpc/client" +import { getProfileSafely } from "@/lib/trpc/memoizedRequests" +import { serverClient } from "@/lib/trpc/server" import BedType from "@/components/HotelReservation/EnterDetails/BedType" import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast" import Details from "@/components/HotelReservation/EnterDetails/Details" +import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion" import Payment from "@/components/HotelReservation/SelectRate/Payment" -import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion" -import LoadingSpinner from "@/components/LoadingSpinner" +import { getIntl } from "@/i18n" +import { StepEnum } from "@/types/components/enterDetails/step" import type { 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({ +export default async function StepPage({ params, }: PageArgs) { - const { step } = params - const [activeStep, setActiveStep] = useState(step) - const intl = useIntl() + const { step, lang } = params - if (!isValidStep(activeStep)) { + const intl = await getIntl() + + const hotel = await serverClient().hotel.hotelData.get({ + hotelId: "811", + language: lang, + }) + + const user = await getProfileSafely() + + if (!isValidStep(step) || !hotel) { return notFound() } - const { data: hotel, isLoading: loadingHotel } = - trpc.hotel.hotelData.get.useQuery({ - hotelId: "811", - language: params.lang, - }) - - const { data: userData } = trpc.user.getSafely.useQuery() - - if (loadingHotel) { - return - } - - if (!hotel) { - // TODO: handle case with hotel missing - return notFound() - } - - switch (activeStep) { - case StepEnum.breakfast: - //return
Select BREAKFAST
- case StepEnum.details: - //return
Select DETAILS
- case StepEnum.payment: - //return
Select PAYMENT
- case StepEnum.selectBed: - // return
Select BED
- } - - function onNav(step: StepEnum) { - setActiveStep(step) - if (typeof window !== "undefined") { - window.history.pushState({}, "", step) - } - } - - let user = null - if (userData && !("error" in userData)) { - user = userData - } - return (
diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 955ce740b..106aaa80a 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -1,9 +1,12 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" +import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" +import { useEnterDetailsStore } from "@/stores/enter-details" + import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -16,8 +19,14 @@ import { bedTypeEnum } from "@/types/enums/bedType" export default function BedType() { const intl = useIntl() + const bedType = useEnterDetailsStore((state) => state.data.bedType) const methods = useForm({ + defaultValues: bedType + ? { + bedType, + } + : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(bedTypeSchema), @@ -28,15 +37,32 @@ export default function BedType() { { id: "Included (based on availability)" }, { b: (str) => {str} } ) + const completeStep = useEnterDetailsStore((state) => state.completeStep) + + const onSubmit = useCallback( + (values: BedTypeSchema) => { + completeStep(values) + }, + [completeStep] + ) + + useEffect(() => { + if (methods.formState.isSubmitting) { + return + } + + const subscription = methods.watch(() => methods.handleSubmit(onSubmit)()) + return () => subscription.unsubscribe() + }, [methods, onSubmit]) return ( -
+ state.data.breakfast) + const methods = useForm({ + defaultValues: breakfast ? { breakfast } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(breakfastSchema), reValidateMode: "onChange", }) + const completeStep = useEnterDetailsStore((state) => state.completeStep) + + const onSubmit = useCallback( + (values: BreakfastSchema) => { + completeStep(values) + }, + [completeStep] + ) + + useEffect(() => { + if (methods.formState.isSubmitting) { + return + } + const subscription = methods.watch(() => methods.handleSubmit(onSubmit)()) + return () => subscription.unsubscribe() + }, [methods, onSubmit]) + return ( - + ({ + countryCode: state.data.countryCode, + email: state.data.email, + firstname: state.data.firstname, + lastname: state.data.lastname, + phoneNumber: state.data.phoneNumber, + })) + const methods = useForm({ defaultValues: { - countryCode: user?.address?.countryCode ?? "", - email: user?.email ?? "", - firstname: user?.firstName ?? "", - lastname: user?.lastName ?? "", - phoneNumber: user?.phoneNumber ?? "", + countryCode: user?.address?.countryCode ?? initialData.countryCode, + email: user?.email ?? initialData.email, + firstname: user?.firstName ?? initialData.firstname, + lastname: user?.lastName ?? initialData.lastname, + phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, }, criteriaMode: "all", mode: "all", @@ -42,6 +54,15 @@ export default function Details({ user }: DetailsProps) { reValidateMode: "onChange", }) + const completeStep = useEnterDetailsStore((state) => state.completeStep) + + const onSubmit = useCallback( + (values: DetailsSchema) => { + completeStep(values) + }, + [completeStep] + ) + return (
@@ -50,7 +71,11 @@ export default function Details({ user }: DetailsProps) { {intl.formatMessage({ id: "Guest information" })} - + {intl.formatMessage({ id: "Proceed to payment method" })} diff --git a/components/HotelReservation/EnterDetails/Provider/index.tsx b/components/HotelReservation/EnterDetails/Provider/index.tsx new file mode 100644 index 000000000..6e4d6cc2c --- /dev/null +++ b/components/HotelReservation/EnterDetails/Provider/index.tsx @@ -0,0 +1,26 @@ +"use client" +import { PropsWithChildren, useRef } from "react" + +import { + EnterDetailsContext, + type EnterDetailsStore, + initEditDetailsState, +} from "@/stores/enter-details" + +import { StepEnum } from "@/types/components/enterDetails/step" + +export default function EnterDetailsProvider({ + step, + children, +}: PropsWithChildren<{ step: StepEnum }>) { + const initialStore = useRef() + if (!initialStore.current) { + initialStore.current = initEditDetailsState(step) + } + + return ( + + {children} + + ) +} diff --git a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx similarity index 69% rename from components/HotelReservation/SelectRate/SectionAccordion/index.tsx rename to components/HotelReservation/EnterDetails/SectionAccordion/index.tsx index a56902248..4f6e87429 100644 --- a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx +++ b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx @@ -1,9 +1,11 @@ "use client" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" import { useIntl } from "react-intl" +import { useEnterDetailsStore } from "@/stores/enter-details" + import { CheckIcon, ChevronDownIcon } from "@/components/Icons" -import Link from "@/components/TempDesignSystem/Link" +import Button from "@/components/TempDesignSystem/Button" import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" @@ -13,17 +15,22 @@ import { SectionAccordionProps } from "@/types/components/hotelReservation/selec export default function SectionAccordion({ header, - isOpen, - isCompleted, label, - path, + step, children, }: React.PropsWithChildren) { const intl = useIntl() + const [isComplete, setIsComplete] = useState(false) + const currentStep = useEnterDetailsStore((state) => state.currentStep) + const isValid = useEnterDetailsStore((state) => state.isValid[step]) + + const navigate = useEnterDetailsStore((state) => state.navigate) const contentRef = useRef(null) const circleRef = useRef(null) + const isOpen = currentStep === step + useEffect(() => { const content = contentRef.current const circle = circleRef.current @@ -44,15 +51,24 @@ export default function SectionAccordion({ } }, [isOpen]) + useEffect(() => { + // We need to set the state on mount because of hydration errors + setIsComplete(isValid) + }, [isValid]) + + function onModify() { + navigate(step) + } + return (
- {isCompleted ? ( + {isComplete ? ( ) : null}
@@ -75,11 +91,18 @@ export default function SectionAccordion({ {label}
- {isCompleted && !isOpen && ( - + {isComplete && !isOpen && ( + )}
diff --git a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css similarity index 95% rename from components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css rename to components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index 6f734737b..11598c8bd 100644 --- a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -75,3 +75,7 @@ transition: max-height 0.4s ease-out; max-height: 0; } + +.wrapper[data-open="true"] .content { + max-height: 1000px; +} diff --git a/components/TempDesignSystem/Button/button.module.css b/components/TempDesignSystem/Button/button.module.css index 0afa102a1..1ad432836 100644 --- a/components/TempDesignSystem/Button/button.module.css +++ b/components/TempDesignSystem/Button/button.module.css @@ -53,6 +53,7 @@ a.inverted { a.text { background: none; border: none; + outline: none; } /* VARIANTS */ diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx index 8d020ea4d..5de39b49e 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx @@ -1,5 +1,7 @@ "use client" +import { useFormContext } from "react-hook-form" + import { CheckIcon, CloseIcon, HeartIcon } from "@/components/Icons" import Caption from "@/components/TempDesignSystem/Text/Caption" import Footnote from "@/components/TempDesignSystem/Text/Footnote" @@ -23,6 +25,8 @@ export default function Card({ type, value, }: CardProps) { + const { register } = useFormContext() + return ( ) diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts index df900953c..3a6eaeaf2 100644 --- a/server/routers/user/query.ts +++ b/server/routers/user/query.ts @@ -238,14 +238,10 @@ export const userQueryRouter = router({ const data = await getVerifiedUser({ session: ctx.session }) - if (!data) { + if (!data || "error" in data) { return null } - if ("error" in data) { - return data - } - return parsedUser(data.data, true) }), name: safeProtectedProcedure.query(async function ({ ctx }) { diff --git a/stores/enter-details.ts b/stores/enter-details.ts new file mode 100644 index 000000000..41d15c3a5 --- /dev/null +++ b/stores/enter-details.ts @@ -0,0 +1,142 @@ +import { produce } from "immer" +import { createContext, useContext } from "react" +import { create, useStore } from "zustand" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { detailsSchema } from "@/components/HotelReservation/EnterDetails/Details/schema" + +import { DetailsSchema } from "@/types/components/enterDetails/details" +import { StepEnum } from "@/types/components/enterDetails/step" +import { bedTypeEnum } from "@/types/enums/bedType" +import { breakfastEnum } from "@/types/enums/breakfast" + +interface EnterDetailsState { + data: { + bedType: bedTypeEnum | undefined + breakfast: breakfastEnum | undefined + } & DetailsSchema + steps: StepEnum[] + currentStep: StepEnum + isValid: Record + completeStep: (updatedData: Partial) => void + navigate: (step: StepEnum, searchParams?: Record) => void +} + +export function initEditDetailsState(currentStep: StepEnum) { + const isBrowser = typeof window !== "undefined" + const sessionData = isBrowser ? sessionStorage.getItem("editDetails") : null + const search = isBrowser ? new URLSearchParams(window.location.search) : null + + const defaultData: EnterDetailsState["data"] = { + bedType: undefined, + breakfast: undefined, + countryCode: "", + email: "", + firstname: "", + lastname: "", + phoneNumber: "", + } + + let inputData = {} + if (search?.size) { + const searchParams: Record = {} + search.forEach((value, key) => { + searchParams[key] = value + }) + + inputData = searchParams + } else if (sessionData) { + inputData = JSON.parse(sessionData) + } + + const validPaths = [StepEnum.selectBed] + + let initialData = defaultData + + const isValid = { + [StepEnum.selectBed]: false, + [StepEnum.breakfast]: false, + [StepEnum.details]: false, + [StepEnum.payment]: false, + } + + const validatedBedType = bedTypeSchema.safeParse(inputData) + if (validatedBedType.success) { + validPaths.push(StepEnum.breakfast) + initialData = { ...initialData, ...validatedBedType.data } + isValid[StepEnum.selectBed] = true + } + const validatedBreakfast = breakfastSchema.safeParse(inputData) + if (validatedBreakfast.success) { + validPaths.push(StepEnum.details) + initialData = { ...initialData, ...validatedBreakfast.data } + isValid[StepEnum.breakfast] = true + } + const validatedDetails = detailsSchema.safeParse(inputData) + if (validatedDetails.success) { + validPaths.push(StepEnum.payment) + initialData = { ...initialData, ...validatedDetails.data } + isValid[StepEnum.details] = true + } + + if (!validPaths.includes(currentStep)) { + currentStep = validPaths.pop()! // We will always have at least one valid path + if (isBrowser) { + window.history.pushState({}, "", currentStep + window.location.search) + } + } + + return create()((set, get) => ({ + data: initialData, + steps: Object.values(StepEnum), + navigate: (step, searchParams) => + set( + produce((state) => { + const query = new URLSearchParams(window.location.search) + if (searchParams) { + Object.entries(searchParams).forEach(([key, value]) => { + query.set(key, value) + }) + } + + state.currentStep = step + window.history.pushState({}, "", step + "?" + query.toString()) + }) + ), + currentStep, + isValid, + completeStep: (updatedData) => + set( + produce((state) => { + state.isValid[state.currentStep] = true + + const nextStep = + state.steps[state.steps.indexOf(state.currentStep) + 1] + + state.data = { ...state.data, ...updatedData } + + state.currentStep = nextStep + get().navigate(nextStep, updatedData) + }) + ), + })) +} + +export type EnterDetailsStore = ReturnType + +export const EnterDetailsContext = createContext(null) + +export const useEnterDetailsStore = ( + selector: (store: EnterDetailsState) => T +): T => { + const enterDetailsContextStore = useContext(EnterDetailsContext) + + if (!enterDetailsContextStore) { + throw new Error( + `useEnterDetailsStore must be used within EnterDetailsContextProvider` + ) + } + + return useStore(enterDetailsContextStore, selector) +} diff --git a/types/components/enterDetails/details.ts b/types/components/enterDetails/details.ts index 84996da56..dbd2ecb7e 100644 --- a/types/components/enterDetails/details.ts +++ b/types/components/enterDetails/details.ts @@ -2,10 +2,10 @@ import { z } from "zod" import { detailsSchema } from "@/components/HotelReservation/EnterDetails/Details/schema" -import { User } from "@/types/user" +import type { SafeUser } from "@/types/user" export interface DetailsSchema extends z.output {} export interface DetailsProps { - user: User | null + user: SafeUser } diff --git a/types/components/enterDetails/step.ts b/types/components/enterDetails/step.ts new file mode 100644 index 000000000..e52d3c856 --- /dev/null +++ b/types/components/enterDetails/step.ts @@ -0,0 +1,6 @@ +export enum StepEnum { + selectBed = "select-bed", + breakfast = "breakfast", + details = "details", + payment = "payment", +} diff --git a/types/components/hotelReservation/selectRate/sectionAccordion.ts b/types/components/hotelReservation/selectRate/sectionAccordion.ts index 802e471f9..7e85bf768 100644 --- a/types/components/hotelReservation/selectRate/sectionAccordion.ts +++ b/types/components/hotelReservation/selectRate/sectionAccordion.ts @@ -1,7 +1,7 @@ +import { StepEnum } from "../../enterDetails/step" + export interface SectionAccordionProps { header: string - isOpen: boolean - isCompleted: boolean label: string - path: string + step: StepEnum } diff --git a/types/user.ts b/types/user.ts index 78587fa7c..75f0fa05c 100644 --- a/types/user.ts +++ b/types/user.ts @@ -1,15 +1,23 @@ import { z } from "zod" -import { creditCardSchema, getUserSchema, membershipSchema } from "@/server/routers/user/output" +import { + creditCardSchema, + getUserSchema, + membershipSchema, +} from "@/server/routers/user/output" + +import type { RouterOutput } from "@/lib/trpc/client" /** * All extended field needs to be added by API team to response or * we have to get the values from elsewhere */ -export interface User extends z.output { } +export interface User extends z.output {} + +export type SafeUser = RouterOutput["user"]["getSafely"] export type CreditCard = z.output -export interface Membership extends z.output { } +export interface Membership extends z.output {} export type Memberships = Membership[]