Merged in feat/server-action-trpc (pull request #422)

feat(SW-160): update profile

Approved-by: Christel Westerberg
This commit is contained in:
Simon.Emanuelsson
2024-08-23 07:31:06 +00:00
committed by Michael Zetterberg
32 changed files with 459 additions and 244 deletions

View File

@@ -1,64 +1,100 @@
"use server" "use server"
import { ZodError } from "zod"
import { z } from "zod"
import { ApiLang } from "@/constants/languages"
import * as api from "@/lib/api"
import { protectedServerActionProcedure } from "@/server/trpc"
import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema" import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema"
import { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries"
import { phoneValidator } from "@/utils/phoneValidator"
import { type State, Status } from "@/types/components/myPages/myProfile/edit" import { Status } from "@/types/components/myPages/myProfile/edit"
export async function editProfile(_prevState: State, values: FormData) { const editProfilePayload = z
try { .object({
const data: Record<string, any> = Object.fromEntries(values.entries()) address: z.object({
city: z.string().optional(),
/** countryCode: z.nativeEnum(countriesMap),
* TODO: Update profile data when endpoint from streetAddress: z.string().optional(),
* API team is ready zipCode: z.string().min(1, { message: "Zip code is required" }),
*/ }),
dateOfBirth: z.string(),
console.info(`Raw Data`) email: z.string().email(),
console.log(data) language: z.nativeEnum(ApiLang),
data.address = { newPassword: z.string().optional(),
city: data["address.city"], password: z.string().optional(),
countryCode: data["address.countryCode"], phoneNumber: phoneValidator("Phone is required"),
streetAddress: data["address.streetAddress"], })
zipCode: data["address.zipCode"], .transform((data) => {
if (!data.password || !data.newPassword) {
delete data.password
delete data.newPassword
} }
const parsedData = editProfileSchema.safeParse(data) return data
if (parsedData.success) { })
console.info(`Success`)
console.log(parsedData.data)
return {
message: "All good!",
status: Status.success,
}
} else {
console.error("Error parsing edit profile")
console.error(parsedData.error)
return {
message: "Invalid data, parse failed!",
status: Status.error,
}
}
} catch (error) {
if (error instanceof ZodError) {
console.error(`ZodError handling profile edit`)
console.error(error)
export const editProfile = protectedServerActionProcedure
.input(editProfileSchema)
.mutation(async function ({ ctx, input }) {
const payload = editProfilePayload.safeParse(input)
if (!payload.success) {
return { return {
errors: error.issues.map((issue) => ({ data: input,
message: `Server validation: ${issue.message}`, issues: payload.error.issues.map((issue) => ({
path: issue.path.join("."), field: issue.path.join("."),
message: issue.message,
})), })),
message: "Invalid form data", message: "Validation failed.",
status: Status.error, status: Status.error,
} }
} }
console.error(`EditProfile Server Action Error`) const response = await api.patch(api.endpoints.v1.profile, {
console.error(error) body: payload.data,
cache: "no-store",
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
if (!response.ok) {
console.info(`Response not ok`)
console.error(response)
return {
data: input,
issues: [],
message: "Server error",
status: Status.error,
}
}
const json = await response.json()
if (json.errors?.length) {
json.errors.forEach((error: any) => {
console.info(`API Fail in response`)
console.error(error)
})
}
const validatedData = editProfileSchema.safeParse(json.data.attributes)
if (!validatedData.success) {
console.log({ ees: validatedData.error })
return {
data: input,
issues: validatedData.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
message: "Data is insufficient",
status: Status.error,
}
}
return { return {
message: "Something went wrong. Please try again.", data: validatedData.data,
status: Status.error, message: "All good!",
status: Status.success,
} }
} })
}

View File

@@ -1,6 +1,7 @@
.container { .container {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
justify-items: flex-start;
max-width: 510px; max-width: 510px;
} }
@@ -18,4 +19,4 @@
.container { .container {
gap: var(--Spacing-x3); gap: var(--Spacing-x3);
} }
} }

View File

@@ -1,5 +1,5 @@
/** /**
* Due to css import issues with parallell routes we are forced to * Due to css import issues with parallel routes we are forced to
* use a regular css file and import it in the page.tsx * use a regular css file and import it in the page.tsx
*/ */
.profile-layout { .profile-layout {

View File

@@ -233,4 +233,4 @@ export const {
auth, auth,
signIn, signIn,
signOut, signOut,
} = NextAuth(config) } = NextAuth(config)

View File

@@ -10,3 +10,9 @@
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-columns: max(164px) 1fr; grid-template-columns: max(164px) 1fr;
} }
@media (min-width: 768px) {
.divider {
display: none;
}
}

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { languageSelect } from "@/constants/languages" import { languageSelect } from "@/constants/languages"
import Divider from "@/components/TempDesignSystem/Divider"
import CountrySelect from "@/components/TempDesignSystem/Form/Country" import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import DateSelect from "@/components/TempDesignSystem/Form/Date" import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input" import Input from "@/components/TempDesignSystem/Form/Input"
@@ -15,24 +16,24 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./formContent.module.css" import styles from "./formContent.module.css"
export default function FormContent() { export default function FormContent() {
const { formatMessage } = useIntl() const intl = useIntl()
// const { pending } = useFormStatus() // const { pending } = useFormStatus()
const city = formatMessage({ id: "City" }) const city = intl.formatMessage({ id: "City" })
const country = formatMessage({ id: "Country" }) const country = intl.formatMessage({ id: "Country" })
const email = `${formatMessage({ id: "Email" })} ${formatMessage({ id: "Address" }).toLowerCase()}` const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
const street = formatMessage({ id: "Address" }) const street = intl.formatMessage({ id: "Address" })
const phoneNumber = formatMessage({ id: "Phone number" }) const phoneNumber = intl.formatMessage({ id: "Phone number" })
const password = formatMessage({ id: "Current password" }) const password = intl.formatMessage({ id: "Current password" })
const retypeNewPassword = formatMessage({ id: "Retype new password" }) const retypeNewPassword = intl.formatMessage({ id: "Retype new password" })
const zipCode = formatMessage({ id: "Zip code" }) const zipCode = intl.formatMessage({ id: "Zip code" })
return ( return (
<> <>
<section className={styles.user}> <section className={styles.user}>
<header> <header>
<Body textTransform="bold"> <Body textTransform="bold">
{formatMessage({ id: "User information" })} {intl.formatMessage({ id: "User information" })}
</Body> </Body>
</header> </header>
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} /> <DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
@@ -59,16 +60,19 @@ export default function FormContent() {
<Phone label={phoneNumber} name="phoneNumber" /> <Phone label={phoneNumber} name="phoneNumber" />
<Select <Select
items={languageSelect} items={languageSelect}
label={formatMessage({ id: "Language" })} label={intl.formatMessage({ id: "Language" })}
name="language" name="language"
placeholder={formatMessage({ id: "Select language" })} placeholder={intl.formatMessage({ id: "Select language" })}
/> />
</section> </section>
<Divider className={styles.divider} color="subtle" />
<section className={styles.password}> <section className={styles.password}>
<header> <header>
<Body textTransform="bold">{formatMessage({ id: "Password" })}</Body> <Body textTransform="bold">
{intl.formatMessage({ id: "Password" })}
</Body>
</header> </header>
<Input label={password} name="currentPassword" type="password" /> <Input label={password} name="password" type="password" />
<NewPassword /> <NewPassword />
<Input <Input
label={retypeNewPassword} label={retypeNewPassword}

View File

@@ -24,10 +24,6 @@
grid-area: buttons; grid-area: buttons;
} }
.btn {
justify-content: center;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.container { .container {
grid-template-areas: grid-template-areas:
@@ -40,9 +36,9 @@
} }
.btnContainer { .btnContainer {
align-self: center;
flex-direction: row; flex-direction: row;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
justify-self: flex-end; justify-self: flex-end;
align-self: center;
} }
} }

View File

@@ -1,8 +1,8 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js" import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
import { useParams } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { useFormState as useReactFormState } from "react-dom" import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { usePhoneInput } from "react-international-phone" import { usePhoneInput } from "react-international-phone"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -13,32 +13,32 @@ import { editProfile } from "@/actions/editProfile"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import FormContent from "./FormContent" import FormContent from "./FormContent"
import { type EditProfileSchema, editProfileSchema } from "./schema" import { type EditProfileSchema, editProfileSchema } from "./schema"
import styles from "./form.module.css" import styles from "./form.module.css"
import type { import {
EditFormProps, type EditFormProps,
State, Status,
} from "@/types/components/myPages/myProfile/edit" } from "@/types/components/myPages/myProfile/edit"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
const formId = "edit-profile" const formId = "edit-profile"
export default function Form({ user }: EditFormProps) { export default function Form({ user }: EditFormProps) {
const { formatMessage } = useIntl() const intl = useIntl()
const router = useRouter()
const params = useParams() const params = useParams()
const lang = params.lang as Lang const lang = params.lang as Lang
/** /**
* like react, react-hook-form also exports a useFormState hook, * RHF isValid defaults to false and never
* we want to clearly keep them separate by naming. * changes when JS is disabled, therefore
* we need to keep it insync ourselves
*/ */
const [state, formAction] = useReactFormState<State, FormData>( const [isValid, setIsValid] = useState(true)
editProfile,
null
)
const { inputValue: phoneInput } = usePhoneInput({ const { inputValue: phoneInput } = usePhoneInput({
defaultCountry: defaultCountry:
@@ -61,7 +61,7 @@ export default function Form({ user }: EditFormProps) {
email: user.email, email: user.email,
language: user.language, language: user.language,
phoneNumber: phoneInput, phoneNumber: phoneInput,
currentPassword: "", password: "",
newPassword: "", newPassword: "",
retypeNewPassword: "", retypeNewPassword: "",
}, },
@@ -70,44 +70,77 @@ export default function Form({ user }: EditFormProps) {
reValidateMode: "onChange", reValidateMode: "onChange",
}) })
async function handleSubmit(data: EditProfileSchema) {
const response = await editProfile(data)
switch (response.status) {
case Status.error:
if (response.issues?.length) {
response.issues.forEach((issue) => {
console.error(issue)
})
}
toast.error(
intl.formatMessage({
id: "An error occured when trying to update profile.",
})
)
break
case Status.success:
toast.success(
intl.formatMessage({ id: "Successfully updated profile!" })
)
/**
* TODO: Toaster?
* Design only has toasters for credit cards
* and membership cards
*/
router.push(profile[lang])
break
}
}
useEffect(() => {
setIsValid(methods.formState.isValid)
}, [setIsValid, methods.formState.isValid])
return ( return (
<section className={styles.container}> <section className={styles.container}>
<hgroup className={styles.title}> <hgroup className={styles.title}>
<Title as="h4" color="red" level="h1" textTransform="capitalize"> <Title as="h4" color="red" level="h1" textTransform="capitalize">
{formatMessage({ id: "Welcome" })} {intl.formatMessage({ id: "Welcome" })}
</Title> </Title>
<Title as="h4" color="burgundy" level="h2" textTransform="capitalize"> <Title as="h4" color="burgundy" level="h2" textTransform="capitalize">
{user.name} {user.name}
</Title> </Title>
</hgroup> </hgroup>
<div className={styles.btnContainer}> <div className={styles.btnContainer}>
<Button <Button asChild intent="secondary" size="small" theme="base">
asChild
intent="secondary"
size="small"
theme="base"
className={styles.btn}
>
<Link href={profile[lang]}> <Link href={profile[lang]}>
{formatMessage({ id: "Discard changes" })} {intl.formatMessage({ id: "Discard changes" })}
</Link> </Link>
</Button> </Button>
<Button <Button
disabled={ disabled={!isValid || methods.formState.isSubmitting}
!methods.formState.isValid || methods.formState.isSubmitting
}
className={styles.btn}
form={formId} form={formId}
intent="primary" intent="primary"
size="small" size="small"
theme="base" theme="base"
type="submit" type="submit"
> >
{formatMessage({ id: "Save" })} {intl.formatMessage({ id: "Save" })}
</Button> </Button>
</div> </div>
<form action={formAction} className={styles.form} id={formId}> <form
/**
* Ignoring since ts doesn't recognize that tRPC
* parses FormData before reaching the route
* @ts-ignore */
action={editProfile}
className={styles.form}
id={formId}
onSubmit={methods.handleSubmit(handleSubmit)}
>
<FormProvider {...methods}> <FormProvider {...methods}>
<FormContent /> <FormContent />
</FormProvider> </FormProvider>

View File

@@ -25,12 +25,12 @@ export const editProfileSchema = z
"Please enter a valid phone number" "Please enter a valid phone number"
), ),
currentPassword: z.string().optional(), password: z.string().optional(),
newPassword: z.string().optional(), newPassword: z.string().optional(),
retypeNewPassword: z.string().optional(), retypeNewPassword: z.string().optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.currentPassword) { if (data.password) {
if (!data.newPassword) { if (!data.newPassword) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
@@ -50,7 +50,7 @@ export const editProfileSchema = z
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Current password is required", message: "Current password is required",
path: ["currentPassword"], path: ["password"],
}) })
} }
} }

View File

@@ -58,12 +58,14 @@ a.default {
align-items: center; align-items: center;
display: flex; display: flex;
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
justify-content: center;
} }
.icon { .icon {
display: flex;
align-items: center; align-items: center;
display: flex;
gap: var(--Spacing-x-half); gap: var(--Spacing-x-half);
justify-content: center;
} }
/* SIZES */ /* SIZES */
@@ -798,4 +800,4 @@ a.default {
.icon.tertiaryLightSecondary:disabled svg, .icon.tertiaryLightSecondary:disabled svg,
.icon.tertiaryLightSecondary:disabled svg * { .icon.tertiaryLightSecondary:disabled svg * {
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled); fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled);
} }

View File

@@ -17,7 +17,6 @@ import type { Key } from "react-aria-components"
import type { DateProps } from "./date" import type { DateProps } from "./date"
/** TODO: Get selecting with Enter-key to work */
export default function DateSelect({ name, registerOptions = {} }: DateProps) { export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
const d = useWatch({ name }) const d = useWatch({ name })
@@ -54,6 +53,18 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const monthLabel = formatMessage({ id: "Month" }) const monthLabel = formatMessage({ id: "Month" })
const yearLabel = formatMessage({ id: "Year" }) const yearLabel = formatMessage({ id: "Year" })
let dateValue = null
try {
/**
* parseDate throws when its not a valid
* date, but we can't check isNan since
* we recieve the date as "1999-01-01"
*/
dateValue = parseDate(d)
} catch (error) {
console.error(error)
}
return ( return (
<DatePicker <DatePicker
aria-label={formatMessage({ id: "Select date of birth" })} aria-label={formatMessage({ id: "Select date of birth" })}
@@ -61,7 +72,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
isRequired={!!registerOptions.required} isRequired={!!registerOptions.required}
name={name} name={name}
ref={field.ref} ref={field.ref}
value={isNaN(d) ? undefined : parseDate(d)} value={dateValue}
> >
<Group> <Group>
<DateInput className={styles.container}> <DateInput className={styles.container}>

View File

@@ -32,8 +32,8 @@ const config = {
defaultVariants: { defaultVariants: {
color: "burgundy", color: "burgundy",
textAlign: "left", textAlign: "left",
type: "h1",
textTransform: "uppercase", textTransform: "uppercase",
type: "h1",
}, },
} as const } as const

View File

@@ -1,10 +1,10 @@
export enum Lang { export enum Lang {
en = "en",
sv = "sv",
no = "no",
fi = "fi",
da = "da", da = "da",
de = "de", de = "de",
en = "en",
fi = "fi",
no = "no",
sv = "sv",
} }
export const languages: Record<Lang, string> = { export const languages: Record<Lang, string> = {
@@ -56,11 +56,21 @@ export const localeToLang: Record<string, Lang> = {
"se-NO": Lang.no, "se-NO": Lang.no,
} as const } as const
export enum ApiLang {
Da = "Da",
De = "De",
En = "En",
Fi = "Fi",
No = "No",
Sv = "Sv",
Unknown = "Unknown",
}
export const languageSelect = [ export const languageSelect = [
{ label: "Danish", value: "Da" }, { label: "Danish", value: ApiLang.Da },
{ label: "German", value: "De" }, { label: "German", value: ApiLang.De },
{ label: "English", value: "En" }, { label: "English", value: ApiLang.En },
{ label: "Finnish", value: "Fi" }, { label: "Finnish", value: ApiLang.Fi },
{ label: "Norwegian", value: "No" }, { label: "Norwegian", value: ApiLang.No },
{ label: "Swedish", value: "Sv" }, { label: "Swedish", value: ApiLang.Sv },
] ]

View File

@@ -9,6 +9,7 @@
"Amenities": "Faciliteter", "Amenities": "Faciliteter",
"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 adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.",
"Are you sure you want to remove the card ending with": "Er du sikker på, at du vil fjerne kortet, der slutter med", "Are you sure you want to remove the card ending with": "Er du sikker på, at du vil fjerne kortet, der slutter med",
"An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.",
"Arrival date": "Ankomstdato", "Arrival date": "Ankomstdato",
"as of today": "fra idag", "as of today": "fra idag",
"As our": "Som vores", "As our": "Som vores",
@@ -133,6 +134,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Noget gik galt, og vi kunne ikke tilføje dit kort. Prøv venligst igen senere.", "Something went wrong and we couldn't add your card. Please try again later.": "Noget gik galt, og vi kunne ikke tilføje dit kort. Prøv venligst igen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noget gik galt, og vi kunne ikke fjerne dit kort. Prøv venligst igen senere.", "Something went wrong and we couldn't remove your card. Please try again later.": "Noget gik galt, og vi kunne ikke fjerne dit kort. Prøv venligst igen senere.",
"Street": "Gade", "Street": "Gade",
"Successfully updated profile!": "Profilen er opdateret med succes!",
"special character": "speciel karakter", "special character": "speciel karakter",
"Total Points": "Samlet antal point", "Total Points": "Samlet antal point",
"Your points to spend": "Dine brugbare pointer", "Your points to spend": "Dine brugbare pointer",
@@ -185,4 +187,4 @@
"Use bonus cheque": "Brug bonuscheck", "Use bonus cheque": "Brug bonuscheck",
"Book reward night": "Book belønningsaften", "Book reward night": "Book belønningsaften",
"Find hotels": "Find hoteller" "Find hotels": "Find hoteller"
} }

View File

@@ -8,6 +8,7 @@
"Amenities": "Annehmlichkeiten", "Amenities": "Annehmlichkeiten",
"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 adding a credit card, please try again later.": "Beim Hinzufügen einer Kreditkarte ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"Are you sure you want to remove the card ending with": "Möchten Sie die Karte mit der Endung", "Are you sure you want to remove the card ending with": "Möchten Sie die Karte mit der Endung",
"An error occurred when trying to update profile.": "Beim Versuch, das Profil zu aktualisieren, ist ein Fehler aufgetreten.",
"Arrival date": "Ankunftsdatum", "Arrival date": "Ankunftsdatum",
"as of today": "Stand heute", "as of today": "Stand heute",
"As our": "Als unser", "As our": "Als unser",
@@ -128,6 +129,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht hinzufügen. Bitte versuchen Sie es später erneut.", "Something went wrong and we couldn't add your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht hinzufügen. Bitte versuchen Sie es später erneut.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht entfernen. Bitte versuchen Sie es später noch einmal.", "Something went wrong and we couldn't remove your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht entfernen. Bitte versuchen Sie es später noch einmal.",
"Street": "Straße", "Street": "Straße",
"Successfully updated profile!": "Profil erfolgreich aktualisiert!",
"special character": "sonderzeichen", "special character": "sonderzeichen",
"Total Points": "Gesamtpunktzahl", "Total Points": "Gesamtpunktzahl",
"Your points to spend": "Meine Punkte", "Your points to spend": "Meine Punkte",
@@ -179,4 +181,4 @@
"Use bonus cheque": "Bonusscheck nutzen", "Use bonus cheque": "Bonusscheck nutzen",
"Book reward night": "Bonusnacht buchen", "Book reward night": "Bonusnacht buchen",
"Find hotels": "Hotels finden" "Find hotels": "Hotels finden"
} }

View File

@@ -9,6 +9,7 @@
"Amenities": "Amenities", "Amenities": "Amenities",
"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 adding a credit card, please try again later.": "An error occurred when adding a credit card, please try again later.",
"Are you sure you want to remove the card ending with": "Are you sure you want to remove the card ending with", "Are you sure you want to remove the card ending with": "Are you sure you want to remove the card ending with",
"An error occurred when trying to update profile.": "An error occurred when trying to update profile.",
"Arrival date": "Arrival date", "Arrival date": "Arrival date",
"as of today": "as of today", "as of today": "as of today",
"As our": "As our", "As our": "As our",
@@ -139,6 +140,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Something went wrong and we couldn't add your card. Please try again later.", "Something went wrong and we couldn't add your card. Please try again later.": "Something went wrong and we couldn't add your card. Please try again later.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Something went wrong and we couldn't remove your card. Please try again later.", "Something went wrong and we couldn't remove your card. Please try again later.": "Something went wrong and we couldn't remove your card. Please try again later.",
"Street": "Street", "Street": "Street",
"Successfully updated profile!": "Successfully updated profile!",
"special character": "special character", "special character": "special character",
"Total Points": "Total Points", "Total Points": "Total Points",
"Your points to spend": "Your points to spend", "Your points to spend": "Your points to spend",
@@ -150,9 +152,9 @@
"TUI Points": "TUI Points", "TUI Points": "TUI Points",
"User information": "User information", "User information": "User information",
"uppercase letter": "uppercase letter", "uppercase letter": "uppercase letter",
"Welcome": "Welcome",
"Visiting address": "Visiting address", "Visiting address": "Visiting address",
"We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.", "We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.",
"Welcome": "Welcome",
"Welcome to": "Welcome to", "Welcome to": "Welcome to",
"Wellness & Exercise": "Wellness & Exercise", "Wellness & Exercise": "Wellness & Exercise",
"Where should you go next?": "Where should you go next?", "Where should you go next?": "Where should you go next?",
@@ -191,4 +193,4 @@
"Use bonus cheque": "Use bonus cheque", "Use bonus cheque": "Use bonus cheque",
"Book reward night": "Book reward night", "Book reward night": "Book reward night",
"Find hotels": "Find hotels" "Find hotels": "Find hotels"
} }

View File

@@ -9,6 +9,7 @@
"Amenities": "Mukavuudet", "Amenities": "Mukavuudet",
"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 adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.",
"Are you sure you want to remove the card ending with": "Haluatko varmasti poistaa kortin, joka päättyy numeroon", "Are you sure you want to remove the card ending with": "Haluatko varmasti poistaa kortin, joka päättyy numeroon",
"An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.",
"Arrival date": "Saapumispäivä", "Arrival date": "Saapumispäivä",
"as of today": "tästä päivästä lähtien", "as of today": "tästä päivästä lähtien",
"As our": "Kuin meidän", "As our": "Kuin meidän",
@@ -133,6 +134,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Jotain meni pieleen, emmekä voineet lisätä korttiasi. Yritä myöhemmin uudelleen.", "Something went wrong and we couldn't add your card. Please try again later.": "Jotain meni pieleen, emmekä voineet lisätä korttiasi. Yritä myöhemmin uudelleen.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Jotain meni pieleen, emmekä voineet poistaa korttiasi. Yritä myöhemmin uudelleen.", "Something went wrong and we couldn't remove your card. Please try again later.": "Jotain meni pieleen, emmekä voineet poistaa korttiasi. Yritä myöhemmin uudelleen.",
"Street": "Katu", "Street": "Katu",
"Successfully updated profile!": "Profiilin päivitys onnistui!",
"special character": "erikoishahmo", "special character": "erikoishahmo",
"Total Points": "Kokonaispisteet", "Total Points": "Kokonaispisteet",
"Your points to spend": "Sinun pisteesi käytettäväksi", "Your points to spend": "Sinun pisteesi käytettäväksi",
@@ -185,4 +187,4 @@
"Use bonus cheque": "Käytä bonussekkiä", "Use bonus cheque": "Käytä bonussekkiä",
"Book reward night": "Kirjapalkinto-ilta", "Book reward night": "Kirjapalkinto-ilta",
"Find hotels": "Etsi hotelleja" "Find hotels": "Etsi hotelleja"
} }

View File

@@ -9,6 +9,7 @@
"Amenities": "Fasiliteter", "Amenities": "Fasiliteter",
"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 adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.",
"Are you sure you want to remove the card ending with": "Er du sikker på at du vil fjerne kortet som slutter på", "Are you sure you want to remove the card ending with": "Er du sikker på at du vil fjerne kortet som slutter på",
"An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.",
"Arrival date": "Ankomstdato", "Arrival date": "Ankomstdato",
"as of today": "per idag", "as of today": "per idag",
"As our": "Som vår", "As our": "Som vår",
@@ -133,6 +134,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Noe gikk galt, og vi kunne ikke legge til kortet ditt. Prøv igjen senere.", "Something went wrong and we couldn't add your card. Please try again later.": "Noe gikk galt, og vi kunne ikke legge til kortet ditt. Prøv igjen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noe gikk galt, og vi kunne ikke fjerne kortet ditt. Vennligst prøv igjen senere.", "Something went wrong and we couldn't remove your card. Please try again later.": "Noe gikk galt, og vi kunne ikke fjerne kortet ditt. Vennligst prøv igjen senere.",
"Street": "Gate", "Street": "Gate",
"Successfully updated profile!": "Vellykket oppdatert profil!",
"special character": "spesiell karakter", "special character": "spesiell karakter",
"Total Points": "Totale poeng", "Total Points": "Totale poeng",
"Your points to spend": "Dine brukbare poeng", "Your points to spend": "Dine brukbare poeng",
@@ -185,4 +187,4 @@
"Use bonus cheque": "Bruk bonussjekk", "Use bonus cheque": "Bruk bonussjekk",
"Book reward night": "Bestill belønningskveld", "Book reward night": "Bestill belønningskveld",
"Find hotels": "Finn hotell" "Find hotels": "Finn hotell"
} }

View File

@@ -9,6 +9,7 @@
"Amenities": "Bekvämligheter", "Amenities": "Bekvämligheter",
"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 adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.",
"Are you sure you want to remove the card ending with": "Är du säker på att du vill ta bort kortet som slutar med", "Are you sure you want to remove the card ending with": "Är du säker på att du vill ta bort kortet som slutar med",
"An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.",
"Arrival date": "Ankomstdatum", "Arrival date": "Ankomstdatum",
"as of today": "från och med idag", "as of today": "från och med idag",
"As our": "Som vår", "As our": "Som vår",
@@ -136,6 +137,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Något gick fel och vi kunde inte lägga till ditt kort. Försök igen senare.", "Something went wrong and we couldn't add your card. Please try again later.": "Något gick fel och vi kunde inte lägga till ditt kort. Försök igen senare.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.", "Something went wrong and we couldn't remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.",
"Street": "Gata", "Street": "Gata",
"Successfully updated profile!": "Profilen har uppdaterats framgångsrikt!",
"special character": "speciell karaktär", "special character": "speciell karaktär",
"Total Points": "Poäng totalt", "Total Points": "Poäng totalt",
"Your points to spend": "Dina spenderbara poäng", "Your points to spend": "Dina spenderbara poäng",
@@ -187,4 +189,4 @@
"Use bonus cheque": "Use bonus cheque", "Use bonus cheque": "Use bonus cheque",
"Book reward night": "Book reward night", "Book reward night": "Book reward night",
"Find hotels": "Hitta hotell" "Find hotels": "Hitta hotell"
} }

View File

@@ -14,6 +14,7 @@ export { endpoints } from "./endpoints"
const defaultOptions: RequestInit = { const defaultOptions: RequestInit = {
cache: "no-store", cache: "no-store",
headers: { headers: {
Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
mode: "cors", mode: "cors",
@@ -25,25 +26,40 @@ const fetch = fetchRetry(global.fetch, {
return Math.pow(2, attempt) * 150 // 150, 300, 600 return Math.pow(2, attempt) * 150 // 150, 300, 600
}, },
}) })
const url = new URL(env.API_BASEURL)
export async function get( export async function get(
endpoint: Endpoint | `${Endpoint}/${string}`, endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithOutBody, options: RequestOptionsWithOutBody,
params?: URLSearchParams params = {}
) { ) {
const url = new URL( url.pathname = endpoint
`${env.API_BASEURL}/${endpoint}${params ? `?${params.toString()}` : ""}` const searchParams = new URLSearchParams(params)
) if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
return fetch(url, merge.all([defaultOptions, { method: "GET" }, options])) return fetch(url, merge.all([defaultOptions, { method: "GET" }, options]))
} }
export async function patch( export async function patch(
endpoint: Endpoint | `${Endpoint}/${string}`, endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithJSONBody options: RequestOptionsWithJSONBody,
params = {}
) { ) {
const { body, ...requestOptions } = options const { body, ...requestOptions } = options
url.pathname = endpoint
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
return fetch( return fetch(
`${env.API_BASEURL}/${endpoint}`, url,
merge.all([ merge.all([
defaultOptions, defaultOptions,
{ body: JSON.stringify(body), method: "PATCH" }, { body: JSON.stringify(body), method: "PATCH" },
@@ -55,11 +71,19 @@ export async function patch(
export async function post( export async function post(
endpoint: Endpoint | `${Endpoint}/${string}`, endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithJSONBody, options: RequestOptionsWithJSONBody,
params?: URLSearchParams params = {},
) { ) {
const { body, ...requestOptions } = options const { body, ...requestOptions } = options
url.pathname = endpoint
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
return fetch( return fetch(
`${env.API_BASEURL}/${endpoint}${params ? `?${params.toString()}` : ""}`, url,
merge.all([ merge.all([
defaultOptions, defaultOptions,
{ body: JSON.stringify(body), method: "POST" }, { body: JSON.stringify(body), method: "POST" },
@@ -71,10 +95,15 @@ export async function post(
export async function remove( export async function remove(
endpoint: Endpoint | `${Endpoint}/${string}`, endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithOutBody, options: RequestOptionsWithOutBody,
params?: URLSearchParams params = {},
) { ) {
return fetch( url.pathname = endpoint
`${env.API_BASEURL}/${endpoint}${params ? `?${params.toString()}` : ""}`, const searchParams = new URLSearchParams(params)
merge.all([defaultOptions, { method: "DELETE" }, options]) if (searchParams.size) {
) searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
return fetch(url, merge.all([defaultOptions, { method: "DELETE" }, options]))
} }

View File

@@ -4,6 +4,7 @@ import "dayjs/locale/fi"
import "dayjs/locale/sv" import "dayjs/locale/sv"
import d from "dayjs" import d from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import isToday from "dayjs/plugin/isToday" import isToday from "dayjs/plugin/isToday"
import relativeTime from "dayjs/plugin/relativeTime" import relativeTime from "dayjs/plugin/relativeTime"
import utc from "dayjs/plugin/utc" import utc from "dayjs/plugin/utc"
@@ -55,6 +56,7 @@ d.locale("no", {
/** /**
* If more plugins are needed https://day.js.org/docs/en/plugin/plugin * If more plugins are needed https://day.js.org/docs/en/plugin/plugin
*/ */
d.extend(advancedFormat)
d.extend(isToday) d.extend(isToday)
d.extend(relativeTime) d.extend(relativeTime)
d.extend(utc) d.extend(utc)

108
package-lock.json generated
View File

@@ -19,9 +19,9 @@
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8", "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
"@t3-oss/env-nextjs": "^0.9.2", "@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
"@trpc/client": "^11.0.0-next-beta.318", "@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-next-beta.318", "@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-next-beta.318", "@trpc/server": "^11.0.0-rc.467",
"@vercel/otel": "^1.9.1", "@vercel/otel": "^1.9.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",
@@ -33,7 +33,7 @@
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"libphonenumber-js": "^1.10.60", "libphonenumber-js": "^1.10.60",
"next": "^14.2.3", "next": "^14.2.3",
"next-auth": "^5.0.0-beta.15", "next-auth": "^5.0.0-beta.19",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-feather": "^2.0.10", "react-feather": "^2.0.10",
@@ -106,15 +106,16 @@
} }
}, },
"node_modules/@auth/core": { "node_modules/@auth/core": {
"version": "0.28.1", "version": "0.32.0",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.32.0.tgz",
"integrity": "sha512-gvp74mypYZADpTlfGRp6HE0G3pIHWvtJpy+KZ+8FvY0cmlIpHog+jdMOdd29dQtLtN25kF2YbfHsesCFuGUQbg==", "integrity": "sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==",
"license": "ISC",
"dependencies": { "dependencies": {
"@panva/hkdf": "^1.1.1", "@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0", "@types/cookie": "0.6.0",
"cookie": "0.6.0", "cookie": "0.6.0",
"jose": "^5.1.3", "jose": "^5.1.3",
"oauth4webapi": "^2.4.0", "oauth4webapi": "^2.9.0",
"preact": "10.11.3", "preact": "10.11.3",
"preact-render-to-string": "5.2.3" "preact-render-to-string": "5.2.3"
}, },
@@ -3676,9 +3677,10 @@
} }
}, },
"node_modules/@panva/hkdf": { "node_modules/@panva/hkdf": {
"version": "1.1.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==", "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
@@ -5641,20 +5643,22 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.29.0", "version": "5.51.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.29.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.9.tgz",
"integrity": "sha512-WgPTRs58hm9CMzEr5jpISe8HXa3qKQ8CxewdYZeVnA54JrPY9B1CZiwsCoLpLkf0dGRZq+LcX5OiJb0bEsOFww==", "integrity": "sha512-HsAwaY5J19MD18ykZDS3aVVh+bAt0i7m6uQlFC2b77DLV9djo+xEN7MWQAQQTR8IM+7r/zbozTQ7P0xr0bHuew==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.29.2", "version": "5.51.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.29.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.11.tgz",
"integrity": "sha512-nyuWILR4u7H5moLGSiifLh8kIqQDLNOHGuSz0rcp+J75fNc8aQLyr5+I2JCHU3n+nJrTTW1ssgAD8HiKD7IFBQ==", "integrity": "sha512-4Kq2x0XpDlpvSnaLG+8pHNH60zEc3mBvb3B2tOMDjcPCi/o+Du3p/9qpPLwJOTliVxxPJAP27fuIhLrsRdCr7A==",
"license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.29.0" "@tanstack/query-core": "5.51.9"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -5838,38 +5842,41 @@
} }
}, },
"node_modules/@trpc/client": { "node_modules/@trpc/client": {
"version": "11.0.0-rc.334", "version": "11.0.0-rc.467",
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.0.0-rc.334.tgz", "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.0.0-rc.467.tgz",
"integrity": "sha512-nVKOxCfhKxBXvs1MwjfNNsWHQH/rHWzS4SxYPtYld7zqANTfNqn8cuClHMKlkeMKljObFiCfWhbdrBamKO/dDw==", "integrity": "sha512-ovZaGdAUl+EEmtJJc5uuo95B0gw8+q3jwNjUQQmmSMU5Isq4sYdjIWNkhbrFtR8CovllFyrRrjAgCWdaOTEY4g==",
"funding": [ "funding": [
"https://trpc.io/sponsor" "https://trpc.io/sponsor"
], ],
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@trpc/server": "11.0.0-rc.334+fdf26e552" "@trpc/server": "11.0.0-rc.467+8f72171d6"
} }
}, },
"node_modules/@trpc/react-query": { "node_modules/@trpc/react-query": {
"version": "11.0.0-rc.334", "version": "11.0.0-rc.467",
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.0.0-rc.334.tgz", "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.0.0-rc.467.tgz",
"integrity": "sha512-DvhU7qMfQkLyrhQoDlrtMAGIgJyin9I8WCsF0oGlAD95ygoJcviD8D+lQzSCUttul9rPcNqWDuGhm5laS7V43A==", "integrity": "sha512-PNpHgISXJ60s0fJc6JUomKe3iu1wj6pZNFHJgQecAEK0gs1y6VM8Oh8CHgZg8+J/KDP/UtUmBcbpFP9l8Nq48w==",
"funding": [ "funding": [
"https://trpc.io/sponsor" "https://trpc.io/sponsor"
], ],
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@tanstack/react-query": "^5.25.0", "@tanstack/react-query": "^5.49.2",
"@trpc/client": "11.0.0-rc.334+fdf26e552", "@trpc/client": "11.0.0-rc.467+8f72171d6",
"@trpc/server": "11.0.0-rc.334+fdf26e552", "@trpc/server": "11.0.0-rc.467+8f72171d6",
"react": ">=18.2.0", "react": ">=18.2.0",
"react-dom": ">=18.2.0" "react-dom": ">=18.2.0"
} }
}, },
"node_modules/@trpc/server": { "node_modules/@trpc/server": {
"version": "11.0.0-rc.334", "version": "11.0.0-rc.467",
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.0.0-rc.334.tgz", "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.0.0-rc.467.tgz",
"integrity": "sha512-ckg+f4z3Lc0wYvm1Cx8Zjz8b2hguNlUFQeF8vLrtWYBL8HEolshmxOHMG9MLc6WDE6T1R7DZrG+xEwjI44XH9w==", "integrity": "sha512-94Gv26ALuBfxgFlSGV3x2uF2ixUEViuK0m3IPKOvCTMreisZkBqyTa3NkBcuPZW/AMUieM5P4Q2NrbHTIA0fKQ==",
"funding": [ "funding": [
"https://trpc.io/sponsor" "https://trpc.io/sponsor"
] ],
"license": "MIT"
}, },
"node_modules/@trysound/sax": { "node_modules/@trysound/sax": {
"version": "0.2.0", "version": "0.2.0",
@@ -5968,7 +5975,8 @@
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
}, },
"node_modules/@types/graceful-fs": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
@@ -7960,6 +7968,7 @@
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -12346,9 +12355,10 @@
} }
}, },
"node_modules/jose": { "node_modules/jose": {
"version": "5.2.4", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.2.4.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz",
"integrity": "sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==", "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
@@ -14366,18 +14376,19 @@
} }
}, },
"node_modules/next-auth": { "node_modules/next-auth": {
"version": "5.0.0-beta.16", "version": "5.0.0-beta.19",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.19.tgz",
"integrity": "sha512-dX2snB+ezN23tFzSes3n3uosT9iBf0eILPYWH/R2fd9n3ZzdMQlRzq7JIOPeS1aLc84IuRlyuyXyx9XmmZB6og==", "integrity": "sha512-YHu1igcAxZPh8ZB7GIM93dqgY6gcAzq66FOhQFheAdOx1raxNcApt05nNyNCSB6NegSiyJ4XOPsaNow4pfDmsg==",
"license": "ISC",
"dependencies": { "dependencies": {
"@auth/core": "0.28.1" "@auth/core": "0.32.0"
}, },
"peerDependencies": { "peerDependencies": {
"@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2", "@simplewebauthn/server": "^9.0.2",
"next": "^14", "next": "^14 || ^15.0.0-0",
"nodemailer": "^6.6.5", "nodemailer": "^6.6.5",
"react": "^18.2.0" "react": "^18.2.0 || ^19.0.0-0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@simplewebauthn/browser": { "@simplewebauthn/browser": {
@@ -14508,9 +14519,10 @@
"dev": true "dev": true
}, },
"node_modules/oauth4webapi": { "node_modules/oauth4webapi": {
"version": "2.10.4", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.4.tgz", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.11.1.tgz",
"integrity": "sha512-DSoj8QoChzOCQlJkRmYxAJCIpnXFW32R0Uq7avyghIeB6iJq0XAblOD7pcq3mx4WEBDwMuKr0Y1qveCBleG2Xw==", "integrity": "sha512-aNzOnL98bL6izG97zgnZs1PFEyO4WDVRhz2Pd066NPak44w5ESLRCYmJIyey8avSBPOMtBjhF3ZDDm7bIb7UOg==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
@@ -15235,6 +15247,7 @@
"version": "10.11.3", "version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/preact" "url": "https://opencollective.com/preact"
@@ -15244,6 +15257,7 @@
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"dependencies": { "dependencies": {
"pretty-format": "^3.8.0" "pretty-format": "^3.8.0"
}, },
@@ -15290,7 +15304,8 @@
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "3.8.0", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
}, },
"node_modules/process": { "node_modules/process": {
"version": "0.11.10", "version": "0.11.10",
@@ -16543,6 +16558,7 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz",
"integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==", "integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0"

View File

@@ -35,9 +35,9 @@
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8", "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
"@t3-oss/env-nextjs": "^0.9.2", "@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
"@trpc/client": "^11.0.0-next-beta.318", "@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-next-beta.318", "@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-next-beta.318", "@trpc/server": "^11.0.0-rc.467",
"@vercel/otel": "^1.9.1", "@vercel/otel": "^1.9.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",
@@ -49,7 +49,7 @@
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"libphonenumber-js": "^1.10.60", "libphonenumber-js": "^1.10.60",
"next": "^14.2.3", "next": "^14.2.3",
"next-auth": "^5.0.0-beta.15", "next-auth": "^5.0.0-beta.19",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-feather": "^2.0.10", "react-feather": "^2.0.10",

View File

@@ -5,8 +5,6 @@ import { Lang } from "@/constants/languages"
import { auth } from "@/auth" import { auth } from "@/auth"
import { unauthorizedError } from "./errors/trpc"
typeof auth typeof auth
type CreateContextOptions = { type CreateContextOptions = {

View File

@@ -49,3 +49,13 @@ export function sessionExpiredError() {
cause: new SessionExpiredError(SESSION_EXPIRED), cause: new SessionExpiredError(SESSION_EXPIRED),
}) })
} }
export const PUBLIC_UNAUTHORIZED = "PUBLIC_UNAUTHORIZED"
export class PublicUnauthorizedError extends Error {}
export function publicUnauthorizedError() {
return new TRPCError({
code: "UNAUTHORIZED",
message: PUBLIC_UNAUTHORIZED,
cause: new PublicUnauthorizedError(PUBLIC_UNAUTHORIZED),
})
}

View File

@@ -6,8 +6,8 @@ import { router } from "./trpc"
export const appRouter = router({ export const appRouter = router({
contentstack: contentstackRouter, contentstack: contentstackRouter,
user: userRouter,
hotel: hotelsRouter, hotel: hotelsRouter,
user: userRouter,
}) })
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter

View File

@@ -20,16 +20,16 @@ import tempRatesData from "./tempRatesData.json"
export const hotelQueryRouter = router({ export const hotelQueryRouter = router({
getHotel: serviceProcedure getHotel: serviceProcedure
.input(getHotelInputSchema) .input(getHotelInputSchema)
.query(async ({ input, ctx }) => { .query(async ({ ctx, input }) => {
const { hotelId, language, include } = input const { hotelId, language, include } = input
const params = new URLSearchParams()
const apiLang = toApiLang(language) const apiLang = toApiLang(language)
params.set("language", apiLang) const params: Record<string, string> = {
hotelId,
language: apiLang,
}
if (include) { if (include) {
params.set("include", include.join(",")) params.include = include.join(",")
} }
const apiResponse = await api.get( const apiResponse = await api.get(
@@ -61,16 +61,16 @@ export const hotelQueryRouter = router({
const roomCategories = included const roomCategories = included
? included ? included
.filter((item) => item.type === "roomcategories") .filter((item) => item.type === "roomcategories")
.map((roomCategory) => { .map((roomCategory) => {
const validatedRoom = roomSchema.safeParse(roomCategory) const validatedRoom = roomSchema.safeParse(roomCategory)
if (!validatedRoom.success) { if (!validatedRoom.success) {
console.error(`Get Room Category Data - Verified Data Error`) console.error(`Get Room Category Data - Verified Data Error`)
console.error(validatedRoom.error) console.error(validatedRoom.error)
throw badRequestError() throw badRequestError()
} }
return validatedRoom.data return validatedRoom.data
}) })
: [] : []
return { return {

View File

@@ -1,7 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { Lang } from "@/constants/languages" // Query
export const getUserInputSchema = z export const getUserInputSchema = z
.object({ .object({
mask: z.boolean().default(true), mask: z.boolean().default(true),
@@ -10,17 +9,27 @@ export const getUserInputSchema = z
export const staysInput = z export const staysInput = z
.object({ .object({
cursor: z.number().optional(), cursor: z
limit: z.number().min(0).default(6), .number()
.optional()
.transform((num) => (num ? String(num) : undefined)),
limit: z
.number()
.min(0)
.default(6)
.transform((num) => String(num)),
}) })
.default({}) .default({})
export const soonestUpcomingStaysInput = z export const friendTransactionsInput = z
.object({ .object({
limit: z.number().int().positive(), limit: z.number().int().positive(),
page: z.number().int().positive(),
}) })
.default({ limit: 3 }) .default({ limit: 5, page: 1 })
// Mutation
export const addCreditCardInput = z.object({ export const addCreditCardInput = z.object({
language: z.string(), language: z.string(),
}) })
@@ -33,9 +42,3 @@ export const saveCreditCardInput = z.object({
transactionId: z.string(), transactionId: z.string(),
merchantId: z.string().optional(), merchantId: z.string().optional(),
}) })
export const friendTransactionsInput = z
.object({
limit: z.number().int().positive(),
page: z.number().int().positive(),
})
.default({ limit: 5, page: 1 })

View File

@@ -79,6 +79,7 @@ async function getVerifiedUser({ session }: { session: Session }) {
console.error(verifiedData.error) console.error(verifiedData.error)
return null return null
} }
return verifiedData return verifiedData
} }
@@ -335,12 +336,9 @@ export const userQueryRouter = router({
.input(staysInput) .input(staysInput)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { limit, cursor } = input const { limit, cursor } = input
const params: Record<string, string> = { limit }
const params = new URLSearchParams()
params.set("limit", limit.toString())
if (cursor) { if (cursor) {
params.set("offset", cursor.toString()) params.offset = cursor
} }
const apiResponse = await api.get( const apiResponse = await api.get(
@@ -403,11 +401,9 @@ export const userQueryRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { limit, cursor } = input const { limit, cursor } = input
const params = new URLSearchParams() const params: Record<string, string> = { limit }
params.set("limit", limit.toString())
if (cursor) { if (cursor) {
params.set("offset", cursor.toString()) params.offset = cursor
} }
const apiResponse = await api.get( const apiResponse = await api.get(

View File

@@ -1,4 +1,6 @@
import { initTRPC } from "@trpc/server" import { initTRPC } from "@trpc/server"
import { experimental_nextAppDirCaller } from "@trpc/server/adapters/next-app-dir"
import { ZodError } from "zod"
import { env } from "@/env/server" import { env } from "@/env/server"
@@ -9,15 +11,30 @@ import {
unauthorizedError, unauthorizedError,
} from "./errors/trpc" } from "./errors/trpc"
import { fetchServiceToken } from "./tokenManager" import { fetchServiceToken } from "./tokenManager"
import { type Context, createContext } from "./context"
import { transformer } from "./transformer" import { transformer } from "./transformer"
import { langInput } from "./utils" import { langInput } from "./utils"
import type { Session } from "next-auth" import type { Session } from "next-auth"
import type { Meta } from "@/types/trpc/meta" import type { Meta } from "@/types/trpc/meta"
import type { Context } from "./context"
const t = initTRPC.context<Context>().meta<Meta>().create({ transformer }) const t = initTRPC
.context<Context>()
.meta<Meta>()
.create({
transformer,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const { createCallerFactory, mergeRouters, router } = t export const { createCallerFactory, mergeRouters, router } = t
export const publicProcedure = t.procedure export const publicProcedure = t.procedure
@@ -113,3 +130,30 @@ export const serviceProcedure = t.procedure.use(async (opts) => {
}, },
}) })
}) })
export const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
createContext,
normalizeFormData: true,
})
)
export const protectedServerActionProcedure = serverActionProcedure.use(
async (opts) => {
const session = await opts.ctx.auth()
if (!session) {
throw unauthorizedError()
}
if (session && session.error === "RefreshAccessTokenError") {
throw sessionExpiredError()
}
return opts.next({
ctx: {
...opts.ctx,
session,
},
})
}
)

View File

@@ -4,25 +4,31 @@ export type EditFormProps = {
user: User user: User
} }
type E = {
message: string
path: string
}
export const enum Status { export const enum Status {
error = "error", error = "error",
success = "success", success = "success",
} }
type ErrorState = { type Data = Record<
errors?: E[] string,
string | undefined | Record<string, string | undefined>
>
type Issue = {
field: string
message: string message: string
status: Status.error
} }
type SuccessState = { export type State = {
data: Data
message: string message: string
status: Status.success } & (
} | {
issues: never
export type State = ErrorState | SuccessState | null status: Status.success
}
| {
issues: Issue[]
status: Status.error
}
)

4
types/jwt.d.ts vendored
View File

@@ -11,9 +11,9 @@ declare module "next-auth/jwt" {
interface JWT extends DefaultJWT, RefreshTokenError { interface JWT extends DefaultJWT, RefreshTokenError {
access_token: string access_token: string
expires_at?: number expires_at?: number
refresh_token: string
loginType: LoginType loginType: LoginType
mfa_scope: boolean
mfa_expires_at: number mfa_expires_at: number
mfa_scope: boolean
refresh_token: string
} }
} }