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
431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
/* 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<PointClaimBookingInfo | null>(null)
|
||
|
||
const { data, isLoading } = trpc.user.getSafely.useQuery()
|
||
|
||
if (state === "invalid") {
|
||
return <InvalidBooking onClose={onClose} />
|
||
}
|
||
|
||
if (state === "form") {
|
||
if (isLoading) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<ClaimPointsForm
|
||
onSuccess={onSuccess}
|
||
initialData={{
|
||
...bookingDetails,
|
||
firstName: data?.firstName ?? "",
|
||
lastName: data?.lastName ?? "",
|
||
email: data?.email ?? "",
|
||
phone: data?.phoneNumber ?? "",
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className={styles.introWrapper}>
|
||
{state === "loading" && (
|
||
<div
|
||
className={styles.spinner}
|
||
aria-live="polite"
|
||
aria-label="Loading booking details, please wait.."
|
||
>
|
||
<LoadingSpinner />
|
||
</div>
|
||
)}
|
||
<div
|
||
className={cx(styles.options, { [styles.hidden]: state === "loading" })}
|
||
>
|
||
<section className={styles.sectionCard}>
|
||
<div className={styles.sectionInfo}>
|
||
<Typography variant="Body/Paragraph/mdBold">
|
||
<h4>Claim points with booking number</h4>
|
||
</Typography>
|
||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||
<p>
|
||
Enter a valid booking number to load booking details
|
||
automatically.
|
||
</p>
|
||
</Typography>
|
||
</div>
|
||
<BookingNumberInput onEvent={handleBookingNumberEvent} />
|
||
</section>
|
||
<Divider />
|
||
<section className={styles.sectionCard}>
|
||
<div className={styles.sectionInfo}>
|
||
<Typography variant="Body/Paragraph/mdBold">
|
||
<h4>Claim points without booking number</h4>
|
||
</Typography>
|
||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||
<p>You need to add booking details in a form.</p>
|
||
</Typography>
|
||
</div>
|
||
<Button variant="Secondary" onPress={() => setState("form")}>
|
||
Fill form to claim points
|
||
</Button>
|
||
</section>
|
||
</div>
|
||
<MessageBanner
|
||
type="info"
|
||
text="Points can be claimed up to 6 months back if you were a member at the time of your stay."
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<BookingNumberFormData>({
|
||
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 (
|
||
<FormProvider {...form}>
|
||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||
<FormInput
|
||
name="bookingNumber"
|
||
label="Booking number"
|
||
leftIcon={<MaterialIcon icon="edit_document" />}
|
||
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)()
|
||
}}
|
||
/>
|
||
</form>
|
||
</FormProvider>
|
||
)
|
||
}
|
||
|
||
function InvalidBooking({ onClose }: { onClose: () => void }) {
|
||
return (
|
||
<div className={styles.invalidWrapper}>
|
||
<Typography variant="Body/Paragraph/mdRegular">
|
||
<p>
|
||
We can’t add these points to your account as it has been longer than 6
|
||
months since your stay.
|
||
</p>
|
||
</Typography>
|
||
<Button variant="Primary" fullWidth onPress={onClose}>
|
||
Close
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type PointClaimUserInfo = {
|
||
firstName: string
|
||
lastName: string
|
||
email: string
|
||
phone: string
|
||
}
|
||
function ClaimPointsForm({
|
||
onSuccess,
|
||
initialData,
|
||
}: {
|
||
onSuccess: () => void
|
||
initialData: Partial<PointClaimBookingInfo & PointClaimUserInfo> | 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 (
|
||
<FormProvider {...form}>
|
||
<form
|
||
className={styles.form}
|
||
onSubmit={form.handleSubmit((data) => mutate(data))}
|
||
>
|
||
<div className={styles.formInputs}>
|
||
{!initialData?.firstName && (
|
||
<FormInput
|
||
name="firstName"
|
||
label="First name"
|
||
autoFocus={autoFocusField === "firstName"}
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
)}
|
||
{!initialData?.lastName && (
|
||
<FormInput
|
||
name="lastName"
|
||
label="Last name"
|
||
autoFocus={autoFocusField === "lastName"}
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
)}
|
||
{!initialData?.email && (
|
||
<FormInput
|
||
name="email"
|
||
label="Email"
|
||
type="email"
|
||
autoFocus={autoFocusField === "email"}
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
)}
|
||
{!initialData?.phone && (
|
||
<FormInput
|
||
name="phone"
|
||
label="Phone"
|
||
type="tel"
|
||
autoFocus={autoFocusField === "phone"}
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
)}
|
||
<FormInput
|
||
name="from"
|
||
label="Arrival (YYYY-MM-DD)"
|
||
leftIcon={<MaterialIcon icon="calendar_today" />}
|
||
autoFocus={autoFocusField === "from"}
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
<FormInput
|
||
name="to"
|
||
label="Departure (YYYY-MM-DD)"
|
||
leftIcon={<MaterialIcon icon="calendar_today" />}
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
<FormInput
|
||
name="city"
|
||
label="City"
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
<FormInput
|
||
name="hotel"
|
||
label="Hotel"
|
||
readOnly={isPending}
|
||
registerOptions={{ required: true }}
|
||
/>
|
||
</div>
|
||
<MessageBanner
|
||
type="info"
|
||
text="Points can be claimed up to 6 months back if you were a member at the time of your stay."
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
variant="Primary"
|
||
fullWidth
|
||
isDisabled={!form.formState.isValid}
|
||
isPending={isPending}
|
||
className={styles.formSubmit}
|
||
>
|
||
Send points claim
|
||
</Button>
|
||
</form>
|
||
</FormProvider>
|
||
)
|
||
}
|
||
|
||
function getAutoFocus(userInfo: Partial<PointClaimUserInfo> | 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 (
|
||
<div className={styles.divider}>
|
||
<Typography variant="Body/Paragraph/mdRegular">
|
||
<span>
|
||
{intl.formatMessage({
|
||
id: "common.or",
|
||
defaultMessage: "or",
|
||
})}
|
||
</span>
|
||
</Typography>
|
||
</div>
|
||
)
|
||
}
|