feat: loosen up the zod validations and return null instead of throwing

This commit is contained in:
Simon Emanuelsson
2024-06-07 10:36:23 +02:00
parent 5c50ac060d
commit aca9221ea6
89 changed files with 1117 additions and 821 deletions

View File

@@ -12,6 +12,11 @@ export default async function MyPages({
}: PageArgs<LangParams & { path: string[] }>) { }: PageArgs<LangParams & { path: string[] }>) {
const accountPage = await serverClient().contentstack.accountPage.get() const accountPage = await serverClient().contentstack.accountPage.get()
const { formatMessage } = await getIntl() const { formatMessage } = await getIntl()
if (!accountPage) {
return null
}
return ( return (
<main className={styles.blocks}> <main className={styles.blocks}>
{accountPage.content.length ? ( {accountPage.content.length ? (

View File

@@ -4,6 +4,8 @@ import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
export default async function LanguageSwitcherRoute() { export default async function LanguageSwitcherRoute() {
const data = await serverClient().contentstack.languageSwitcher.get() const data = await serverClient().contentstack.languageSwitcher.get()
if (!data) {
return null
}
return <LanguageSwitcher urls={data.urls} lang={data.lang} /> return <LanguageSwitcher urls={data.urls} lang={data.lang} />
} }

View File

@@ -1,5 +1,3 @@
import { ReactNode } from "react"
import Header from "@/components/Current/Header" import Header from "@/components/Current/Header"
import { LangParams, PageArgs } from "@/types/params" import { LangParams, PageArgs } from "@/types/params"
@@ -7,15 +5,8 @@ import { LangParams, PageArgs } from "@/types/params"
export default function HeaderLayout({ export default function HeaderLayout({
params, params,
languageSwitcher, languageSwitcher,
children,
}: PageArgs<LangParams> & { }: PageArgs<LangParams> & {
languageSwitcher: ReactNode languageSwitcher: React.ReactNode
children: ReactNode
}) { }) {
return ( return <Header lang={params.lang} languageSwitcher={languageSwitcher} />
<>
<Header lang={params.lang} languageSwitcher={languageSwitcher} />
{children}
</>
)
} }

View File

@@ -4,6 +4,8 @@ import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
export default async function LanguageSwitcherRoute() { export default async function LanguageSwitcherRoute() {
const data = await serverClient().contentstack.languageSwitcher.get() const data = await serverClient().contentstack.languageSwitcher.get()
if (!data) {
return null
}
return <LanguageSwitcher urls={data.urls} lang={data.lang} /> return <LanguageSwitcher urls={data.urls} lang={data.lang} />
} }

View File

@@ -10,6 +10,9 @@ import type { LangParams } from "@/types/params"
export default async function LoyaltyPage({ lang }: LangParams) { export default async function LoyaltyPage({ lang }: LangParams) {
const loyaltyPage = await serverClient().contentstack.loyaltyPage.get() const loyaltyPage = await serverClient().contentstack.loyaltyPage.get()
if (!loyaltyPage) {
return null
}
return ( return (
<section className={styles.content}> <section className={styles.content}>
{loyaltyPage.sidebar.length ? ( {loyaltyPage.sidebar.length ? (

View File

@@ -14,6 +14,9 @@ import { LangParams } from "@/types/params"
export default async function MyPages({ lang }: LangParams) { export default async function MyPages({ lang }: LangParams) {
const accountPage = await serverClient().contentstack.accountPage.get() const accountPage = await serverClient().contentstack.accountPage.get()
if (!accountPage) {
return null
}
const linkToOverview = `/${lang}/webview${accountPage.url}` !== overview[lang] const linkToOverview = `/${lang}/webview${accountPage.url}` !== overview[lang]

View File

@@ -11,6 +11,9 @@ import { LangParams } from "@/types/params"
export default async function AboutScandicFriends({ lang }: LangParams) { export default async function AboutScandicFriends({ lang }: LangParams) {
const loyaltyPage = await serverClient().contentstack.loyaltyPage.get() const loyaltyPage = await serverClient().contentstack.loyaltyPage.get()
if (!loyaltyPage) {
return null
}
return ( return (
<section className={styles.content}> <section className={styles.content}>
<LinkToOverview lang={lang} /> <LinkToOverview lang={lang} />

View File

@@ -10,6 +10,9 @@ import { LangParams } from "@/types/params"
export default async function Footer({ lang }: LangParams) { export default async function Footer({ lang }: LangParams) {
const footerData = await serverClient().contentstack.base.footer({ lang }) const footerData = await serverClient().contentstack.base.footer({ lang })
if (!footerData) {
return null
}
return ( return (
<footer className={styles.container}> <footer className={styles.container}>
<div className={styles.content}> <div className={styles.content}>

View File

@@ -21,6 +21,10 @@ export default async function Header({
}) })
const session = await auth() const session = await auth()
if (!data) {
return null
}
const homeHref = homeHrefs[env.NODE_ENV][lang] const homeHref = homeHrefs[env.NODE_ENV][lang]
const { frontpage_link_text, logo, menu, top_menu } = data const { frontpage_link_text, logo, menu, top_menu } = data

View File

@@ -19,6 +19,9 @@ async function DynamicComponentBlock({ component }: DynamicComponentProps) {
const session = await auth() const session = await auth()
const user = session ? await serverClient().user.get() : null const user = session ? await serverClient().user.get() : null
if (!user) {
return null
}
switch (component) { switch (component) {
case LoyaltyComponentEnum.how_it_works: case LoyaltyComponentEnum.how_it_works:

View File

@@ -10,6 +10,9 @@ import type { ContactRowProps } from "@/types/components/loyalty/sidebar"
export default async function ContactRow({ contact }: ContactRowProps) { export default async function ContactRow({ contact }: ContactRowProps) {
const data = await serverClient().contentstack.base.contact() const data = await serverClient().contentstack.base.contact()
if (!data) {
return null
}
const val = getValueFromContactConfig(contact.contact_field, data) const val = getValueFromContactConfig(contact.contact_field, data)

View File

@@ -30,15 +30,19 @@ export default async function JoinLoyaltyContact({
{block.preamble ? ( {block.preamble ? (
<Body textAlign="center">{block.preamble}</Body> <Body textAlign="center">{block.preamble}</Body>
) : null} ) : null}
<Button asChild className={styles.link} intent="primary"> <Button asChild intent="primary">
<Link href="#">{formatMessage({ id: "Join Scandic Friends" })}</Link> <Body asChild fontOnly textAlign="center" textTransform="bold">
<Link href={login[lang]}>
{formatMessage({ id: "Join Scandic Friends" })}
</Link>
</Body>
</Button> </Button>
<Link href={login[lang]}> <Footnote asChild fontOnly textAlign="center" textTransform="bold">
<Footnote textAlign="center" textTransform="bold"> <Link color="burgundy" href={`/${lang}/login`}>
{formatMessage({ id: "Already a friend?" })} <br /> {formatMessage({ id: "Already a friend?" })} <br />
{formatMessage({ id: "Click here to log in" })} {formatMessage({ id: "Click here to log in" })}
</Footnote> </Link>
</Link> </Footnote>
</article> </article>
{block.contact ? <Contact contactBlock={block.contact} /> : null} {block.contact ? <Contact contactBlock={block.contact} /> : null}
</section> </section>

View File

@@ -10,12 +10,3 @@
gap: var(--Spacing-x5); gap: var(--Spacing-x5);
padding: var(--Spacing-x4) var(--Spacing-x2) var(--Spacing-x5); padding: var(--Spacing-x4) var(--Spacing-x2) var(--Spacing-x5);
} }
/* TODO: Remove when we get proper button variables */
.link {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
}

View File

@@ -32,8 +32,8 @@ export default function SidebarLoyalty({
return ( return (
<JoinLoyaltyContact <JoinLoyaltyContact
block={block.join_loyalty_contact} block={block.join_loyalty_contact}
key={`${block.__typename}-${idx}`}
lang={lang} lang={lang}
key={`${block.join_loyalty_contact.title}-${idx}`}
/> />
) )
default: default:

View File

@@ -17,29 +17,6 @@
grid-area: header; grid-area: header;
} }
.subtitle {
color: var(--some-black-color, #000);
font-family: var(--typography-Script-1-fontFamily);
font-size: 1.6rem;
font-weight: 400;
line-height: 1.8rem;
margin: 0;
}
.title {
color: var(--some-black-color, #000);
font-family: var(--typography-Title-1-fontFamily);
font-size: 1.6rem;
font-weight: 900;
inline-size: 18rem;
line-height: 1.8rem;
margin: 0;
overflow-wrap: break-word;
padding: 0;
text-align: center;
text-transform: uppercase;
}
.section { .section {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
@@ -121,17 +98,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.journey .subtitle {
font-size: 2.6rem;
line-height: 3.2rem;
}
.journey .title {
font-size: 2.6rem;
inline-size: 25rem;
line-height: 3.2rem;
}
.victories { .victories {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: var(--card-height) 1fr 1fr; grid-template-rows: var(--card-height) 1fr 1fr;
@@ -139,20 +105,11 @@
.circle { .circle {
align-items: center; align-items: center;
background-color: var(--some-white-color, #fff); background-color: var(--Main-Grey-White);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
height: 2rem; height: 2rem;
justify-content: center; justify-content: center;
width: 2rem; width: 2rem;
} }
.victory .subtitle {
font-size: 1.3rem;
line-height: 1.6rem;
}
.victory .title {
inline-size: 13rem;
}
} }

View File

@@ -1,4 +1,5 @@
import Image from "@/components/Image" import Image from "@/components/Image"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
@@ -25,8 +26,10 @@ export default async function Challenges({
<section className={styles.journeys}> <section className={styles.journeys}>
{journeys.map((journey) => ( {journeys.map((journey) => (
<article className={styles.journey} key={journey.title}> <article className={styles.journey} key={journey.title}>
<p className={styles.subtitle}>{journey.tag}</p> <BiroScript color="black">{journey.tag}</BiroScript>
<h4 className={styles.title}>{journey.title}</h4> <Title as="h5" level="h4">
{journey.title}
</Title>
</article> </article>
))} ))}
</section> </section>
@@ -48,8 +51,10 @@ export default async function Challenges({
width={12} width={12}
/> />
</div> </div>
<p className={styles.subtitle}>{victory.tag}</p> <BiroScript color="black">{victory.tag}</BiroScript>
<h4 className={styles.title}>{victory.title}</h4> <Title as="h5" level="h4">
{victory.title}
</Title>
</article> </article>
))} ))}
</section> </section>

View File

@@ -16,6 +16,9 @@ export default async function Overview({
title, title,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const user = await serverClient().user.get() const user = await serverClient().user.get()
if (!user) {
return null
}
return ( return (
<section className={styles.container}> <section className={styles.container}>
<Header link={link} subtitle={subtitle} title={title} topTitle /> <Header link={link} subtitle={subtitle} title={title} topTitle />

View File

@@ -16,16 +16,19 @@ async function CurrentPointsBalance({
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const user = await serverClient().user.get() const user = await serverClient().user.get()
const { formatMessage } = await getIntl() const { formatMessage } = await getIntl()
if (!user) {
return null
}
const membership = getMembership(user.memberships) const membership = getMembership(user.memberships)
return ( return (
<div> <div>
<Header title={title} link={link} subtitle={subtitle} /> <Header title={title} link={link} subtitle={subtitle} />
<div className={styles.card}> <div className={styles.card}>
<h2>{`${formatMessage({ id: "Total points" })}*`}</h2> <h2>{`${formatMessage({ id: "Total Points" })}*`}</h2>
<p <p className={styles.points}>
className={styles.points} {`${formatMessage({ id: "Points" })}: ${membership?.currentPoints || "N/A"}`}
>{`${formatMessage({ id: "Points" })}: ${membership?.currentPoints || "N/A"}`}</p> </p>
<p className={styles.disclaimer}> <p className={styles.disclaimer}>
{`*${formatMessage({ id: "Points may take up to 10 days to be displayed." })}`} {`*${formatMessage({ id: "Points may take up to 10 days to be displayed." })}`}
</p> </p>

View File

@@ -0,0 +1,45 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import DesktopTable from "./Desktop"
import MobileTable from "./Mobile"
import type {
ClientEarnAndBurnProps,
TransactionsObject,
} from "@/types/components/myPages/myPage/earnAndBurn"
export default function ClientEarnAndBurn({
initialData,
lang,
}: ClientEarnAndBurnProps) {
/**
* desctruct fetchNextPage, hasNextPage once pagination is
* possible through API
*/
const { data } = trpc.user.transaction.friendTransactions.useInfiniteQuery(
{ limit: 5 },
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
initialData: {
pageParams: [undefined, 1],
pages: [initialData],
},
}
)
// TS having a hard time with the filtered type.
// This is only temporary as we will not return null
// later on when we handle errors appropriately.
const filteredTransactions = (data?.pages.filter(
(page) => page && page.data
) ?? []) as unknown as TransactionsObject[]
const transactions = filteredTransactions.flatMap((page) => page.data)
return (
<>
<MobileTable lang={lang} transactions={transactions} />
<DesktopTable lang={lang} transactions={transactions} />
</>
)
}

View File

@@ -0,0 +1,35 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import styles from "./row.module.css"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function Row({ transaction, lang }: RowProps) {
const { formatMessage } = useIntl()
const description =
transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${transaction.nights} ${formatMessage({ id: "nights" })}`
: `${transaction.nights} ${formatMessage({ id: "nights" })}`
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
const departure = dt(transaction.checkoutDate)
.locale(lang)
.format("DD MMM YYYY")
const values = [
arrival,
description,
transaction.confirmationNumber,
departure,
transaction.awardPoints,
]
return (
<tr className={styles.tr}>
{values.map((value, idx) => (
<td key={`value-${idx}`} className={styles.td}>
{value}
</td>
))}
</tr>
)
}

View File

@@ -0,0 +1,8 @@
.tr {
border: 1px solid #e6e9ec;
}
.td {
text-align: left;
padding: 16px 32px;
}

View File

@@ -0,0 +1,36 @@
.container {
display: none;
}
.table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.thead {
background-color: var(--Main-Grey-10);
border-left: 1px solid var(--Main-Grey-10);
border-right: 1px solid var(--Main-Grey-10);
}
.th {
text-align: left;
padding: 20px 32px;
}
.placeholder {
width: 100%;
padding: 24px;
text-align: center;
border: 1px solid var(--Main-Grey-10);
}
@media screen and (min-width: 768px) {
.container {
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: auto;
}
}

View File

@@ -0,0 +1,74 @@
import { useIntl } from "react-intl"
import Row from "./Row"
import styles from "./desktop.module.css"
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
const tableHeadings = [
"Arrival date",
"Description",
"Booking number",
"Transaction date",
"Points",
]
export default function DesktopTable({ lang, transactions }: TableProps) {
const { formatMessage } = useIntl()
return (
<div className={styles.container}>
{transactions.length ? (
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
{formatMessage({ id: heading })}
</th>
))}
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<Row
lang={lang}
key={transaction.confirmationNumber}
transaction={transaction}
/>
))}
</tbody>
</table>
) : (
// TODO: add once pagination is available through API
// <Button
// disabled={isFetching}
// intent="primary"
// bgcolor="white"
// type="button"
// onClick={loadMoreData}
// >
// {formatMessage({id:"See more transactions"})}
// </Button>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
{heading}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={tableHeadings.length} className={styles.placeholder}>
{formatMessage({ id: "No transactions available" })}
</td>
</tr>
</tbody>
</table>
)}
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./mobile.module.css"
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function MobileTable({ lang, transactions }: TableProps) {
const { formatMessage } = useIntl()
return (
<div className={styles.container}>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
<Body asChild>
<th className={styles.th}>
{formatMessage({ id: "Transactions" })}
</th>
</Body>
<Body asChild>
<th className={styles.th}>{formatMessage({ id: "Points" })}</th>
</Body>
</tr>
</thead>
<tbody>
{transactions.length ? (
transactions.map((transaction) => (
<tr className={styles.tr} key={transaction.confirmationNumber}>
<td className={`${styles.td} ${styles.transactionDetails}`}>
<span className={styles.transactionDate}>
{dt(transaction.checkinDate)
.locale(lang)
.format("DD MMM YYYY")}
</span>
{transaction.hotelName && transaction.city ? (
<span>{`${transaction.hotelName}, ${transaction.city}`}</span>
) : null}
<span>
{`${transaction.nights} ${formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`}
</span>
</td>
<td
className={`${styles.mobileTd} ${styles.transactionPoints}`}
>
{`${transaction.awardPoints} P`}
</td>
</tr>
))
) : (
<tr>
<td className={styles.placeholder} colSpan={2}>
{formatMessage({ id: "Empty" })}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,46 @@
.table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.thead {
background-color: var(--Main-Grey-10);
}
.th {
padding: var(--Spacing-x2);
}
.tr {
border-top: 1px solid var(--Main-Grey-10);
}
.td {
padding: var(--Spacing-x2);
}
.transactionDetails {
display: grid;
font-size: var(--typography-Footnote-Regular-fontSize);
}
.transactionDate {
font-weight: 700;
}
.transactionPoints {
font-size: var(--typography-Body-Regular-fontSize);
}
.placeholder {
text-align: center;
padding: var(--Spacing-x4);
border: 1px solid var(--Main-Grey-10);
}
@media screen and (min-width: 768px) {
.container {
display: none;
}
}

View File

@@ -2,97 +2,3 @@
display: grid; display: grid;
gap: var(--Spacing-x3); gap: var(--Spacing-x3);
} }
.mobileTable {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.mobileThead {
background-color: var(--Main-Grey-10);
}
.mobileTh {
font-size: var(--typography-Body-Regular-fontSize);
font-weight: 500;
padding: var(--Spacing-x2);
text-align: left;
}
.mobileTr {
border-top: 1px solid var(--Main-Grey-10);
}
.mobileTd {
padding: var(--Spacing-x2);
}
.mobileTransactionDetails {
display: grid;
font-size: var(--typography-Footnote-Regular-fontSize);
}
.mobileTransactionDate {
font-weight: 700;
}
.mobileTransactionPoints {
font-size: var(--typography-Body-Regular-fontSize);
}
.mobilePlaceholder {
text-align: center;
padding: var(--Spacing-x4);
border: 1px solid var(--Main-Grey-10);
}
.tableContainer {
display: none;
}
.table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.thead {
background-color: var(--Main-Grey-10);
border-left: 1px solid var(--Main-Grey-10);
border-right: 1px solid var(--Main-Grey-10);
}
.tr {
border: 1px solid #e6e9ec;
}
.th {
text-align: left;
padding: 20px 32px;
}
.td {
text-align: left;
padding: 16px 32px;
}
.placeholder {
width: 100%;
padding: 24px;
text-align: center;
border: 1px solid var(--Main-Grey-10);
}
@media screen and (min-width: 768px) {
.mobileTableContainer {
display: none;
}
.tableContainer {
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: auto;
}
}

View File

@@ -1,187 +1,28 @@
"use client" import { serverClient } from "@/lib/trpc/server"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import Header from "@/components/SectionHeader" import Header from "@/components/SectionHeader"
import ClientEarnAndBurn from "./Client"
import styles from "./earnAndBurn.module.css" import styles from "./earnAndBurn.module.css"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage" import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
import { Page, RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
const tableHeadings = [ export default async function EarnAndBurn({
"Arrival date",
"Description",
"Booking number",
"Transaction date",
"Points",
]
function EarnAndBurn({
lang, lang,
title,
subtitle,
link, link,
subtitle,
title,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const intl = useIntl() const initialTransactions =
const { data, hasNextPage, fetchNextPage } = await serverClient().user.transaction.friendTransactions({ limit: 5 })
trpc.user.transaction.friendTransactions.useInfiniteQuery( if (!initialTransactions) {
{ limit: 5 }, return null
{
getNextPageParam: (lastPage: Page) => lastPage.nextCursor,
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
} }
const transactions = data?.pages.flatMap((page) => page.data) ?? []
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Header title={title} link={link} subtitle={subtitle} /> <Header title={title} link={link} subtitle={subtitle} />
<ClientEarnAndBurn initialData={initialTransactions} lang={lang} />
<div className={styles.mobileTableContainer}>
<table className={styles.mobileTable}>
<thead className={styles.mobileThead}>
<tr>
<th className={styles.mobileTh}>
{intl.formatMessage({ id: "Transactions" })}
</th>
<th className={styles.mobileTh}>
{intl.formatMessage({ id: "Points" })}
</th>
</tr>
</thead>
<tbody>
{transactions.length ? (
transactions.map((transaction) => (
<tr
className={styles.mobileTr}
key={transaction.confirmationNumber}
>
<td
className={`${styles.mobileTd} ${styles.mobileTransactionDetails}`}
>
<span className={styles.mobileTransactionDate}>
{dt(transaction.checkinDate)
.locale(lang)
.format("DD MMM YYYY")}
</span>
{transaction.hotelName && transaction.city ? (
<span>{`${transaction.hotelName}, ${transaction.city}`}</span>
) : null}
<span>
{`${transaction.nights} ${intl.formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`}
</span>
</td>
<td
className={`${styles.mobileTd} ${styles.mobileTransactionPoints}`}
>
{`${transaction.awardPoints} P`}
</td>
</tr>
))
) : (
<tr>
<td className={styles.mobilePlaceholder} colSpan={2}>
{intl.formatMessage({ id: "Empty" })}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.tableContainer}>
{transactions.length ? (
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
{intl.formatMessage({ id: heading })}
</th>
))}
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<Row
lang={lang}
key={transaction.confirmationNumber}
transaction={transaction}
/>
))}
</tbody>
</table>
) : (
// TODO: add once pagination is available through API
// <Button
// disabled={isFetching}
// intent="primary"
// bgcolor="white"
// type="button"
// onClick={loadMoreData}
// >
// {intl.formatMessage({id:"See more transactions"})}
// </Button>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
{heading}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td
colSpan={tableHeadings.length}
className={styles.placeholder}
>
{intl.formatMessage({ id: "No transactions available" })}
</td>
</tr>
</tbody>
</table>
)}
</div>
</div> </div>
) )
} }
function Row({ transaction, lang }: RowProps) {
const intl = useIntl()
const description =
transaction.hotelName && transaction.city
? `${intl.formatMessage({ id: transaction.hotelName })}, ${transaction.city} ${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
: `${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
const departure = dt(transaction.checkoutDate)
.locale(lang)
.format("DD MMM YYYY")
const values = [
arrival,
description,
transaction.confirmationNumber,
departure,
transaction.awardPoints,
]
return (
<tr className={styles.tr}>
{values.map((value, idx) => (
<td key={`value-${idx}`} className={styles.td}>
{value}
</td>
))}
</tr>
)
}
export default EarnAndBurn

View File

@@ -0,0 +1,67 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import EmptyPreviousStaysBlock from "./EmptyPreviousStays"
import type {
PreviousStaysClientProps,
PreviousStaysNonNullResponseObject,
} from "@/types/components/myPages/stays/previous"
export default function ClientPreviousStays({
initialPreviousStays,
lang,
}: PreviousStaysClientProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.user.stays.previous.useInfiniteQuery(
{},
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
initialData: {
pageParams: [undefined, 1],
pages: [initialPreviousStays],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
// TS having a hard time with the filtered type.
// This is only temporary as we will not return null
// later on when we handle errors appropriately.
const filteredStays = (data?.pages.filter((page) => page && page.data) ??
[]) as unknown as PreviousStaysNonNullResponseObject[]
const stays = filteredStays.flatMap((page) => page.data)
return isLoading ? (
<LoadingSpinner />
) : stays.length ? (
<ListContainer>
<Grids.Stackable>
{stays.map((stay) => (
<StayCard
key={stay.attributes.confirmationNumber}
lang={lang}
stay={stay}
/>
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
) : (
<EmptyPreviousStaysBlock />
)
}

View File

@@ -1,64 +1,29 @@
"use client" import { serverClient } from "@/lib/trpc/server"
import { trpc } from "@/lib/trpc/client" import SectionHeader from "@/components/SectionHeader"
import LoadingSpinner from "@/components/LoadingSpinner"
import Header from "@/components/SectionHeader"
import Grids from "@/components/TempDesignSystem/Grids"
import Container from "../Container" import Container from "../Container"
import ListContainer from "../ListContainer" import ClientPreviousStays from "./Client"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import EmptyPreviousStaysBlock from "./EmptyPreviousStays"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage" import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default function PreviousStays({ export default async function PreviousStays({
lang, lang,
title, title,
subtitle, subtitle,
link, link,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const initialPreviousStays = await serverClient().user.stays.previous()
trpc.user.stays.previous.useInfiniteQuery( if (!initialPreviousStays?.data) {
{}, return null
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
} }
const stays = data?.pages.flatMap((page) => page.data) ?? []
return ( return (
<Container> <Container>
<Header title={title} subtitle={subtitle} link={link} /> <SectionHeader title={title} subtitle={subtitle} link={link} />
{isLoading ? ( <ClientPreviousStays
<LoadingSpinner /> initialPreviousStays={initialPreviousStays}
) : stays.length ? ( lang={lang}
<ListContainer> />
<Grids.Stackable>
{stays.map((stay) => (
<StayCard
key={stay.attributes.confirmationNumber}
lang={lang}
stay={stay}
/>
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
) : (
<EmptyPreviousStaysBlock />
)}
</Container> </Container>
) )
} }

View File

@@ -16,14 +16,17 @@ export default async function SoonestStays({
subtitle, subtitle,
link, link,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const { data: stays } = await serverClient().user.stays.upcoming({ limit: 3 }) const response = await serverClient().user.stays.upcoming({ limit: 3 })
if (!response?.data) {
return null
}
return ( return (
<section className={styles.container}> <section className={styles.container}>
<Header title={title} subtitle={subtitle} link={link} /> <Header title={title} subtitle={subtitle} link={link} />
{stays.length ? ( {response.data.length ? (
<Grids.Stackable> <Grids.Stackable>
{stays.map((stay) => ( {response.data.map((stay) => (
<StayCard <StayCard
key={stay.attributes.confirmationNumber} key={stay.attributes.confirmationNumber}
lang={lang} lang={lang}

View File

@@ -1,8 +1,8 @@
import { Calendar } from "react-feather"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { CalendarIcon } from "@/components/Icons"
import Image from "@/components/Image" import Image from "@/components/Image"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./stay.module.css" import styles from "./stay.module.css"
@@ -33,14 +33,14 @@ export default function StayCard({ stay, lang }: StayCardProps) {
{hotelInformation.hotelName} {hotelInformation.hotelName}
</Title> </Title>
<div className={styles.date}> <div className={styles.date}>
<Calendar <CalendarIcon color="burgundy" />
height={20} <Caption asChild>
width={20} <time dateTime={arrivalDateTime}>{arrivalDate}</time>
color="var(--Scandic-Brand-Burgundy)" </Caption>
/>
<time dateTime={arrivalDateTime}>{arrivalDate}</time>
{" - "} {" - "}
<time dateTime={departDateTime}>{departDate}</time> <Caption asChild>
<time dateTime={departDateTime}>{departDate}</time>
</Caption>
</div> </div>
</footer> </footer>
</article> </article>

View File

@@ -36,8 +36,4 @@
align-items: center; align-items: center;
display: flex; display: flex;
gap: var(--Spacing-x-half); gap: var(--Spacing-x-half);
font-family: var(--typography-Caption-Regular-fontFamily);
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
line-height: var(--typography-Caption-Regular-lineHeight);
} }

View File

@@ -0,0 +1,67 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import type {
UpcomingStaysClientProps,
UpcomingStaysNonNullResponseObject,
} from "@/types/components/myPages/stays/upcoming"
export default function ClientUpcomingStays({
initialUpcomingStays,
lang,
}: UpcomingStaysClientProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.user.stays.upcoming.useInfiniteQuery(
{},
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
initialData: {
pageParams: [undefined, 1],
pages: [initialUpcomingStays],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
// TS having a hard time with the filtered type.
// This is only temporary as we will not return null
// later on when we handle errors appropriately.
const filteredStays = (data?.pages.filter((page) => page && page.data) ??
[]) as unknown as UpcomingStaysNonNullResponseObject[]
const stays = filteredStays.flatMap((page) => page.data)
return isLoading ? (
<LoadingSpinner />
) : stays.length ? (
<ListContainer>
<Grids.Stackable>
{stays.map((stay) => (
<StayCard
key={stay.attributes.confirmationNumber}
lang={lang}
stay={stay}
/>
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
) : (
<EmptyUpcomingStaysBlock />
)
}

View File

@@ -1,64 +1,29 @@
"use client" import { serverClient } from "@/lib/trpc/server"
import { trpc } from "@/lib/trpc/client" import SectionHeader from "@/components/SectionHeader"
import LoadingSpinner from "@/components/LoadingSpinner"
import Header from "@/components/SectionHeader"
import Grids from "@/components/TempDesignSystem/Grids"
import Container from "../Container" import Container from "../Container"
import ListContainer from "../ListContainer" import ClientUpcomingStays from "./Client"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage" import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default function UpcomingStays({ export default async function UpcomingStays({
lang, lang,
title, title,
subtitle, subtitle,
link, link,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const { data, hasNextPage, isFetching, fetchNextPage, isLoading } = const initialUpcomingStays = await serverClient().user.stays.upcoming()
trpc.user.stays.upcoming.useInfiniteQuery( if (!initialUpcomingStays?.data) {
{ limit: 6 }, return null
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
} }
const stays = data?.pages.flatMap((page) => page.data) ?? []
return ( return (
<Container> <Container>
<Header title={title} subtitle={subtitle} link={link} /> <SectionHeader title={title} subtitle={subtitle} link={link} />
{isLoading ? ( <ClientUpcomingStays
<LoadingSpinner /> initialUpcomingStays={initialUpcomingStays}
) : stays.length ? ( lang={lang}
<ListContainer> />
<Grids.Stackable>
{stays.map((stay) => (
<StayCard
key={stay.attributes.confirmationNumber}
lang={lang}
stay={stay}
/>
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
) : (
<EmptyUpcomingStaysBlock />
)}
</Container> </Container>
) )
} }

View File

@@ -1,6 +1,6 @@
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import { ChevronRightIcon , HouseIcon } from "@/components/Icons" import { ChevronRightIcon, HouseIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -8,6 +8,9 @@ import styles from "./breadcrumbs.module.css"
export default async function Breadcrumbs() { export default async function Breadcrumbs() {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get() const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
if (!breadcrumbs) {
return null
}
const homeBreadcrumb = breadcrumbs.shift() const homeBreadcrumb = breadcrumbs.shift()
return ( return (
<nav className={styles.breadcrumbs}> <nav className={styles.breadcrumbs}>

View File

@@ -15,6 +15,9 @@ import type { LangParams } from "@/types/params"
export default async function Sidebar({ lang }: LangParams) { export default async function Sidebar({ lang }: LangParams) {
const navigation = await serverClient().contentstack.myPages.navigation.get() const navigation = await serverClient().contentstack.myPages.navigation.get()
const { formatMessage } = await getIntl() const { formatMessage } = await getIntl()
if (!navigation) {
return null
}
return ( return (
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<nav className={styles.nav}> <nav className={styles.nav}>

View File

@@ -5,6 +5,9 @@ import Form from "./Form"
export default async function EditProfile() { export default async function EditProfile() {
const user = await serverClient().user.get() const user = await serverClient().user.get()
if (!user) {
return null
}
return ( return (
<Container user={user}> <Container user={user}>
<Form user={user} /> <Form user={user} />

View File

@@ -17,6 +17,9 @@ import styles from "./profile.module.css"
export default async function Profile() { export default async function Profile() {
// const { formatMessage } = await getIntl() // const { formatMessage } = await getIntl()
const user = await serverClient().user.get() const user = await serverClient().user.get()
if (!user) {
return null
}
// const countryName = countries.find( // const countryName = countries.find(
// (country) => country.code === user.address.country // (country) => country.code === user.address.country
// ) // )

View File

@@ -2,6 +2,7 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
/* TODO: Waiting for variables for buttons from Design team */
font-family: var(--typography-Body-Regular-fontFamily); font-family: var(--typography-Body-Regular-fontFamily);
font-weight: 600; font-weight: 600;
line-height: 150%; line-height: 150%;

View File

@@ -41,13 +41,8 @@
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
} }
.scriptedTitle { span.scriptedTitle {
color: var(--script-color); color: var(--script-color);
font-family: var(--typography-Script-2-fontFamily);
font-size: var(--typography-Script-2-fontSize);
font-weight: var(--typography-Script-2-fontWeight);
line-height: var(--typography-Script-2-lineHeight);
letter-spacing: 0.48px;
padding: var(--Spacing-x1); padding: var(--Spacing-x1);
margin: 0; margin: 0;
transform: rotate(-3deg); transform: rotate(-3deg);
@@ -57,13 +52,8 @@
color: var(--font-color); color: var(--font-color);
} }
.bodyText { p.bodyText {
color: var(--font-color); color: var(--font-color);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
line-height: var(--typography-Body-Regular-lineHeight);
letter-spacing: 0.096px;
text-align: center;
} }
.buttonContainer { .buttonContainer {

View File

@@ -1,6 +1,8 @@
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { cardVariants } from "./variants" import { cardVariants } from "./variants"
@@ -42,13 +44,20 @@ export default function Card({
> >
{scriptedTopTitle ? ( {scriptedTopTitle ? (
<section className={styles.scriptContainer}> <section className={styles.scriptContainer}>
<h3 className={styles.scriptedTitle}>{scriptedTopTitle}</h3> <BiroScript className={styles.scriptedTitle} type="two">
{scriptedTopTitle}
</BiroScript>
<Divider />
</section> </section>
) : null} ) : null}
<Title as="h5" className={styles.heading} level="h3"> <Title as="h5" className={styles.heading} level="h3">
{heading} {heading}
</Title> </Title>
{bodyText ? <p className={styles.bodyText}>{bodyText}</p> : null} {bodyText ? (
<Body className={styles.bodyText} textAlign="center">
{bodyText}
</Body>
) : null}
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
{primaryButton ? ( {primaryButton ? (
<Button asChild theme={buttonTheme} size="small"> <Button asChild theme={buttonTheme} size="small">

View File

@@ -23,12 +23,6 @@
.input, .input,
.listBoxItem { .listBoxItem {
color: var(--UI-Grey-60); color: var(--UI-Grey-60);
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
font-weight: var(--typography-Body-Regular-fontWeight);
letter-spacing: var--typography-Body-Regular-letterSpacing;
line-height: var(--typography-Body-Regular-lineHeight);
} }
.button { .button {

View File

@@ -14,6 +14,8 @@ import {
import { useController, useFormContext } from "react-hook-form" import { useController, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import SelectChevron from "../SelectChevron" import SelectChevron from "../SelectChevron"
import { countries } from "./countries" import { countries } from "./countries"
@@ -53,11 +55,13 @@ export default function CountrySelect({
selectedKey={field.value} selectedKey={field.value}
> >
<div className={styles.comboBoxContainer}> <div className={styles.comboBoxContainer}>
<Input <Body asChild fontOnly>
aria-label={selectCountryLabel} <Input
className={styles.input} aria-label={selectCountryLabel}
placeholder={selectCountryLabel} className={styles.input}
/> placeholder={selectCountryLabel}
/>
</Body>
<Button className={styles.button}> <Button className={styles.button}>
<SelectChevron /> <SelectChevron />
</Button> </Button>
@@ -79,14 +83,15 @@ export default function CountrySelect({
> >
<ListBox> <ListBox>
{countries.map((country, idx) => ( {countries.map((country, idx) => (
<ListBoxItem <Body asChild fontOnly key={`${country.code}-${idx}`}>
aria-label={country.name} <ListBoxItem
className={styles.listBoxItem} aria-label={country.name}
id={country.code} className={styles.listBoxItem}
key={`${country.code}-${idx}`} id={country.code}
> >
{country.name} {country.name}
</ListBoxItem> </ListBoxItem>
</Body>
))} ))}
</ListBox> </ListBox>
</Popover> </Popover>

View File

@@ -1,8 +1,4 @@
.message { .message {
color: var(--Scandic-Red-60); color: var(--Scandic-Red-60);
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
line-height: var(--typography-Body-Regular-lineHeight);
margin: var(--Spacing-x-half) 0 0; margin: var(--Spacing-x-half) 0 0;
} }

View File

@@ -1,5 +1,7 @@
import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message" import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./error.module.css" import styles from "./error.module.css"
import type { ErrorMessageProps } from "./errorMessage" import type { ErrorMessageProps } from "./errorMessage"
@@ -12,7 +14,11 @@ export default function ErrorMessage<T>({
<RHFErrorMessage <RHFErrorMessage
errors={errors} errors={errors}
name={name} name={name}
render={({ message }) => <p className={styles.message}>{message}</p>} render={({ message }) => (
<Body className={styles.message} fontOnly>
{message}
</Body>
)}
/> />
) )
} }

View File

@@ -3,6 +3,7 @@ import { Input as AriaInput, TextField } from "react-aria-components"
import { useController } from "react-hook-form" import { useController } from "react-hook-form"
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage" import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./input.module.css" import styles from "./input.module.css"
@@ -35,11 +36,13 @@ export default function Input({
onChange={field.onChange} onChange={field.onChange}
type={type} type={type}
> >
<AriaInput <Body asChild fontOnly>
className={styles.input} <AriaInput
placeholder={placeholder} className={styles.input}
ref={field.ref} placeholder={placeholder}
/> ref={field.ref}
/>
</Body>
<ErrorMessage errors={formState.errors} name={name} /> <ErrorMessage errors={formState.errors} name={name} />
</TextField> </TextField>
) )

View File

@@ -2,12 +2,7 @@
border: 2px solid var(--UI-Grey-60); border: 2px solid var(--UI-Grey-60);
border-radius: var(--Corner-radius-Small); border-radius: var(--Corner-radius-Small);
color: var(--UI-Grey-60); color: var(--UI-Grey-60);
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
height: 40px; height: 40px;
letter-spacing: var(--typography-Body-Regular-letterSpacing);
line-height: var(--typography-Body-Regular-lineHeight);
padding: var(--Spacing-x1) var(--Spacing-x2); padding: var(--Spacing-x1) var(--Spacing-x2);
width: min(280px, 100%); width: min(280px, 100%);
} }

View File

@@ -11,6 +11,8 @@ import {
SelectValue, SelectValue,
} from "react-aria-components" } from "react-aria-components"
import Body from "../../Text/Body"
import Footnote from "../../Text/Footnote"
import SelectChevron from "../SelectChevron" import SelectChevron from "../SelectChevron"
import styles from "./select.module.css" import styles from "./select.module.css"
@@ -48,38 +50,44 @@ export default function Select({
placeholder={placeholder} placeholder={placeholder}
selectedKey={value as Key} selectedKey={value as Key}
> >
<Button className={styles.input}> <Body asChild fontOnly>
<div className={styles.inputContentWrapper}> <Button className={styles.input}>
<Label className={styles.label}>{label}</Label> <div className={styles.inputContentWrapper}>
<SelectValue /> <Footnote asChild fontOnly>
</div> <Label className={styles.label}>{label}</Label>
<SelectChevron /> </Footnote>
</Button> <SelectValue />
<Popover </div>
className={styles.popover} <SelectChevron />
placement="bottom" </Button>
shouldFlip={false} </Body>
/** <Body asChild fontOnly>
* react-aria uses portals to render Popover in body <Popover
* unless otherwise specified. We need it to be contained className={styles.popover}
* by this component to both access css variables assigned placement="bottom"
* on the container as well as to not overflow it at any time. shouldFlip={false}
*/ /**
UNSTABLE_portalContainer={rootDiv ?? undefined} * react-aria uses portals to render Popover in body
> * unless otherwise specified. We need it to be contained
<ListBox className={styles.listBox}> * by this component to both access css variables assigned
{items.map((item) => ( * on the container as well as to not overflow it at any time.
<ListBoxItem */
aria-label={String(item)} UNSTABLE_portalContainer={rootDiv ?? undefined}
className={styles.listBoxItem} >
id={item.value} <ListBox className={styles.listBox}>
key={item.label} {items.map((item) => (
> <ListBoxItem
{item.label} aria-label={String(item)}
</ListBoxItem> className={styles.listBoxItem}
))} id={item.value}
</ListBox> key={item.label}
</Popover> >
{item.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</Body>
</ReactAriaSelect> </ReactAriaSelect>
</div> </div>
) )

View File

@@ -4,10 +4,6 @@
.label { .label {
color: var(--Base-Text-UI-Placeholder); color: var(--Base-Text-UI-Placeholder);
font-family: var(--typography-Footnote-Regular-fontFamily);
font-size: var(--typography-Footnote-Regular-fontSize);
font-weight: var(--typography-Footnote-Regular-fontWeight);
line-height: var(--typography-Footnote-Regular-lineHeight);
} }
.select { .select {
@@ -32,11 +28,6 @@
display: flex; display: flex;
gap: var(--Spacing-x-half); gap: var(--Spacing-x-half);
height: 56px; height: 56px;
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
letter-spacing: var(--typography-Body-Regular-letterSpacing);
line-height: var(--typography-Body-Regular-lineHeight);
outline: none; outline: none;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2); padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
text-align: left; text-align: left;
@@ -56,7 +47,6 @@
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.08); box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.08);
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
font-size: var(--typography-Body-Regular-fontSize);
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
overflow: auto; overflow: auto;
width: 100%; width: 100%;

View File

@@ -34,6 +34,10 @@
text-align: left; text-align: left;
} }
.black {
color: #000;
}
.burgundy { .burgundy {
color: var(--Scandic-Brand-Burgundy); color: var(--Scandic-Brand-Burgundy);
} }

View File

@@ -5,6 +5,7 @@ import styles from "./biroScript.module.css"
const config = { const config = {
variants: { variants: {
color: { color: {
black: styles.black,
burgundy: styles.burgundy, burgundy: styles.burgundy,
pale: styles.pale, pale: styles.pale,
primaryLightOnSurfaceAccent: styles.plosa, primaryLightOnSurfaceAccent: styles.plosa,

View File

@@ -3,6 +3,10 @@
padding: 0; padding: 0;
} }
.bodyFontOnly {
font-style: normal;
}
.bold { .bold {
font-family: var(--typography-Body-Bold-fontFamily); font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize); font-size: var(--typography-Body-Bold-fontSize);

View File

@@ -6,4 +6,5 @@ export interface BodyProps
extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">, extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">,
VariantProps<typeof bodyVariants> { VariantProps<typeof bodyVariants> {
asChild?: boolean asChild?: boolean
fontOnly?: boolean
} }

View File

@@ -1,6 +1,6 @@
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { bodyVariants } from "./variants" import { bodyFontOnlyVariants, bodyVariants } from "./variants"
import type { BodyProps } from "./body" import type { BodyProps } from "./body"
@@ -8,16 +8,23 @@ export default function Body({
asChild = false, asChild = false,
className = "", className = "",
color, color,
fontOnly = false,
textAlign, textAlign,
textTransform, textTransform,
...props ...props
}: BodyProps) { }: BodyProps) {
const Comp = asChild ? Slot : "p" const Comp = asChild ? Slot : "p"
const classNames = bodyVariants({ const classNames = fontOnly
className, ? bodyFontOnlyVariants({
color, className,
textAlign, textAlign,
textTransform, textTransform,
}) })
: bodyVariants({
className,
color,
textAlign,
textTransform,
})
return <Comp className={classNames} {...props} /> return <Comp className={classNames} {...props} />
} }

View File

@@ -27,3 +27,22 @@ const config = {
} as const } as const
export const bodyVariants = cva(styles.body, config) export const bodyVariants = cva(styles.body, config)
const fontOnlyconfig = {
variants: {
textAlign: {
center: styles.textAlignCenter,
left: styles.textAlignLeft,
},
textTransform: {
bold: styles.bold,
regular: styles.regular,
underlined: styles.underlined,
},
},
defaultVariants: {
textAlign: "left",
textTransform: "regular",
},
} as const
export const bodyFontOnlyVariants = cva(styles.bodyFontOnly, fontOnlyconfig)

View File

@@ -1,8 +1,12 @@
.body { .caption {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.captionFontOnly {
font-style: normal;
}
.bold { .bold {
font-family: var(--typography-Caption-Bold-fontFamily); font-family: var(--typography-Caption-Bold-fontFamily);
font-size: var(--typography-Caption-Bold-fontSize); font-size: var(--typography-Caption-Bold-fontSize);

View File

@@ -6,4 +6,5 @@ export interface CaptionProps
extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">, extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">,
VariantProps<typeof captionVariants> { VariantProps<typeof captionVariants> {
asChild?: boolean asChild?: boolean
fontOnly?: boolean
} }

View File

@@ -1,6 +1,6 @@
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { captionVariants } from "./variants" import { captionVariants, fontOnlycaptionVariants } from "./variants"
import type { CaptionProps } from "./caption" import type { CaptionProps } from "./caption"
@@ -8,14 +8,20 @@ export default function Caption({
asChild = false, asChild = false,
className = "", className = "",
color, color,
fontOnly = false,
textTransform, textTransform,
...props ...props
}: CaptionProps) { }: CaptionProps) {
const Comp = asChild ? Slot : "p" const Comp = asChild ? Slot : "p"
const classNames = captionVariants({ const classNames = fontOnly
className, ? fontOnlycaptionVariants({
color, className,
textTransform, textTransform,
}) })
: captionVariants({
className,
color,
textTransform,
})
return <Comp className={classNames} {...props} /> return <Comp className={classNames} {...props} />
} }

View File

@@ -21,3 +21,20 @@ const config = {
} as const } as const
export const captionVariants = cva(styles.caption, config) export const captionVariants = cva(styles.caption, config)
const fontOnlyConfig = {
variants: {
textTransform: {
bold: styles.bold,
regular: styles.regular,
},
},
defaultVariants: {
textTransform: "regular",
},
} as const
export const fontOnlycaptionVariants = cva(
styles.captionFontOnly,
fontOnlyConfig
)

View File

@@ -3,6 +3,10 @@
padding: 0; padding: 0;
} }
.footnoteFontOnly {
font-style: normal;
}
.bold { .bold {
font-family: var(--typography-Footnote-Bold-fontFamily); font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize); font-size: var(--typography-Footnote-Bold-fontSize);

View File

@@ -6,4 +6,5 @@ export interface FootnoteProps
extends Omit<React.HTMLAttributes<HTMLParagraphElement>, "color">, extends Omit<React.HTMLAttributes<HTMLParagraphElement>, "color">,
VariantProps<typeof footnoteVariants> { VariantProps<typeof footnoteVariants> {
asChild?: boolean asChild?: boolean
fontOnly?: boolean
} }

View File

@@ -1,6 +1,6 @@
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { footnoteVariants } from "./variants" import { footnoteFontOnlyVariants, footnoteVariants } from "./variants"
import type { FootnoteProps } from "./footnote" import type { FootnoteProps } from "./footnote"
@@ -8,16 +8,23 @@ export default function Footnote({
asChild = false, asChild = false,
className = "", className = "",
color, color,
fontOnly = false,
textAlign, textAlign,
textTransform, textTransform,
...props ...props
}: FootnoteProps) { }: FootnoteProps) {
const Comp = asChild ? Slot : "p" const Comp = asChild ? Slot : "p"
const classNames = footnoteVariants({ const classNames = fontOnly
className, ? footnoteFontOnlyVariants({
color, className,
textAlign, textAlign,
textTransform, textTransform,
}) })
: footnoteVariants({
className,
color,
textAlign,
textTransform,
})
return <Comp className={classNames} {...props} /> return <Comp className={classNames} {...props} />
} }

View File

@@ -24,3 +24,24 @@ const config = {
} as const } as const
export const footnoteVariants = cva(styles.footnote, config) export const footnoteVariants = cva(styles.footnote, config)
const fontOnlyConfig = {
variants: {
textAlign: {
center: styles.center,
left: styles.left,
},
textTransform: {
bold: styles.bold,
regular: styles.regular,
},
},
defaultVariants: {
textTransform: "regular",
},
} as const
export const footnoteFontOnlyVariants = cva(
styles.footnoteFontOnly,
fontOnlyConfig
)

View File

@@ -1,4 +1,5 @@
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { Children } from "react"
import { subtitleVariants } from "./variants" import { subtitleVariants } from "./variants"
@@ -13,7 +14,7 @@ export default function Subtitle({
textTransform, textTransform,
...props ...props
}: SubtitleProps) { }: SubtitleProps) {
if (hideEmpty && !props.children) { if (hideEmpty && Children.count(props.children) === 0) {
return null return null
} }
const Comp = asChild ? Slot : "p" const Comp = asChild ? Slot : "p"

View File

@@ -1,3 +1,5 @@
import { Children } from "react"
import { headingVariants } from "./variants" import { headingVariants } from "./variants"
import type { HeadingProps } from "./title" import type { HeadingProps } from "./title"
@@ -12,7 +14,7 @@ export default function Title({
textAlign, textAlign,
textTransform, textTransform,
}: HeadingProps) { }: HeadingProps) {
if (hideEmpty && !children) { if (hideEmpty && Children.count(children) === 0) {
return null return null
} }
const Hx = level const Hx = level

View File

@@ -2,7 +2,7 @@ import { headingVariants } from "./variants"
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority"
type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5"
export interface HeadingProps export interface HeadingProps
extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">, extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">,

View File

@@ -22,7 +22,6 @@ const config = {
h3: styles.h3, h3: styles.h3,
h4: styles.h4, h4: styles.h4,
h5: styles.h5, h5: styles.h5,
h6: styles.h6,
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -23,6 +23,7 @@
"Description": "Beskrivelse", "Description": "Beskrivelse",
"Edit": "Redigere", "Edit": "Redigere",
"Email": "E-mail", "Email": "E-mail",
"Empty": "Empty",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele", "Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Find booking": "Find booking", "Find booking": "Find booking",
"Get inspired": "Blive inspireret", "Get inspired": "Blive inspireret",

View File

@@ -23,6 +23,7 @@
"Description": "Beschreibung", "Description": "Beschreibung",
"Edit": "Bearbeiten", "Edit": "Bearbeiten",
"Email": "Email", "Email": "Email",
"Empty": "Empty",
"Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile", "Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Find booking": "Buchung finden", "Find booking": "Buchung finden",
"Get inspired": "Lass dich inspirieren", "Get inspired": "Lass dich inspirieren",

View File

@@ -23,8 +23,10 @@
"Description": "Description", "Description": "Description",
"Edit": "Edit", "Edit": "Edit",
"Email": "Email", "Email": "Email",
"Empty": "Empty",
"Explore all levels and benefits": "Explore all levels and benefits", "Explore all levels and benefits": "Explore all levels and benefits",
"Find booking": "Find booking", "Find booking": "Find booking",
"Free soft drink voucher for the kids when staying": "Free soft drink voucher for the kids when staying",
"Get inspired": "Get inspired", "Get inspired": "Get inspired",
"Go back to overview": "Go back to overview", "Go back to overview": "Go back to overview",
"How it works": "How it works", "How it works": "How it works",

View File

@@ -23,6 +23,7 @@
"Description": "Kuvaus", "Description": "Kuvaus",
"Edit": "Muokata", "Edit": "Muokata",
"Email": "Sähköposti", "Email": "Sähköposti",
"Empty": "Empty",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin", "Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Find booking": "Etsi varaus", "Find booking": "Etsi varaus",
"Get inspired": "Inspiroidu", "Get inspired": "Inspiroidu",

View File

@@ -23,6 +23,7 @@
"Description": "Beskrivelse", "Description": "Beskrivelse",
"Edit": "Redigere", "Edit": "Redigere",
"Email": "E-post", "Email": "E-post",
"Empty": "Empty",
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler", "Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
"Find booking": "Finn booking", "Find booking": "Finn booking",
"Get inspired": "Bli inspirert", "Get inspired": "Bli inspirert",

View File

@@ -23,6 +23,7 @@
"Description": "Beskrivning", "Description": "Beskrivning",
"Edit": "Redigera", "Edit": "Redigera",
"Email": "E-post", "Email": "E-post",
"Empty": "Tom",
"Explore all levels and benefits": "Utforska alla nivåer och fördelar", "Explore all levels and benefits": "Utforska alla nivåer och fördelar",
"Find booking": "Hitta bokning", "Find booking": "Hitta bokning",
"Get inspired": "Bli inspirerad", "Get inspired": "Bli inspirerad",

View File

@@ -64,7 +64,7 @@ export default function TrpcProvider({
}), }),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 3000, staleTime: 60 * 1000,
retry(failureCount, error) { retry(failureCount, error) {
if (error instanceof TRPCClientError) { if (error instanceof TRPCClientError) {
const appError: TRPCClientError<AnyTRPCRouter> = error const appError: TRPCClientError<AnyTRPCRouter> = error

View File

@@ -3,7 +3,7 @@ import {
GetAccountPageRefs, GetAccountPageRefs,
} from "@/lib/graphql/Query/AccountPage.graphql" } from "@/lib/graphql/Query/AccountPage.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc" import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { import {
@@ -45,15 +45,14 @@ export const accountPageQueryRouter = router({
throw notFound(refsResponse) throw notFound(refsResponse)
} }
// Remove empty objects from a fetched content type. Needed since
// Contentstack returns empty objects for all non queried blocks in modular blocks.
// This is an ongoing support case in Contentstack, ticker number #00031579
const cleanedData = removeEmptyObjects(refsResponse.data) const cleanedData = removeEmptyObjects(refsResponse.data)
const validatedAccountPageRefs = const validatedAccountPageRefs =
validateAccountPageRefsSchema.safeParse(cleanedData) validateAccountPageRefsSchema.safeParse(cleanedData)
if (!validatedAccountPageRefs.success) { if (!validatedAccountPageRefs.success) {
throw internalServerError(validatedAccountPageRefs.error) console.info(`Failed to validate My Page Refs - (uid: ${uid})`)
console.error(validatedAccountPageRefs.error)
return null
} }
const connections = getConnections(validatedAccountPageRefs.data) const connections = getConnections(validatedAccountPageRefs.data)
@@ -81,7 +80,9 @@ export const accountPageQueryRouter = router({
) )
if (!validatedAccountPage.success) { if (!validatedAccountPage.success) {
throw internalServerError(validatedAccountPage.error) console.info(`Failed to validate Account Page - (uid: ${uid})`)
console.error(validatedAccountPage.error)
return null
} }
// TODO: Make returned data nicer // TODO: Make returned data nicer

View File

@@ -8,12 +8,8 @@ import {
GetCurrentHeaderRef, GetCurrentHeaderRef,
} from "@/lib/graphql/Query/CurrentHeader.graphql" } from "@/lib/graphql/Query/CurrentHeader.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
import { import { contentstackBaseProcedure, router } from "@/server/trpc"
contentstackBaseProcedure,
publicProcedure,
router,
} from "@/server/trpc"
import { generateTag } from "@/utils/generateTag" import { generateTag } from "@/utils/generateTag"
@@ -47,12 +43,16 @@ export const baseQueryRouter = router({
) )
if (!validatedContactConfigConfig.success) { if (!validatedContactConfigConfig.success) {
throw internalServerError(validatedContactConfigConfig.error) console.info(
`Failed to validate Contact Config Data - (lang: ${ctx.lang})`
)
console.error(validatedContactConfigConfig.error)
return null
} }
return validatedContactConfigConfig.data.all_contact_config.items[0] return validatedContactConfigConfig.data.all_contact_config.items[0]
}), }),
header: publicProcedure.input(langInput).query(async ({ input }) => { header: contentstackBaseProcedure.input(langInput).query(async ({ input }) => {
const responseRef = await request<HeaderRefDataRaw>(GetCurrentHeaderRef, { const responseRef = await request<HeaderRefDataRaw>(GetCurrentHeaderRef, {
locale: input.lang, locale: input.lang,
}) })
@@ -79,7 +79,9 @@ export const baseQueryRouter = router({
) )
if (!validatedHeaderConfig.success) { if (!validatedHeaderConfig.success) {
throw internalServerError(validatedHeaderConfig.error) console.info(`Failed to validate Header - (lang: ${input.lang})`)
console.error(validatedHeaderConfig.error)
return null
} }
const logo = const logo =
@@ -91,7 +93,7 @@ export const baseQueryRouter = router({
logo, logo,
} as HeaderData } as HeaderData
}), }),
footer: publicProcedure.input(langInput).query(async ({ input }) => { footer: contentstackBaseProcedure.input(langInput).query(async ({ input }) => {
const responseRef = await request<FooterRefDataRaw>(GetCurrentFooterRef, { const responseRef = await request<FooterRefDataRaw>(GetCurrentFooterRef, {
locale: input.lang, locale: input.lang,
}) })
@@ -116,7 +118,9 @@ export const baseQueryRouter = router({
) )
if (!validatedFooterConfig.success) { if (!validatedFooterConfig.success) {
throw internalServerError(validatedFooterConfig.error) console.info(`Failed to validate Footer - (lang: ${input.lang})`)
console.error(validatedFooterConfig.error)
return null
} }
return validatedFooterConfig.data.all_current_footer.items[0] return validatedFooterConfig.data.all_current_footer.items[0]

View File

@@ -6,7 +6,6 @@ import {
GetMyPagesBreadcrumbs, GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs, GetMyPagesBreadcrumbsRefs,
} from "@/lib/graphql/Query/BreadcrumbsMyPages.graphql" } from "@/lib/graphql/Query/BreadcrumbsMyPages.graphql"
import { internalServerError } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc" import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { import {
@@ -41,7 +40,11 @@ async function getLoyaltyPageBreadcrumbs(variables: Variables) {
) )
if (!validatedRefsData.success) { if (!validatedRefsData.success) {
throw internalServerError(validatedRefsData.error) console.info(
`Failed to validate Loyaltypage Breadcrumbs Refs - (url: ${variables.url})`
)
console.error(validatedRefsData.error)
return null
} }
const tags = getTags(validatedRefsData.data.all_loyalty_page, variables) const tags = getTags(validatedRefsData.data.all_loyalty_page, variables)
@@ -53,14 +56,18 @@ async function getLoyaltyPageBreadcrumbs(variables: Variables) {
) )
if (!response.data.all_loyalty_page.items[0].web?.breadcrumbs?.title) { if (!response.data.all_loyalty_page.items[0].web?.breadcrumbs?.title) {
return [] return null
} }
const validatedBreadcrumbsData = const validatedBreadcrumbsData =
validateLoyaltyPageBreadcrumbsContentstackSchema.safeParse(response.data) validateLoyaltyPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) { if (!validatedBreadcrumbsData.success) {
throw internalServerError(validatedBreadcrumbsData.error) console.info(
`Failed to validate Loyaltypage Breadcrumbs Data - (url: ${variables.url})`
)
console.error(validatedBreadcrumbsData.error)
return null
} }
return getBreadcrumbs( return getBreadcrumbs(
@@ -80,7 +87,11 @@ async function getMyPagesBreadcrumbs(variables: Variables) {
refsResponse.data refsResponse.data
) )
if (!validatedRefsData.success) { if (!validatedRefsData.success) {
throw internalServerError(validatedRefsData.error) console.info(
`Failed to validate My Page Breadcrumbs Refs - (url: ${variables.url})`
)
console.error(validatedRefsData.error)
return null
} }
const tags = getTags(validatedRefsData.data.all_account_page, variables) const tags = getTags(validatedRefsData.data.all_account_page, variables)
@@ -99,7 +110,11 @@ async function getMyPagesBreadcrumbs(variables: Variables) {
validateMyPagesBreadcrumbsContentstackSchema.safeParse(response.data) validateMyPagesBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) { if (!validatedBreadcrumbsData.success) {
throw internalServerError(validatedBreadcrumbsData.error) console.info(
`Failed to validate My Page Breadcrumbs Data - (url: ${variables.url})`
)
console.error(validatedBreadcrumbsData.error)
return null
} }
return getBreadcrumbs( return getBreadcrumbs(

View File

@@ -119,7 +119,11 @@ export const languageSwitcherQueryRouter = router({
validateLanguageSwitcherData.safeParse(urls) validateLanguageSwitcherData.safeParse(urls)
if (!validatedLanguageSwitcherData.success) { if (!validatedLanguageSwitcherData.success) {
throw internalServerError(validatedLanguageSwitcherData.error) console.info(
`Failed to validate Language Switcher Data - (contentType: ${ctx.contentType}, lang: ${ctx.lang}, uid: ${ctx.uid})`
)
console.error(validatedLanguageSwitcherData.error)
return null
} }
return { return {

View File

@@ -3,7 +3,7 @@ import {
GetLoyaltyPageRefs, GetLoyaltyPageRefs,
} from "@/lib/graphql/Query/LoyaltyPage.graphql" } from "@/lib/graphql/Query/LoyaltyPage.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc" import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { import {
@@ -35,9 +35,9 @@ function makeButtonObject(button: any) {
href: href:
button.is_contentstack_link && button.linkConnection.edges.length button.is_contentstack_link && button.linkConnection.edges.length
? button.linkConnection.edges[0].node.web?.original_url || ? button.linkConnection.edges[0].node.web?.original_url ||
removeMultipleSlashes( removeMultipleSlashes(
`/${button.linkConnection.edges[0].node.system.locale}/${button.linkConnection.edges[0].node.url}` `/${button.linkConnection.edges[0].node.system.locale}/${button.linkConnection.edges[0].node.url}`
) )
: button.external_link.href, : button.external_link.href,
isExternal: !button.is_contentstack_link, isExternal: !button.is_contentstack_link,
} }
@@ -62,17 +62,16 @@ export const loyaltyPageQueryRouter = router({
throw notFound(refsResponse) throw notFound(refsResponse)
} }
// Remove empty objects from a fetched content type. Needed since
// Contentstack returns empty objects for all non queried blocks in modular blocks.
// This is an ongoing support case in Contentstack, ticker number #00031579
const cleanedData = removeEmptyObjects(refsResponse.data) const cleanedData = removeEmptyObjects(refsResponse.data)
const validatedLoyaltyPageRefs = const validatedLoyaltyPageRefs =
validateLoyaltyPageRefsSchema.safeParse(cleanedData) validateLoyaltyPageRefsSchema.safeParse(cleanedData)
if (!validatedLoyaltyPageRefs.success) { if (!validatedLoyaltyPageRefs.success) {
console.error("Bad validation for `GetLoyaltyPageRefs`") console.info(
`Failed to validate Loyaltypage Refs - (lang: ${lang}, uid: ${uid})`
)
console.error(validatedLoyaltyPageRefs.error) console.error(validatedLoyaltyPageRefs.error)
throw internalServerError(validatedLoyaltyPageRefs.error) return null
} }
const connections = getConnections(validatedLoyaltyPageRefs.data) const connections = getConnections(validatedLoyaltyPageRefs.data)
@@ -97,66 +96,66 @@ export const loyaltyPageQueryRouter = router({
const blocks = response.data.loyalty_page.blocks const blocks = response.data.loyalty_page.blocks
? response.data.loyalty_page.blocks.map((block: any) => { ? response.data.loyalty_page.blocks.map((block: any) => {
switch (block.__typename) { switch (block.__typename) {
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksDynamicContent: case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksDynamicContent:
return { return {
...block, ...block,
dynamic_content: { dynamic_content: {
...block.dynamic_content, ...block.dynamic_content,
link: block.dynamic_content.link.pageConnection.edges.length link: block.dynamic_content.link.pageConnection.edges.length
? { ? {
text: block.dynamic_content.link.text, text: block.dynamic_content.link.text,
href: removeMultipleSlashes( href: removeMultipleSlashes(
`/${block.dynamic_content.link.pageConnection.edges[0].node.system.locale}/${block.dynamic_content.link.pageConnection.edges[0].node.url}` `/${block.dynamic_content.link.pageConnection.edges[0].node.system.locale}/${block.dynamic_content.link.pageConnection.edges[0].node.url}`
), ),
title: title:
block.dynamic_content.link.pageConnection.edges[0] block.dynamic_content.link.pageConnection.edges[0]
.node.title, .node.title,
} }
: undefined, : undefined,
}, },
} }
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksShortcuts: case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksShortcuts:
return { return {
...block, ...block,
shortcuts: { shortcuts: {
...block.shortcuts, ...block.shortcuts,
shortcuts: block.shortcuts.shortcuts.map((shortcut: any) => ({ shortcuts: block.shortcuts.shortcuts.map((shortcut: any) => ({
text: shortcut.text, text: shortcut.text,
openInNewTab: shortcut.open_in_new_tab, openInNewTab: shortcut.open_in_new_tab,
...shortcut.linkConnection.edges[0].node, ...shortcut.linkConnection.edges[0].node,
url: url:
shortcut.linkConnection.edges[0].node.web?.original_url || shortcut.linkConnection.edges[0].node.web?.original_url ||
removeMultipleSlashes( removeMultipleSlashes(
`/${shortcut.linkConnection.edges[0].node.system.locale}/${shortcut.linkConnection.edges[0].node.url}` `/${shortcut.linkConnection.edges[0].node.system.locale}/${shortcut.linkConnection.edges[0].node.url}`
), ),
})), })),
}, },
} }
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksCardsGrid: case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksCardsGrid:
return { return {
...block, ...block,
cards_grid: { cards_grid: {
...block.cards_grid, ...block.cards_grid,
cards: block.cards_grid.cardConnection.edges.map( cards: block.cards_grid.cardConnection.edges.map(
({ node: card }: { node: any }) => { ({ node: card }: { node: any }) => {
return { return {
...card, ...card,
primaryButton: card.has_primary_button primaryButton: card.has_primary_button
? makeButtonObject(card.primary_button) ? makeButtonObject(card.primary_button)
: undefined, : undefined,
secondaryButton: card.has_secondary_button secondaryButton: card.has_secondary_button
? makeButtonObject(card.secondary_button) ? makeButtonObject(card.secondary_button)
: undefined, : undefined,
}
} }
), }
}, ),
} },
default: }
return block default:
} return block
}) }
})
: null : null
const loyaltyPage = { const loyaltyPage = {
@@ -170,7 +169,11 @@ export const loyaltyPageQueryRouter = router({
validateLoyaltyPageSchema.safeParse(loyaltyPage) validateLoyaltyPageSchema.safeParse(loyaltyPage)
if (!validatedLoyaltyPage.success) { if (!validatedLoyaltyPage.success) {
throw internalServerError(validatedLoyaltyPage.error) console.info(
`Failed to validate Loyaltypage Data - (lang: ${lang}, uid: ${uid})`
)
console.error(validatedLoyaltyPage.error)
return null
} }
// Assert LoyaltyPage type to get correct typings for RTE fields // Assert LoyaltyPage type to get correct typings for RTE fields

View File

@@ -3,7 +3,7 @@ import {
GetNavigationMyPagesRefs, GetNavigationMyPagesRefs,
} from "@/lib/graphql/Query/NavigationMyPages.graphql" } from "@/lib/graphql/Query/NavigationMyPages.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
import { contentstackBaseProcedure, router } from "@/server/trpc" import { contentstackBaseProcedure, router } from "@/server/trpc"
import { import {
@@ -71,7 +71,11 @@ export const navigationQueryRouter = router({
const validatedMyPagesNavigationRefs = const validatedMyPagesNavigationRefs =
navigationRefsPayloadSchema.safeParse(refsResponse.data) navigationRefsPayloadSchema.safeParse(refsResponse.data)
if (!validatedMyPagesNavigationRefs.success) { if (!validatedMyPagesNavigationRefs.success) {
throw internalServerError(validatedMyPagesNavigationRefs.error) console.info(
`Failed to validate My Pages Navigation Refs - (lang: ${lang}`
)
console.error(validatedMyPagesNavigationRefs.error)
return null
} }
const connections = getConnections(validatedMyPagesNavigationRefs.data) const connections = getConnections(validatedMyPagesNavigationRefs.data)
@@ -99,7 +103,11 @@ export const navigationQueryRouter = router({
response.data response.data
) )
if (!validatedMyPagesNavigation.success) { if (!validatedMyPagesNavigation.success) {
throw internalServerError(validatedMyPagesNavigation.error) console.info(
`Failed to validate My Pages Navigation Data - (lang: ${lang}`
)
console.error(validatedMyPagesNavigation.error)
return null
} }
const menuItem = const menuItem =
@@ -112,7 +120,11 @@ export const navigationQueryRouter = router({
const validatedNav = getNavigationSchema.safeParse(nav) const validatedNav = getNavigationSchema.safeParse(nav)
if (!validatedNav.success) { if (!validatedNav.success) {
throw internalServerError(validatedNav.error) console.info(
`Failed to validate My Pages Navigation Return Data - (lang: ${lang}`
)
console.error(validatedNav.error)
return null
} }
return validatedNav.data return validatedNav.data

View File

@@ -1,3 +0,0 @@
/**
* Add User mutations
*/

View File

@@ -104,17 +104,17 @@ export const getFriendTransactionsSchema = z.object({
data: z.array( data: z.array(
z.object({ z.object({
attributes: z.object({ attributes: z.object({
hotelOperaId: z.string(), awardPoints: z.number().default(0),
confirmationNumber: z.string(), checkinDate: z.string().default(""),
checkinDate: z.string(), checkoutDate: z.string().default(""),
checkoutDate: z.string(), confirmationNumber: z.string().default(""),
nights: z.number(), hotelOperaId: z.string().default(""),
awardPoints: z.number(), nights: z.number().default(1),
pointsCalculated: z.boolean(), pointsCalculated: z.boolean().default(true),
hotelInformation: z hotelInformation: z
.object({ .object({
hotelName: z.string(), city: z.string().default(""),
city: z.string(), name: z.string().default(""),
hotelContent: z.object({ hotelContent: z.object({
images: z.object({ images: z.object({
metaData: z.object({ metaData: z.object({
@@ -135,29 +135,28 @@ export const getFriendTransactionsSchema = z.object({
.optional(), .optional(),
}), }),
relationships: z.object({ relationships: z.object({
booking: z.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
}),
hotel: z hotel: z
.object({ .object({
links: z.object({
related: z.string(),
}),
data: z.object({ data: z.object({
id: z.string(), id: z.string().default(""),
type: z.string(), type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}), }),
}) })
.optional(), .optional(),
booking: z.object({
links: z.object({
related: z.string(),
}),
data: z.object({
id: z.string(),
type: z.string(),
}),
}),
}), }),
type: z.string(), type: z.string().default(""),
}) })
), ),
links: z links: z
@@ -169,7 +168,3 @@ export const getFriendTransactionsSchema = z.object({
}) })
.nullable(), .nullable(),
}) })
type GetFriendTransactionsData = z.infer<typeof getFriendTransactionsSchema>
export type Transaction = GetFriendTransactionsData["data"][number]

View File

@@ -1,11 +1,4 @@
import * as api from "@/lib/api" import * as api from "@/lib/api"
import {
badRequestError,
forbiddenError,
internalServerError,
notFound,
unauthorizedError,
} from "@/server/errors/trpc"
import { protectedProcedure, router } from "@/server/trpc" import { protectedProcedure, router } from "@/server/trpc"
import { friendTransactionsInput, staysInput } from "./input" import { friendTransactionsInput, staysInput } from "./input"
@@ -34,26 +27,36 @@ export const userQueryRouter = router({
}) })
if (!apiResponse.ok) { if (!apiResponse.ok) {
switch (apiResponse.status) { // switch (apiResponse.status) {
case 400: // case 400:
throw badRequestError(apiResponse) // throw badRequestError(apiResponse)
case 401: // case 401:
throw unauthorizedError(apiResponse) // throw unauthorizedError(apiResponse)
case 403: // case 403:
throw forbiddenError(apiResponse) // throw forbiddenError(apiResponse)
default: // default:
throw internalServerError(apiResponse) // throw internalServerError(apiResponse)
} // }
console.info(`API Response Failed - Getting User`)
console.info(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse)
return null
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
if (!apiJson.data?.attributes) { if (!apiJson.data?.attributes) {
throw notFound(apiJson) // throw notFound(apiJson)
console.error(
`User has no data - (user: ${JSON.stringify(ctx.session.user)})`
)
return null
} }
const verifiedData = getUserSchema.safeParse(apiJson.data.attributes) const verifiedData = getUserSchema.safeParse(apiJson.data.attributes)
if (!verifiedData.success) { if (!verifiedData.success) {
throw internalServerError(verifiedData.error) console.info(`Failed to validate User - (name: ${ctx.session.user?.name}`)
console.error(verifiedData.error)
return null
} }
return { return {
@@ -98,28 +101,35 @@ export const userQueryRouter = router({
) )
if (!apiResponse.ok) { if (!apiResponse.ok) {
switch (apiResponse.status) { // switch (apiResponse.status) {
case 400: // case 400:
throw badRequestError(apiResponse) // throw badRequestError(apiResponse)
case 401: // case 401:
throw unauthorizedError(apiResponse) // throw unauthorizedError(apiResponse)
case 403: // case 403:
throw forbiddenError(apiResponse) // throw forbiddenError(apiResponse)
default: // default:
throw internalServerError(apiResponse) // throw internalServerError(apiResponse)
} // }
console.info(`API Response Failed - Getting Previous Stays`)
console.info(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse)
return null
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson) const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) { if (!verifiedData.success) {
throw internalServerError(verifiedData.error) console.info(`Failed to validate Previous Stays Data`)
console.info(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(verifiedData.error)
return null
} }
const nextCursor = const nextCursor =
verifiedData.data.links && verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset ? verifiedData.data.links.offset
: undefined : undefined
@@ -152,27 +162,34 @@ export const userQueryRouter = router({
) )
if (!apiResponse.ok) { if (!apiResponse.ok) {
switch (apiResponse.status) { // switch (apiResponse.status) {
case 400: // case 400:
throw badRequestError(apiResponse) // throw badRequestError(apiResponse)
case 401: // case 401:
throw unauthorizedError(apiResponse) // throw unauthorizedError(apiResponse)
case 403: // case 403:
throw forbiddenError(apiResponse) // throw forbiddenError(apiResponse)
default: // default:
throw internalServerError(apiResponse) // throw internalServerError(apiResponse)
} // }
console.info(`API Response Failed - Getting Upcoming Stays`)
console.info(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse)
return null
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson) const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) { if (!verifiedData.success) {
throw internalServerError(verifiedData.error) console.info(`Failed to validate Upcoming Stays Data`)
console.info(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(verifiedData.error)
return null
} }
const nextCursor = const nextCursor =
verifiedData.data.links && verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset ? verifiedData.data.links.offset
: undefined : undefined
@@ -186,76 +203,70 @@ export const userQueryRouter = router({
friendTransactions: protectedProcedure friendTransactions: protectedProcedure
.input(friendTransactionsInput) .input(friendTransactionsInput)
.query(async (opts) => { .query(async (opts) => {
try { const { limit, cursor } = opts.input
const { limit, cursor } = opts.input
const params = new URLSearchParams() const params = new URLSearchParams()
params.set("limit", limit.toString()) params.set("limit", limit.toString())
if (cursor) { if (cursor) {
params.set("offset", cursor.toString()) params.set("offset", cursor.toString())
} }
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.friendTransactions, api.endpoints.v1.friendTransactions,
{ {
headers: { headers: {
Authorization: `Bearer ${opts.ctx.session.token.access_token}`, Authorization: `Bearer ${opts.ctx.session.token.access_token}`,
},
}, },
params },
) params
)
if (!apiResponse.ok) { if (!apiResponse.ok) {
switch (apiResponse.status) { // switch (apiResponse.status) {
case 400: // case 400:
throw badRequestError() // throw badRequestError()
case 401: // case 401:
throw unauthorizedError() // throw unauthorizedError()
case 403: // case 403:
throw forbiddenError() // throw forbiddenError()
default: // default:
throw internalServerError() // throw internalServerError()
} // }
} console.info(`API Response Failed - Getting Friend Transactions`)
console.info(`User: (${JSON.stringify(opts.ctx.session.user)})`)
console.error(apiResponse)
return null
}
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
// const apiJson = friendTransactionsMockJson const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.info(`Failed to validate Friend Transactions Data`)
console.info(`User: (${JSON.stringify(opts.ctx.session.user)})`)
console.error(verifiedData.error)
return null
}
if (!apiJson.data?.length) { const nextCursor =
// throw internalServerError() verifiedData.data.links &&
} verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson) return {
data: verifiedData.data.data.map(({ attributes }) => {
if (!verifiedData.success) { return {
console.info(`Get Friend Transactions - Verified Data Error`) awardPoints: attributes.awardPoints,
console.error(verifiedData.error)
throw badRequestError()
}
const nextCursor =
verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
return {
data: verifiedData.data.data.map(({ attributes }) => ({
checkinDate: attributes.checkinDate, checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate, checkoutDate: attributes.checkoutDate,
awardPoints: attributes.awardPoints,
hotelName: attributes.hotelInformation?.hotelName,
city: attributes.hotelInformation?.city, city: attributes.hotelInformation?.city,
nights: attributes.nights,
confirmationNumber: attributes.confirmationNumber, confirmationNumber: attributes.confirmationNumber,
})), hotelName: attributes.hotelInformation?.name,
nextCursor, nights: attributes.nights,
} }
} catch (error) { }),
console.info(`Get Friend Transactions Error`) nextCursor,
console.error(error)
throw internalServerError()
} }
}), }),
}), }),

View File

@@ -1,25 +1,28 @@
import { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { UserQueryRouter } from "../user"
export type TransactionResponse = Awaited<
ReturnType<UserQueryRouter["transaction"]["friendTransactions"]>
>
export type TransactionsObject = NonNullable<TransactionResponse>
export type Transactions = NonNullable<TransactionResponse>["data"]
export type Transaction = NonNullable<TransactionResponse>["data"][number]
export type ClientEarnAndBurnProps = {
initialData: TransactionsObject
lang: Lang
}
export type EarnAndBurnProps = { export type EarnAndBurnProps = {
lang: Lang lang: Lang
} }
type Transaction = { export interface TableProps {
checkinDate: string lang: Lang
checkoutDate: string transactions: Transactions
awardPoints: number
hotelName?: string
city?: string
nights: number
confirmationNumber: string
} }
export type Page = { export interface RowProps {
data: Transaction[]
nextCursor?: number
}
export type RowProps = {
lang: Lang lang: Lang
transaction: Transaction transaction: Transaction
} }

View File

@@ -0,0 +1,15 @@
import type { Lang } from "@/constants/languages"
import type { UserQueryRouter } from "../user"
export type PreviousStaysResponse = Awaited<
ReturnType<UserQueryRouter["stays"]["previous"]>
>
export type PreviousStaysNonNullResponseObject =
NonNullable<PreviousStaysResponse>
export type PreviousStays = NonNullable<PreviousStaysResponse>["data"]
export type PreviousStay = NonNullable<PreviousStaysResponse>["data"][number]
export interface PreviousStaysClientProps {
lang: Lang
initialPreviousStays: PreviousStaysNonNullResponseObject
}

View File

@@ -0,0 +1,15 @@
import type { Lang } from "@/constants/languages"
import type { UserQueryRouter } from "../user"
export type UpcomingStaysResponse = Awaited<
ReturnType<UserQueryRouter["stays"]["upcoming"]>
>
export type UpcomingStaysNonNullResponseObject =
NonNullable<UpcomingStaysResponse>
export type UpcomingStays = NonNullable<UpcomingStaysResponse>["data"]
export type UpcomingStay = NonNullable<UpcomingStaysResponse>["data"][number]
export interface UpcomingStaysClientProps {
lang: Lang
initialUpcomingStays: UpcomingStaysNonNullResponseObject
}

View File

@@ -1,5 +1,9 @@
import { userQueryRouter } from "@/server/routers/user/query"
import type { User } from "@/types/user" import type { User } from "@/types/user"
export type UserQueryRouter = typeof userQueryRouter
export interface UserProps { export interface UserProps {
user: User user: User
} }