Merged in feat/loy-291-new-claim-points-flow (pull request #3508)
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
This commit is contained in:
@@ -52,3 +52,4 @@ DTMC_ENTRA_ID_CLIENT=""
|
||||
DTMC_ENTRA_ID_ISSUER=""
|
||||
DTMC_ENTRA_ID_SECRET=""
|
||||
|
||||
NEXT_PUBLIC_NEW_POINTCLAIMS="true"
|
||||
|
||||
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <OldClaimPointsLink />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.claim}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "points.claimPoints.missingPreviousStay",
|
||||
defaultMessage: "Missing a previous stay?",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Button variant="Text" size="sm" onPress={() => setOpenModal(true)}>
|
||||
{intl.formatMessage({
|
||||
id: "points.claimPoints.cta",
|
||||
defaultMessage: "Claim points",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<Modal
|
||||
title="Add missing points"
|
||||
isOpen={openModal}
|
||||
onToggle={(open) => setOpenModal(open)}
|
||||
>
|
||||
<Dialog aria-label="TODO" className={styles.dialog}>
|
||||
{({ close }) => (
|
||||
<ClaimPointsWizard
|
||||
onSuccess={() => {
|
||||
toast.info(
|
||||
<>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>We're on it!</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
If your points have not been added to your account
|
||||
within 2 weeks, please contact us.
|
||||
</p>
|
||||
</Typography>
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
)
|
||||
close()
|
||||
}}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
||||
6
apps/scandic-web/env/client.ts
vendored
6
apps/scandic-web/env/client.ts
vendored
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,7 +85,10 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
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}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* AUTO-GENERATED — DO NOT EDIT */
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const EditDocumentFilled = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M220-80q-24 0-42-18t-18-42v-680q0-24 18-42t42-18h315q12 0 23.5 5t19.5 13l204 204q8 8 13 19.5t5 23.5v99q0 8-5 14.5t-13 8.5q-12 5-23 11.5T738-465L518-246q-8 8-13 19.5t-5 23.5v93q0 13-8.5 21.5T470-80zm340-30v-81q0-6 2-11t7-10l212-211q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22-4.5 22.5T902-300L692-89q-5 5-10 7t-11 2h-81q-13 0-21.5-8.5T560-110m263-194 37-39-37-37-38 38zM550-600h190L520-820l220 220-220-220v190q0 13 8.5 21.5T550-600" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default EditDocumentFilled
|
||||
@@ -0,0 +1,10 @@
|
||||
/* AUTO-GENERATED — DO NOT EDIT */
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const EditDocumentOutlined = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M560-110v-81q0-5.57 2-10.78 2-5.22 7-10.22l211.61-210.77q9.11-9.12 20.25-13.18Q812-440 823-440q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22-4.5 22.5-13.58 20.62L692-89q-5 5-10.22 7-5.21 2-10.78 2h-81q-12.75 0-21.37-8.63Q560-97.25 560-110m300-233-37-37zM620-140h38l121-122-37-37-122 121zM220-80q-24 0-42-18t-18-42v-680q0-24 18-42t42-18h315q12.44 0 23.72 5T578-862l204 204q8 8 13 19.28t5 23.72v71q0 12.75-8.68 21.37-8.67 8.63-21.5 8.63-12.82 0-21.32-8.63-8.5-8.62-8.5-21.37v-56H550q-12.75 0-21.37-8.63Q520-617.25 520-630v-190H220v680h250q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32Q482.75-80 470-80zm0-60v-680zm541-141-19-18 37 37z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default EditDocumentOutlined
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -91,6 +91,7 @@ const ICONS = [
|
||||
"download",
|
||||
"dresser",
|
||||
"edit_calendar",
|
||||
"edit_document",
|
||||
"edit_square",
|
||||
"edit",
|
||||
"electric_bike",
|
||||
|
||||
Reference in New Issue
Block a user