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[] }>) {
const accountPage = await serverClient().contentstack.accountPage.get()
const { formatMessage } = await getIntl()
if (!accountPage) {
return null
}
return (
<main className={styles.blocks}>
{accountPage.content.length ? (

View File

@@ -4,6 +4,8 @@ import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
export default async function LanguageSwitcherRoute() {
const data = await serverClient().contentstack.languageSwitcher.get()
if (!data) {
return null
}
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 { LangParams, PageArgs } from "@/types/params"
@@ -7,15 +5,8 @@ import { LangParams, PageArgs } from "@/types/params"
export default function HeaderLayout({
params,
languageSwitcher,
children,
}: PageArgs<LangParams> & {
languageSwitcher: ReactNode
children: ReactNode
languageSwitcher: React.ReactNode
}) {
return (
<>
<Header lang={params.lang} languageSwitcher={languageSwitcher} />
{children}
</>
)
return <Header lang={params.lang} languageSwitcher={languageSwitcher} />
}

View File

@@ -4,6 +4,8 @@ import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
export default async function LanguageSwitcherRoute() {
const data = await serverClient().contentstack.languageSwitcher.get()
if (!data) {
return null
}
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) {
const loyaltyPage = await serverClient().contentstack.loyaltyPage.get()
if (!loyaltyPage) {
return null
}
return (
<section className={styles.content}>
{loyaltyPage.sidebar.length ? (

View File

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

View File

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

View File

@@ -71,4 +71,4 @@
.mobile {
display: none;
}
}
}

View File

@@ -21,6 +21,10 @@ export default async function Header({
})
const session = await auth()
if (!data) {
return null
}
const homeHref = homeHrefs[env.NODE_ENV][lang]
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 user = session ? await serverClient().user.get() : null
if (!user) {
return null
}
switch (component) {
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) {
const data = await serverClient().contentstack.base.contact()
if (!data) {
return null
}
const val = getValueFromContactConfig(contact.contact_field, data)

View File

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

View File

@@ -10,12 +10,3 @@
gap: 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 (
<JoinLoyaltyContact
block={block.join_loyalty_contact}
key={`${block.__typename}-${idx}`}
lang={lang}
key={`${block.join_loyalty_contact.title}-${idx}`}
/>
)
default:

View File

@@ -18,4 +18,4 @@
justify-content: center;
min-height: 280px;
padding: var(--Spacing-x7) var(--Spacing-x3);
}
}

View File

@@ -17,29 +17,6 @@
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 {
display: grid;
gap: 0.8rem;
@@ -121,17 +98,6 @@
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 {
grid-template-columns: 1fr;
grid-template-rows: var(--card-height) 1fr 1fr;
@@ -139,20 +105,11 @@
.circle {
align-items: center;
background-color: var(--some-white-color, #fff);
background-color: var(--Main-Grey-White);
border-radius: 50%;
display: flex;
height: 2rem;
justify-content: center;
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 BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
@@ -25,8 +26,10 @@ export default async function Challenges({
<section className={styles.journeys}>
{journeys.map((journey) => (
<article className={styles.journey} key={journey.title}>
<p className={styles.subtitle}>{journey.tag}</p>
<h4 className={styles.title}>{journey.title}</h4>
<BiroScript color="black">{journey.tag}</BiroScript>
<Title as="h5" level="h4">
{journey.title}
</Title>
</article>
))}
</section>
@@ -48,8 +51,10 @@ export default async function Challenges({
width={12}
/>
</div>
<p className={styles.subtitle}>{victory.tag}</p>
<h4 className={styles.title}>{victory.title}</h4>
<BiroScript color="black">{victory.tag}</BiroScript>
<Title as="h5" level="h4">
{victory.title}
</Title>
</article>
))}
</section>

View File

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

View File

@@ -16,16 +16,19 @@ async function CurrentPointsBalance({
}: AccountPageComponentProps) {
const user = await serverClient().user.get()
const { formatMessage } = await getIntl()
if (!user) {
return null
}
const membership = getMembership(user.memberships)
return (
<div>
<Header title={title} link={link} subtitle={subtitle} />
<div className={styles.card}>
<h2>{`${formatMessage({ id: "Total points" })}*`}</h2>
<p
className={styles.points}
>{`${formatMessage({ id: "Points" })}: ${membership?.currentPoints || "N/A"}`}</p>
<h2>{`${formatMessage({ id: "Total Points" })}*`}</h2>
<p className={styles.points}>
{`${formatMessage({ id: "Points" })}: ${membership?.currentPoints || "N/A"}`}
</p>
<p className={styles.disclaimer}>
{`*${formatMessage({ id: "Points may take up to 10 days to be displayed." })}`}
</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;
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 { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { serverClient } from "@/lib/trpc/server"
import Header from "@/components/SectionHeader"
import ClientEarnAndBurn from "./Client"
import styles from "./earnAndBurn.module.css"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
import { Page, RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
const tableHeadings = [
"Arrival date",
"Description",
"Booking number",
"Transaction date",
"Points",
]
function EarnAndBurn({
export default async function EarnAndBurn({
lang,
title,
subtitle,
link,
subtitle,
title,
}: AccountPageComponentProps) {
const intl = useIntl()
const { data, hasNextPage, fetchNextPage } =
trpc.user.transaction.friendTransactions.useInfiniteQuery(
{ limit: 5 },
{
getNextPageParam: (lastPage: Page) => lastPage.nextCursor,
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
const initialTransactions =
await serverClient().user.transaction.friendTransactions({ limit: 5 })
if (!initialTransactions) {
return null
}
const transactions = data?.pages.flatMap((page) => page.data) ?? []
return (
<div className={styles.container}>
<Header title={title} link={link} subtitle={subtitle} />
<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>
<ClientEarnAndBurn initialData={initialTransactions} lang={lang} />
</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 LoadingSpinner from "@/components/LoadingSpinner"
import Header from "@/components/SectionHeader"
import Grids from "@/components/TempDesignSystem/Grids"
import SectionHeader from "@/components/SectionHeader"
import Container from "../Container"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import EmptyPreviousStaysBlock from "./EmptyPreviousStays"
import ClientPreviousStays from "./Client"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default function PreviousStays({
export default async function PreviousStays({
lang,
title,
subtitle,
link,
}: AccountPageComponentProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.user.stays.previous.useInfiniteQuery(
{},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
const initialPreviousStays = await serverClient().user.stays.previous()
if (!initialPreviousStays?.data) {
return null
}
const stays = data?.pages.flatMap((page) => page.data) ?? []
return (
<Container>
<Header title={title} subtitle={subtitle} link={link} />
{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 />
)}
<SectionHeader title={title} subtitle={subtitle} link={link} />
<ClientPreviousStays
initialPreviousStays={initialPreviousStays}
lang={lang}
/>
</Container>
)
}

View File

@@ -16,14 +16,17 @@ export default async function SoonestStays({
subtitle,
link,
}: 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 (
<section className={styles.container}>
<Header title={title} subtitle={subtitle} link={link} />
{stays.length ? (
{response.data.length ? (
<Grids.Stackable>
{stays.map((stay) => (
{response.data.map((stay) => (
<StayCard
key={stay.attributes.confirmationNumber}
lang={lang}

View File

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

View File

@@ -36,8 +36,4 @@
align-items: center;
display: flex;
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 LoadingSpinner from "@/components/LoadingSpinner"
import Header from "@/components/SectionHeader"
import Grids from "@/components/TempDesignSystem/Grids"
import SectionHeader from "@/components/SectionHeader"
import Container from "../Container"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import ClientUpcomingStays from "./Client"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default function UpcomingStays({
export default async function UpcomingStays({
lang,
title,
subtitle,
link,
}: AccountPageComponentProps) {
const { data, hasNextPage, isFetching, fetchNextPage, isLoading } =
trpc.user.stays.upcoming.useInfiniteQuery(
{ limit: 6 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
const initialUpcomingStays = await serverClient().user.stays.upcoming()
if (!initialUpcomingStays?.data) {
return null
}
const stays = data?.pages.flatMap((page) => page.data) ?? []
return (
<Container>
<Header title={title} subtitle={subtitle} link={link} />
{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 />
)}
<SectionHeader title={title} subtitle={subtitle} link={link} />
<ClientUpcomingStays
initialUpcomingStays={initialUpcomingStays}
lang={lang}
/>
</Container>
)
}

View File

@@ -1,6 +1,6 @@
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 Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -8,6 +8,9 @@ import styles from "./breadcrumbs.module.css"
export default async function Breadcrumbs() {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
if (!breadcrumbs) {
return null
}
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={styles.breadcrumbs}>

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,13 +41,8 @@
gap: var(--Spacing-x1);
}
.scriptedTitle {
span.scriptedTitle {
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);
margin: 0;
transform: rotate(-3deg);
@@ -57,13 +52,8 @@
color: var(--font-color);
}
.bodyText {
p.bodyText {
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 {

View File

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

View File

@@ -23,12 +23,6 @@
.input,
.listBoxItem {
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 {

View File

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

View File

@@ -1,8 +1,4 @@
.message {
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;
}

View File

@@ -1,5 +1,7 @@
import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./error.module.css"
import type { ErrorMessageProps } from "./errorMessage"
@@ -12,7 +14,11 @@ export default function ErrorMessage<T>({
<RHFErrorMessage
errors={errors}
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 ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./input.module.css"
@@ -35,11 +36,13 @@ export default function Input({
onChange={field.onChange}
type={type}
>
<AriaInput
className={styles.input}
placeholder={placeholder}
ref={field.ref}
/>
<Body asChild fontOnly>
<AriaInput
className={styles.input}
placeholder={placeholder}
ref={field.ref}
/>
</Body>
<ErrorMessage errors={formState.errors} name={name} />
</TextField>
)

View File

@@ -2,12 +2,7 @@
border: 2px solid var(--UI-Grey-60);
border-radius: var(--Corner-radius-Small);
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;
letter-spacing: var(--typography-Body-Regular-letterSpacing);
line-height: var(--typography-Body-Regular-lineHeight);
padding: var(--Spacing-x1) var(--Spacing-x2);
width: min(280px, 100%);
}

View File

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

View File

@@ -4,10 +4,6 @@
.label {
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 {
@@ -32,11 +28,6 @@
display: flex;
gap: var(--Spacing-x-half);
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;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
text-align: left;
@@ -56,7 +47,6 @@
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.08);
display: inline-flex;
flex-direction: column;
font-size: var(--typography-Body-Regular-fontSize);
gap: var(--Spacing-x1);
overflow: auto;
width: 100%;
@@ -79,4 +69,4 @@
.listBoxItem[data-selected="true"] {
font-weight: 500;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,3 +27,22 @@ const config = {
} as const
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;
padding: 0;
}
.captionFontOnly {
font-style: normal;
}
.bold {
font-family: var(--typography-Caption-Bold-fontFamily);
font-size: var(--typography-Caption-Bold-fontSize);

View File

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

View File

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

View File

@@ -21,3 +21,20 @@ const config = {
} as const
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;
}
.footnoteFontOnly {
font-style: normal;
}
.bold {
font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize);

View File

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

View File

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

View File

@@ -24,3 +24,24 @@ const config = {
} as const
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 { Children } from "react"
import { subtitleVariants } from "./variants"
@@ -13,7 +14,7 @@ export default function Subtitle({
textTransform,
...props
}: SubtitleProps) {
if (hideEmpty && !props.children) {
if (hideEmpty && Children.count(props.children) === 0) {
return null
}
const Comp = asChild ? Slot : "p"

View File

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

View File

@@ -2,7 +2,7 @@ import { headingVariants } from "./variants"
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
extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">,

View File

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

View File

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

View File

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

View File

@@ -23,8 +23,10 @@
"Description": "Description",
"Edit": "Edit",
"Email": "Email",
"Empty": "Empty",
"Explore all levels and benefits": "Explore all levels and benefits",
"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",
"Go back to overview": "Go back to overview",
"How it works": "How it works",

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import {
GetAccountPageRefs,
} from "@/lib/graphql/Query/AccountPage.graphql"
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 {
@@ -45,15 +45,14 @@ export const accountPageQueryRouter = router({
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 validatedAccountPageRefs =
validateAccountPageRefsSchema.safeParse(cleanedData)
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)
@@ -81,7 +80,9 @@ export const accountPageQueryRouter = router({
)
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

View File

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

View File

@@ -6,7 +6,6 @@ import {
GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs,
} from "@/lib/graphql/Query/BreadcrumbsMyPages.graphql"
import { internalServerError } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import {
@@ -41,7 +40,11 @@ async function getLoyaltyPageBreadcrumbs(variables: Variables) {
)
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)
@@ -53,14 +56,18 @@ async function getLoyaltyPageBreadcrumbs(variables: Variables) {
)
if (!response.data.all_loyalty_page.items[0].web?.breadcrumbs?.title) {
return []
return null
}
const validatedBreadcrumbsData =
validateLoyaltyPageBreadcrumbsContentstackSchema.safeParse(response.data)
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(
@@ -80,7 +87,11 @@ async function getMyPagesBreadcrumbs(variables: Variables) {
refsResponse.data
)
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)
@@ -99,7 +110,11 @@ async function getMyPagesBreadcrumbs(variables: Variables) {
validateMyPagesBreadcrumbsContentstackSchema.safeParse(response.data)
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(

View File

@@ -119,7 +119,11 @@ export const languageSwitcherQueryRouter = router({
validateLanguageSwitcherData.safeParse(urls)
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 {

View File

@@ -3,7 +3,7 @@ import {
GetLoyaltyPageRefs,
} from "@/lib/graphql/Query/LoyaltyPage.graphql"
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 {
@@ -35,9 +35,9 @@ function makeButtonObject(button: any) {
href:
button.is_contentstack_link && button.linkConnection.edges.length
? button.linkConnection.edges[0].node.web?.original_url ||
removeMultipleSlashes(
`/${button.linkConnection.edges[0].node.system.locale}/${button.linkConnection.edges[0].node.url}`
)
removeMultipleSlashes(
`/${button.linkConnection.edges[0].node.system.locale}/${button.linkConnection.edges[0].node.url}`
)
: button.external_link.href,
isExternal: !button.is_contentstack_link,
}
@@ -62,17 +62,16 @@ export const loyaltyPageQueryRouter = router({
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 validatedLoyaltyPageRefs =
validateLoyaltyPageRefsSchema.safeParse(cleanedData)
if (!validatedLoyaltyPageRefs.success) {
console.error("Bad validation for `GetLoyaltyPageRefs`")
console.info(
`Failed to validate Loyaltypage Refs - (lang: ${lang}, uid: ${uid})`
)
console.error(validatedLoyaltyPageRefs.error)
throw internalServerError(validatedLoyaltyPageRefs.error)
return null
}
const connections = getConnections(validatedLoyaltyPageRefs.data)
@@ -97,66 +96,66 @@ export const loyaltyPageQueryRouter = router({
const blocks = response.data.loyalty_page.blocks
? response.data.loyalty_page.blocks.map((block: any) => {
switch (block.__typename) {
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksDynamicContent:
return {
...block,
dynamic_content: {
...block.dynamic_content,
link: block.dynamic_content.link.pageConnection.edges.length
? {
text: block.dynamic_content.link.text,
href: removeMultipleSlashes(
`/${block.dynamic_content.link.pageConnection.edges[0].node.system.locale}/${block.dynamic_content.link.pageConnection.edges[0].node.url}`
),
title:
block.dynamic_content.link.pageConnection.edges[0]
.node.title,
}
: undefined,
},
}
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksShortcuts:
return {
...block,
shortcuts: {
...block.shortcuts,
shortcuts: block.shortcuts.shortcuts.map((shortcut: any) => ({
text: shortcut.text,
openInNewTab: shortcut.open_in_new_tab,
...shortcut.linkConnection.edges[0].node,
url:
shortcut.linkConnection.edges[0].node.web?.original_url ||
removeMultipleSlashes(
`/${shortcut.linkConnection.edges[0].node.system.locale}/${shortcut.linkConnection.edges[0].node.url}`
),
})),
},
}
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksCardsGrid:
return {
...block,
cards_grid: {
...block.cards_grid,
cards: block.cards_grid.cardConnection.edges.map(
({ node: card }: { node: any }) => {
return {
...card,
primaryButton: card.has_primary_button
? makeButtonObject(card.primary_button)
: undefined,
secondaryButton: card.has_secondary_button
? makeButtonObject(card.secondary_button)
: undefined,
}
switch (block.__typename) {
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksDynamicContent:
return {
...block,
dynamic_content: {
...block.dynamic_content,
link: block.dynamic_content.link.pageConnection.edges.length
? {
text: block.dynamic_content.link.text,
href: removeMultipleSlashes(
`/${block.dynamic_content.link.pageConnection.edges[0].node.system.locale}/${block.dynamic_content.link.pageConnection.edges[0].node.url}`
),
title:
block.dynamic_content.link.pageConnection.edges[0]
.node.title,
}
: undefined,
},
}
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksShortcuts:
return {
...block,
shortcuts: {
...block.shortcuts,
shortcuts: block.shortcuts.shortcuts.map((shortcut: any) => ({
text: shortcut.text,
openInNewTab: shortcut.open_in_new_tab,
...shortcut.linkConnection.edges[0].node,
url:
shortcut.linkConnection.edges[0].node.web?.original_url ||
removeMultipleSlashes(
`/${shortcut.linkConnection.edges[0].node.system.locale}/${shortcut.linkConnection.edges[0].node.url}`
),
})),
},
}
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksCardsGrid:
return {
...block,
cards_grid: {
...block.cards_grid,
cards: block.cards_grid.cardConnection.edges.map(
({ node: card }: { node: any }) => {
return {
...card,
primaryButton: card.has_primary_button
? makeButtonObject(card.primary_button)
: undefined,
secondaryButton: card.has_secondary_button
? makeButtonObject(card.secondary_button)
: undefined,
}
),
},
}
default:
return block
}
})
}
),
},
}
default:
return block
}
})
: null
const loyaltyPage = {
@@ -170,7 +169,11 @@ export const loyaltyPageQueryRouter = router({
validateLoyaltyPageSchema.safeParse(loyaltyPage)
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

View File

@@ -3,7 +3,7 @@ import {
GetNavigationMyPagesRefs,
} from "@/lib/graphql/Query/NavigationMyPages.graphql"
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 {
@@ -71,7 +71,11 @@ export const navigationQueryRouter = router({
const validatedMyPagesNavigationRefs =
navigationRefsPayloadSchema.safeParse(refsResponse.data)
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)
@@ -99,7 +103,11 @@ export const navigationQueryRouter = router({
response.data
)
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 =
@@ -112,7 +120,11 @@ export const navigationQueryRouter = router({
const validatedNav = getNavigationSchema.safeParse(nav)
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

View File

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

View File

@@ -104,17 +104,17 @@ export const getFriendTransactionsSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
hotelOperaId: z.string(),
confirmationNumber: z.string(),
checkinDate: z.string(),
checkoutDate: z.string(),
nights: z.number(),
awardPoints: z.number(),
pointsCalculated: z.boolean(),
awardPoints: z.number().default(0),
checkinDate: z.string().default(""),
checkoutDate: z.string().default(""),
confirmationNumber: z.string().default(""),
hotelOperaId: z.string().default(""),
nights: z.number().default(1),
pointsCalculated: z.boolean().default(true),
hotelInformation: z
.object({
hotelName: z.string(),
city: z.string(),
city: z.string().default(""),
name: z.string().default(""),
hotelContent: z.object({
images: z.object({
metaData: z.object({
@@ -135,29 +135,28 @@ export const getFriendTransactionsSchema = z.object({
.optional(),
}),
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
.object({
links: z.object({
related: z.string(),
}),
data: z.object({
id: z.string(),
type: z.string(),
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
})
.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
@@ -169,7 +168,3 @@ export const getFriendTransactionsSchema = z.object({
})
.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 {
badRequestError,
forbiddenError,
internalServerError,
notFound,
unauthorizedError,
} from "@/server/errors/trpc"
import { protectedProcedure, router } from "@/server/trpc"
import { friendTransactionsInput, staysInput } from "./input"
@@ -34,26 +27,36 @@ export const userQueryRouter = router({
})
if (!apiResponse.ok) {
switch (apiResponse.status) {
case 400:
throw badRequestError(apiResponse)
case 401:
throw unauthorizedError(apiResponse)
case 403:
throw forbiddenError(apiResponse)
default:
throw internalServerError(apiResponse)
}
// switch (apiResponse.status) {
// case 400:
// throw badRequestError(apiResponse)
// case 401:
// throw unauthorizedError(apiResponse)
// case 403:
// throw forbiddenError(apiResponse)
// default:
// 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()
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)
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 {
@@ -98,28 +101,35 @@ export const userQueryRouter = router({
)
if (!apiResponse.ok) {
switch (apiResponse.status) {
case 400:
throw badRequestError(apiResponse)
case 401:
throw unauthorizedError(apiResponse)
case 403:
throw forbiddenError(apiResponse)
default:
throw internalServerError(apiResponse)
}
// switch (apiResponse.status) {
// case 400:
// throw badRequestError(apiResponse)
// case 401:
// throw unauthorizedError(apiResponse)
// case 403:
// throw forbiddenError(apiResponse)
// default:
// 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 verifiedData = getStaysSchema.safeParse(apiJson)
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 =
verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount
verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
@@ -152,27 +162,34 @@ export const userQueryRouter = router({
)
if (!apiResponse.ok) {
switch (apiResponse.status) {
case 400:
throw badRequestError(apiResponse)
case 401:
throw unauthorizedError(apiResponse)
case 403:
throw forbiddenError(apiResponse)
default:
throw internalServerError(apiResponse)
}
// switch (apiResponse.status) {
// case 400:
// throw badRequestError(apiResponse)
// case 401:
// throw unauthorizedError(apiResponse)
// case 403:
// throw forbiddenError(apiResponse)
// default:
// 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 verifiedData = getStaysSchema.safeParse(apiJson)
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 =
verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount
verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
@@ -186,76 +203,70 @@ export const userQueryRouter = router({
friendTransactions: protectedProcedure
.input(friendTransactionsInput)
.query(async (opts) => {
try {
const { limit, cursor } = opts.input
const { limit, cursor } = opts.input
const params = new URLSearchParams()
params.set("limit", limit.toString())
const params = new URLSearchParams()
params.set("limit", limit.toString())
if (cursor) {
params.set("offset", cursor.toString())
}
if (cursor) {
params.set("offset", cursor.toString())
}
const apiResponse = await api.get(
api.endpoints.v1.friendTransactions,
{
headers: {
Authorization: `Bearer ${opts.ctx.session.token.access_token}`,
},
const apiResponse = await api.get(
api.endpoints.v1.friendTransactions,
{
headers: {
Authorization: `Bearer ${opts.ctx.session.token.access_token}`,
},
params
)
},
params
)
if (!apiResponse.ok) {
switch (apiResponse.status) {
case 400:
throw badRequestError()
case 401:
throw unauthorizedError()
case 403:
throw forbiddenError()
default:
throw internalServerError()
}
}
if (!apiResponse.ok) {
// switch (apiResponse.status) {
// case 400:
// throw badRequestError()
// case 401:
// throw unauthorizedError()
// case 403:
// throw forbiddenError()
// default:
// 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 = friendTransactionsMockJson
const apiJson = await apiResponse.json()
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) {
// throw internalServerError()
}
const nextCursor =
verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.info(`Get Friend Transactions - Verified Data Error`)
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 }) => ({
return {
data: verifiedData.data.data.map(({ attributes }) => {
return {
awardPoints: attributes.awardPoints,
checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate,
awardPoints: attributes.awardPoints,
hotelName: attributes.hotelInformation?.hotelName,
city: attributes.hotelInformation?.city,
nights: attributes.nights,
confirmationNumber: attributes.confirmationNumber,
})),
nextCursor,
}
} catch (error) {
console.info(`Get Friend Transactions Error`)
console.error(error)
throw internalServerError()
hotelName: attributes.hotelInformation?.name,
nights: attributes.nights,
}
}),
nextCursor,
}
}),
}),

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 = {
lang: Lang
}
type Transaction = {
checkinDate: string
checkoutDate: string
awardPoints: number
hotelName?: string
city?: string
nights: number
confirmationNumber: string
export interface TableProps {
lang: Lang
transactions: Transactions
}
export type Page = {
data: Transaction[]
nextCursor?: number
}
export type RowProps = {
export interface RowProps {
lang: Lang
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"
export type UserQueryRouter = typeof userQueryRouter
export interface UserProps {
user: User
}