From c2cf6b03a73b9599f2102985513702753071cafe Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Tue, 3 Feb 2026 13:27:24 +0000 Subject: [PATCH] Merged in feat/loy-291-new-claim-points-flow (pull request #3508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(LOY-291): New claim points flow for logged in users * wip new flow * More wip * More wip * Wip styling * wip with a mutation * Actually fetch booking data * More styling wip * Fix toast duration * fix loading a11y maybe * More stuff * Add feature flag * Add invalid state * Clean up * Add fields for missing user info * Restructure files * Add todos * Disable warning * Fix icon and border radius Approved-by: Emma Zettervall Approved-by: Matilda Landström --- apps/scandic-web/.env.local.example | 1 + .../Points/ClaimPoints/ClaimPointsWizard.tsx | 430 ++++++++++++++++++ .../Points/ClaimPoints/claimPoints.module.css | 97 ++++ .../Points/ClaimPoints/index.tsx | 92 ++++ apps/scandic-web/env/client.ts | 6 + .../lib/components/Form/FormInput/index.tsx | 5 +- .../Icons/MaterialIcon/generated.tsx | 5 + .../generated/EditDocumentFilled.tsx | 10 + .../generated/EditDocumentOutlined.tsx | 10 + packages/trpc/lib/routers/booking/query.ts | 2 + .../query/findBookingForCurrentUserRoute.ts | 86 ++++ packages/trpc/lib/routers/user/mutation.ts | 31 ++ scripts/material-symbols-update.mts | 1 + 13 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/ClaimPointsWizard.tsx create mode 100644 packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentFilled.tsx create mode 100644 packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentOutlined.tsx create mode 100644 packages/trpc/lib/routers/booking/query/findBookingForCurrentUserRoute.ts diff --git a/apps/scandic-web/.env.local.example b/apps/scandic-web/.env.local.example index f3db807d2..9c2ae4a7e 100644 --- a/apps/scandic-web/.env.local.example +++ b/apps/scandic-web/.env.local.example @@ -52,3 +52,4 @@ DTMC_ENTRA_ID_CLIENT="" DTMC_ENTRA_ID_ISSUER="" DTMC_ENTRA_ID_SECRET="" +NEXT_PUBLIC_NEW_POINTCLAIMS="true" diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/ClaimPointsWizard.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/ClaimPointsWizard.tsx new file mode 100644 index 000000000..c1c8e7b4d --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/ClaimPointsWizard.tsx @@ -0,0 +1,430 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +/* TODO remove disable and add i18n */ +/* TODO add analytics */ +import { zodResolver } from "@hookform/resolvers/zod" +import { cx } from "class-variance-authority" +import { useState } from "react" +import { FormProvider, useForm, useWatch } from "react-hook-form" +import { useIntl } from "react-intl" +import z from "zod" + +import { dt } from "@scandic-hotels/common/dt" +import { Button } from "@scandic-hotels/design-system/Button" +import { FormInput } from "@scandic-hotels/design-system/Form/FormInput" +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner" +import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner" +import { Typography } from "@scandic-hotels/design-system/Typography" +import { trpc } from "@scandic-hotels/trpc/client" + +import useLang from "@/hooks/useLang" + +import styles from "./claimPoints.module.css" + +type PointClaimBookingInfo = { + from: string + to: string + city: string + hotel: string +} +export function ClaimPointsWizard({ + onSuccess, + onClose, +}: { + onSuccess: () => void + onClose: () => void +}) { + const [state, setState] = useState< + "initial" | "loading" | "invalid" | "form" + >("initial") + const [bookingDetails, setBookingDetails] = + useState(null) + + const { data, isLoading } = trpc.user.getSafely.useQuery() + + if (state === "invalid") { + return + } + + if (state === "form") { + if (isLoading) { + return null + } + + return ( + + ) + } + + const handleBookingNumberEvent = (event: BookingNumberEvent) => { + switch (event.type) { + case "submit": + setState("loading") + break + case "error": + setState("initial") + break + case "invalid": + setState("invalid") + break + case "success": + setBookingDetails(event.data) + setState("form") + break + } + } + + return ( +
+ {state === "loading" && ( +
+ +
+ )} +
+
+
+ +

Claim points with booking number

+
+ +

+ Enter a valid booking number to load booking details + automatically. +

+
+
+ +
+ +
+
+ +

Claim points without booking number

+
+ +

You need to add booking details in a form.

+
+
+ +
+
+ +
+ ) +} + +type BookingNumberFormData = { + bookingNumber: string +} +type BookingNumberEvent = + | { type: "submit" } + | { type: "success"; data: PointClaimBookingInfo } + | { type: "error" } + | { type: "invalid" } +function BookingNumberInput({ + onEvent, +}: { + onEvent: (event: BookingNumberEvent) => void +}) { + const lang = useLang() + const form = useForm({ + resolver: zodResolver( + z.object({ + bookingNumber: z + .string() + // TODO Check UX for validation as different environments have different lengths + .min(9, { message: "Booking number must be 10 digits" }) + .max(10, { message: "Booking number must be 10 digits" }), + }) + ), + defaultValues: { + bookingNumber: "", + }, + }) + + const confirmationNumber = useWatch({ + name: "bookingNumber", + control: form.control, + }) + + const { refetch, isFetching } = + trpc.booking.findBookingForCurrentUser.useQuery( + { + confirmationNumber, + lang, + }, + { enabled: false } + ) + + const handleSubmit = async () => { + onEvent({ type: "submit" }) + const result = await refetch() + if (!result.data) { + onEvent({ type: "error" }) + form.setError("bookingNumber", { + type: "manual", + message: + "We could not find a booking with this number registered in your name.", + }) + return + } + + const data = result.data + + // TODO validate if this should be check out or check in date + const checkOutDate = dt(data.booking.checkOutDate) + const sixMonthsAgo = dt().subtract(6, "months") + if (checkOutDate.isBefore(sixMonthsAgo, "day")) { + onEvent({ type: "invalid" }) + return + } + + onEvent({ + type: "success", + data: { + from: data.booking.checkInDate, + to: data.booking.checkOutDate, + city: data.hotel.cityName, + hotel: data.hotel.name, + }, + }) + } + + return ( + +
+ } + description="Enter your 10-digit booking number" + maxLength={10} + showClearContentIcon + disabled={isFetching} + autoFocus + autoComplete="off" + onChange={(e) => { + const value = e.target.value + if (value.length !== 10) return + + form.handleSubmit(handleSubmit)() + }} + /> + +
+ ) +} + +function InvalidBooking({ onClose }: { onClose: () => void }) { + return ( +
+ +

+ We can’t add these points to your account as it has been longer than 6 + months since your stay. +

+
+ +
+ ) +} + +type PointClaimUserInfo = { + firstName: string + lastName: string + email: string + phone: string +} +function ClaimPointsForm({ + onSuccess, + initialData, +}: { + onSuccess: () => void + initialData: Partial | null +}) { + const form = useForm({ + resolver: zodResolver( + z.object({ + from: z.string().min(1, { message: "Arrival date is required" }), + to: z.string().min(1, { message: "Departure date is required" }), + city: z.string().min(1, { message: "City is required" }), + hotel: z.string().min(1, { message: "Hotel is required" }), + firstName: z.string().min(1, { message: "First name is required" }), + lastName: z.string().min(1, { message: "Last name is required" }), + email: z + .string() + .email("Enter a valid email") + .min(1, { message: "Email is required" }), + phone: z.string().min(1, { message: "Phone is required" }), + }) + ), + defaultValues: { + from: initialData?.from || "", + to: initialData?.to || "", + city: initialData?.city || "", + hotel: initialData?.hotel || "", + firstName: initialData?.firstName || "", + lastName: initialData?.lastName || "", + email: initialData?.email || "", + phone: initialData?.phone || "", + }, + mode: "all", + }) + + const { mutate, isPending } = trpc.user.claimPoints.useMutation({ + onSuccess, + }) + + const autoFocusField = getAutoFocus(initialData) + + return ( + +
mutate(data))} + > +
+ {!initialData?.firstName && ( + + )} + {!initialData?.lastName && ( + + )} + {!initialData?.email && ( + + )} + {!initialData?.phone && ( + + )} + } + autoFocus={autoFocusField === "from"} + readOnly={isPending} + registerOptions={{ required: true }} + /> + } + readOnly={isPending} + registerOptions={{ required: true }} + /> + + +
+ + + +
+ ) +} + +function getAutoFocus(userInfo: Partial | null) { + if (!userInfo?.firstName) { + return "firstName" + } + + if (!userInfo?.lastName) { + return "lastName" + } + + if (!userInfo?.email) { + return "email" + } + + if (!userInfo?.phone) { + return "phone" + } + + return "from" +} + +function Divider() { + const intl = useIntl() + + return ( +
+ + + {intl.formatMessage({ + id: "common.or", + defaultMessage: "or", + })} + + +
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/claimPoints.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/claimPoints.module.css index 9421503c6..589c7e66c 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/claimPoints.module.css +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/claimPoints.module.css @@ -6,3 +6,100 @@ gap: var(--Space-x2); white-space: nowrap; } + +.dialog { + max-width: 560px; +} + +.introWrapper { + position: relative; + display: flex; + flex-direction: column; + gap: var(--Space-x3); +} + +.options { + display: flex; + flex-direction: column; + gap: var(--Space-x1); +} + +.sectionCard { + display: flex; + flex-direction: column; + gap: var(--Space-x3); + padding: var(--Space-x2); + background-color: var(--Surface-Primary-OnSurface-Default); + border-radius: var(--Corner-Radius-md); +} + +.sectionInfo { + display: flex; + flex-direction: column; + gap: var(--Space-x1); +} + +.spinner { + background-color: var(--Base-Surface-Primary-light-Normal); + position: absolute; + inset: 0; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; +} + +.bookingInputDescription { + display: flex; + align-items: center; + gap: var(--Space-x05); +} + +.form { + display: flex; + flex-direction: column; + gap: var(--Space-x3); +} + +.formInputs { + display: flex; + flex-direction: column; + gap: var(--Space-x2); +} + +.formSubmit { + margin-top: auto; +} + +.divider { + width: 100%; + position: relative; + display: flex; + justify-content: center; + + & > span { + position: relative; + padding: 0 var(--Space-x2); + background-color: white; + } + + &::before { + position: absolute; + bottom: calc(50% - 1px); + content: ""; + display: block; + height: 1px; + width: 100%; + background-color: var(--Border-Default); + } +} + +.hidden { + visibility: hidden; +} + +.invalidWrapper { + display: flex; + flex-direction: column; + gap: var(--Space-x3); +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/index.tsx index 4c1699025..d026c44ef 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/ClaimPoints/index.tsx @@ -1,18 +1,110 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +/* TODO remove disable and add i18n */ "use client" +import { useEffect, useState } from "react" +import { Dialog } from "react-aria-components" import { useIntl } from "react-intl" +import { Button } from "@scandic-hotels/design-system/Button" import ButtonLink from "@scandic-hotels/design-system/ButtonLink" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import Modal from "@scandic-hotels/design-system/Modal" +import { toast } from "@scandic-hotels/design-system/Toast" import { Typography } from "@scandic-hotels/design-system/Typography" import { missingPoints } from "@/constants/missingPointsHrefs" +import { env } from "@/env/client" import useLang from "@/hooks/useLang" +import { ClaimPointsWizard } from "./ClaimPointsWizard" + import styles from "./claimPoints.module.css" export default function ClaimPoints() { + const intl = useIntl() + const [openModal, setOpenModal] = useLinkableModalState("claim-points") + + const useNewFlow = env.NEXT_PUBLIC_NEW_POINTCLAIMS + if (!useNewFlow) { + return + } + + return ( + <> +
+ +

+ {intl.formatMessage({ + id: "points.claimPoints.missingPreviousStay", + defaultMessage: "Missing a previous stay?", + })} +

+
+ +
+ setOpenModal(open)} + > + + {({ close }) => ( + { + toast.info( + <> + +

We're on it!

+
+ +

+ If your points have not been added to your account + within 2 weeks, please contact us. +

+
+ , + { + duration: Infinity, + } + ) + close() + }} + onClose={close} + /> + )} +
+
+ + ) +} + +function useLinkableModalState(target: string) { + const [openModal, setOpenModal] = useState(false) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const claimPoints = params.get("target") === target + + if (claimPoints) { + params.delete("target") + const newUrl = `${window.location.pathname}?${params.toString()}` + window.history.replaceState({}, "", newUrl) + // eslint-disable-next-line react-hooks/set-state-in-effect + setOpenModal(true) + } + }, [target]) + + return [openModal, setOpenModal] as const +} + +function OldClaimPointsLink() { const intl = useIntl() const lang = useLang() diff --git a/apps/scandic-web/env/client.ts b/apps/scandic-web/env/client.ts index e195ce7fe..da83c9df8 100644 --- a/apps/scandic-web/env/client.ts +++ b/apps/scandic-web/env/client.ts @@ -16,6 +16,11 @@ export const env = createEnv({ .transform((s) => getSemver("scandic-web", s, process.env.BRANCH || "development") ), + NEXT_PUBLIC_NEW_POINTCLAIMS: z + .string() + .optional() + .default("false") + .transform((s) => s === "true"), }, emptyStringAsUndefined: true, runtimeEnv: { @@ -26,5 +31,6 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE, NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL, NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG, + NEXT_PUBLIC_NEW_POINTCLAIMS: process.env.NEXT_PUBLIC_NEW_POINTCLAIMS, }, }) diff --git a/packages/design-system/lib/components/Form/FormInput/index.tsx b/packages/design-system/lib/components/Form/FormInput/index.tsx index 7489baf24..67408a3ae 100644 --- a/packages/design-system/lib/components/Form/FormInput/index.tsx +++ b/packages/design-system/lib/components/Form/FormInput/index.tsx @@ -85,7 +85,10 @@ export const FormInput = forwardRef( ref={mergeRefs(field.ref, ref)} name={field.name} onBlur={field.onBlur} - onChange={field.onChange} + onChange={(event) => { + field.onChange(event) + props.onChange?.(event) + }} value={field.value ?? ""} autoComplete={autoComplete} id={id ?? field.name} diff --git a/packages/design-system/lib/components/Icons/MaterialIcon/generated.tsx b/packages/design-system/lib/components/Icons/MaterialIcon/generated.tsx index 8b7b49349..1a0a579de 100644 --- a/packages/design-system/lib/components/Icons/MaterialIcon/generated.tsx +++ b/packages/design-system/lib/components/Icons/MaterialIcon/generated.tsx @@ -160,6 +160,8 @@ import EditOutlined from "./generated/EditOutlined" import EditFilled from "./generated/EditFilled" import EditCalendarOutlined from "./generated/EditCalendarOutlined" import EditCalendarFilled from "./generated/EditCalendarFilled" +import EditDocumentOutlined from "./generated/EditDocumentOutlined" +import EditDocumentFilled from "./generated/EditDocumentFilled" import EditSquareOutlined from "./generated/EditSquareOutlined" import EditSquareFilled from "./generated/EditSquareFilled" import ElectricBikeOutlined from "./generated/ElectricBikeOutlined" @@ -642,6 +644,9 @@ const _materialIcons = { edit_calendar: { rounded: { outlined: EditCalendarOutlined, filled: EditCalendarFilled }, }, + edit_document: { + rounded: { outlined: EditDocumentOutlined, filled: EditDocumentFilled }, + }, edit_square: { rounded: { outlined: EditSquareOutlined, filled: EditSquareFilled }, }, diff --git a/packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentFilled.tsx b/packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentFilled.tsx new file mode 100644 index 000000000..a1bec53fe --- /dev/null +++ b/packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentFilled.tsx @@ -0,0 +1,10 @@ +/* AUTO-GENERATED — DO NOT EDIT */ +import type { SVGProps } from "react" + +const EditDocumentFilled = (props: SVGProps) => ( + + + +) + +export default EditDocumentFilled diff --git a/packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentOutlined.tsx b/packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentOutlined.tsx new file mode 100644 index 000000000..0d16c37d9 --- /dev/null +++ b/packages/design-system/lib/components/Icons/MaterialIcon/generated/EditDocumentOutlined.tsx @@ -0,0 +1,10 @@ +/* AUTO-GENERATED — DO NOT EDIT */ +import type { SVGProps } from "react" + +const EditDocumentOutlined = (props: SVGProps) => ( + + + +) + +export default EditDocumentOutlined diff --git a/packages/trpc/lib/routers/booking/query.ts b/packages/trpc/lib/routers/booking/query.ts index 4b01defec..062608e48 100644 --- a/packages/trpc/lib/routers/booking/query.ts +++ b/packages/trpc/lib/routers/booking/query.ts @@ -1,4 +1,5 @@ import { router } from "../.." +import { findBookingForCurrentUserRoute } from "./query/findBookingForCurrentUserRoute" import { findBookingRoute } from "./query/findBookingRoute" import { getBookingRoute } from "./query/getBookingRoute" import { getBookingStatusRoute } from "./query/getBookingStatusRoute" @@ -7,6 +8,7 @@ import { getLinkedReservationsRoute } from "./query/getLinkedReservationsRoute" export const bookingQueryRouter = router({ get: getBookingRoute, findBooking: findBookingRoute, + findBookingForCurrentUser: findBookingForCurrentUserRoute, linkedReservations: getLinkedReservationsRoute, status: getBookingStatusRoute, }) diff --git a/packages/trpc/lib/routers/booking/query/findBookingForCurrentUserRoute.ts b/packages/trpc/lib/routers/booking/query/findBookingForCurrentUserRoute.ts new file mode 100644 index 000000000..6732dbcde --- /dev/null +++ b/packages/trpc/lib/routers/booking/query/findBookingForCurrentUserRoute.ts @@ -0,0 +1,86 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { notFoundError } from "../../../errors" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { findBooking } from "../../../services/booking/findBooking" +import { getHotel } from "../../hotels/services/getHotel" +import { findBookingInput } from "../input" + +export const findBookingForCurrentUserRoute = safeProtectedServiceProcedure + .input( + findBookingInput.omit({ lastName: true, firstName: true, email: true }) + ) + .query(async function ({ ctx, input }) { + const lang = input.lang ?? ctx.lang + const { confirmationNumber } = input + const user = await ctx.getScandicUser() + const token = await ctx.getScandicUserToken() + + const findBookingCounter = createCounter( + "trpc.booking.findBookingForCurrentUser" + ) + const metricsFindBooking = findBookingCounter.init({ + confirmationNumber, + }) + metricsFindBooking.start() + + if (!user || !token) { + metricsFindBooking.dataError( + `Fail to find user when finding booking for ${confirmationNumber}`, + { confirmationNumber } + ) + return null + } + + const booking = await findBooking( + { + confirmationNumber, + lang, + lastName: user.lastName, + firstName: user.firstName, + email: user.email, + }, + token + ) + + if (!booking) { + metricsFindBooking.dataError( + `Fail to find booking data for ${confirmationNumber}`, + { confirmationNumber } + ) + return null + } + + const hotelData = await getHotel( + { + hotelId: booking.hotelId, + isCardOnlyPayment: false, + language: lang, + }, + ctx.serviceToken + ) + + if (!hotelData) { + metricsFindBooking.dataError( + `Failed to find hotel data for ${booking.hotelId}`, + { + hotelId: booking.hotelId, + } + ) + + throw notFoundError({ + message: "Hotel data not found", + errorDetails: { hotelId: booking.hotelId }, + }) + } + + metricsFindBooking.success() + + return { + hotel: { + name: hotelData.hotel.name, + cityName: hotelData.hotel.cityName, + }, + booking, + } + }) diff --git a/packages/trpc/lib/routers/user/mutation.ts b/packages/trpc/lib/routers/user/mutation.ts index 4d425db9b..c57fd1310 100644 --- a/packages/trpc/lib/routers/user/mutation.ts +++ b/packages/trpc/lib/routers/user/mutation.ts @@ -1,3 +1,5 @@ +import z from "zod" + import { signupVerify } from "@scandic-hotels/common/constants/routes/signup" import { createLogger } from "@scandic-hotels/common/logger/createLogger" import { createCounter } from "@scandic-hotels/common/telemetry" @@ -318,4 +320,33 @@ export const userMutationRouter = router({ return true }), }), + claimPoints: protectedProcedure + .input( + z.object({ + from: z.string(), + to: z.string(), + city: z.string(), + hotel: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + phone: z.string(), + }) + ) + .mutation(async function ({ input, ctx }) { + userMutationLogger.info("api.user.claimPoints start") + const user = await ctx.getScandicUser() + if (!user) { + throw "error" + } + + // eslint-disable-next-line no-console + console.log("Claiming points", input, user.membershipNumber) + + // TODO Waiting for API endpoint, simulating delay + await new Promise((resolve) => setTimeout(resolve, 2_000)) + + userMutationLogger.info("api.user.claimPoints success") + return true + }), }) diff --git a/scripts/material-symbols-update.mts b/scripts/material-symbols-update.mts index 4e2bf464f..708a842ca 100644 --- a/scripts/material-symbols-update.mts +++ b/scripts/material-symbols-update.mts @@ -91,6 +91,7 @@ const ICONS = [ "download", "dresser", "edit_calendar", + "edit_document", "edit_square", "edit", "electric_bike",