Merge branch 'develop' into feat/SW-266-seo-loyalty-pages

This commit is contained in:
Pontus Dreij
2024-08-21 14:11:53 +02:00
87 changed files with 2056 additions and 10893 deletions

View File

@@ -11,6 +11,7 @@ CURITY_CLIENT_SECRET_SERVICE=""
CURITY_CLIENT_ID_USER="" CURITY_CLIENT_ID_USER=""
CURITY_CLIENT_SECRET_USER="" CURITY_CLIENT_SECRET_USER=""
CURITY_ISSUER_USER="https://testlogin.scandichotels.com" CURITY_ISSUER_USER="https://testlogin.scandichotels.com"
CURITY_ISSUER_SERVICE="https://testlogin.scandichotels.com"
CYPRESS_BASE_URL="http://localhost:3000" CYPRESS_BASE_URL="http://localhost:3000"
# See next.config.js for info # See next.config.js for info
DEPLOY_PRIME_URL="http://localhost:3000" DEPLOY_PRIME_URL="http://localhost:3000"

View File

@@ -1,3 +1,4 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import { CreditCard, Delete } from "@/components/Icons" import { CreditCard, Delete } from "@/components/Icons"
@@ -18,6 +19,8 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
const { formatMessage } = await getIntl() const { formatMessage } = await getIntl()
const creditCards = await serverClient().user.creditCards() const creditCards = await serverClient().user.creditCards()
const { lang } = params
return ( return (
<section className={styles.container}> <section className={styles.container}>
<article className={styles.content}> <article className={styles.content}>
@@ -41,7 +44,9 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
))} ))}
</div> </div>
) : null} ) : null}
<AddCreditCardButton /> <AddCreditCardButton
redirectUrl={`${env.PUBLIC_URL}/api/web/add-card-callback/${lang}`}
/>
</section> </section>
) )
} }

View File

@@ -1,4 +1,5 @@
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import HotelCard from "@/components/HotelReservation/HotelCard" import HotelCard from "@/components/HotelReservation/HotelCard"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter" import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
@@ -18,14 +19,12 @@ export default async function SelectHotelPage({
const intl = await getIntl() const intl = await getIntl()
setLang(params.lang) setLang(params.lang)
const { attributes } = await serverClient().hotel.getHotel({ // TODO: Use real endpoint.
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55", const hotel = tempHotelData.data.attributes
language: getLang(), const hotels = [hotel]
})
const hotels = [attributes]
const hotelFilters = await serverClient().hotel.getFilters({ const hotelFilters = await serverClient().hotel.getFilters({
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55", hotelId: "879",
}) })
const tempSearchTerm = "Stockholm" const tempSearchTerm = "Stockholm"
@@ -40,12 +39,7 @@ export default async function SelectHotelPage({
zoomLevel={11} zoomLevel={11}
mapType="roadmap" mapType="roadmap"
/> />
<Link <Link className={styles.link} color="burgundy" href="#">
className={styles.link}
color="burgundy"
variant="underscored"
href="#"
>
{intl.formatMessage({ id: "Show map" })} {intl.formatMessage({ id: "Show map" })}
<ChevronRightIcon color="burgundy" className={styles.icon} /> <ChevronRightIcon color="burgundy" className={styles.icon} />
</Link> </Link>

View File

@@ -1,4 +1,5 @@
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import HotelCard from "@/components/HotelReservation/HotelCard" import HotelCard from "@/components/HotelReservation/HotelCard"
import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection" import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection"
@@ -14,11 +15,9 @@ import { LangParams, PageArgs } from "@/types/params"
export default async function SelectRate({ params }: PageArgs<LangParams>) { export default async function SelectRate({ params }: PageArgs<LangParams>) {
setLang(params.lang) setLang(params.lang)
// TODO: pass the correct hotel ID // TODO: Use real endpoint.
const { attributes: hotel } = await serverClient().hotel.getHotel({ const hotel = tempHotelData.data.attributes
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
language: getLang(),
})
const rooms = await serverClient().hotel.getRates({ const rooms = await serverClient().hotel.getRates({
// TODO: pass the correct hotel ID and all other parameters that should be included in the search // TODO: pass the correct hotel ID and all other parameters that should be included in the search
hotelId: "1", hotelId: "1",

View File

@@ -9,6 +9,7 @@ import TokenRefresher from "@/components/Auth/TokenRefresher"
import AdobeSDKScript from "@/components/Current/AdobeSDKScript" import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
import Footer from "@/components/Current/Footer" import Footer from "@/components/Current/Footer"
import VwoScript from "@/components/Current/VwoScript" import VwoScript from "@/components/Current/VwoScript"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
import { preloadUserTracking } from "@/components/TrackingSDK" import { preloadUserTracking } from "@/components/TrackingSDK"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import ServerIntlProvider from "@/i18n/Provider" import ServerIntlProvider from "@/i18n/Provider"
@@ -55,6 +56,7 @@ export default async function RootLayout({
<TrpcProvider> <TrpcProvider>
{header} {header}
{children} {children}
<ToastHandler />
<Footer /> <Footer />
<TokenRefresher /> <TokenRefresher />
</TrpcProvider> </TrpcProvider>

View File

@@ -1,3 +1,4 @@
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation" import { notFound, redirect } from "next/navigation"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
@@ -28,7 +29,11 @@ export default async function ContentTypePage({
case "unauthorized": // fall through case "unauthorized": // fall through
case "forbidden": // fall through case "forbidden": // fall through
case "token_expired": case "token_expired":
redirect(`/${getLang()}/webview/refresh`) const h = headers()
const returnURL = `/${getLang()}/webview${h.get("x-pathname")!}`
redirect(
`/${getLang()}/webview/refresh?returnUrl=${encodeURIComponent(returnURL)}`
)
} }
} }

View File

@@ -0,0 +1,47 @@
import { NextRequest } from "next/server"
import { env } from "process"
import { Lang } from "@/constants/languages"
import { serverClient } from "@/lib/trpc/server"
import { badRequest, internalServerError } from "@/server/errors/next"
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string } }
) {
try {
const lang = params.lang as Lang
const searchParams = request.nextUrl.searchParams
const success = searchParams.get("success")
const failure = searchParams.get("failure")
const trxId = searchParams.get("datatransTrxId")
const returnUrl = new URL(
`${env.PUBLIC_URL}/${lang ?? Lang.en}/scandic-friends/my-pages/profile`
)
if (success) {
if (!trxId) {
return badRequest("Missing datatransTrxId param")
}
const saveCardSuccess = await serverClient().user.saveCard({
transactionId: trxId,
})
if (saveCardSuccess) {
returnUrl.searchParams.set("success", "true")
} else {
returnUrl.searchParams.set("failure", "true")
}
} else if (failure) {
returnUrl.searchParams.set("failure", "true")
}
return Response.redirect(returnUrl, 307)
} catch (error) {
console.error(error)
return internalServerError()
}
}

View File

@@ -1,21 +1,10 @@
.container { .container {
display: none; border-top: 1px solid var(--Base-Border-Subtle);
border-bottom: 1px solid var(--Base-Border-Subtle);
} }
/** @media screen and (max-width: 1367px) {
* Update the styles after mobile UX is ready
*/
@media screen and (min-width: 1367px) {
.container { .container {
display: grid; display: none;
padding: 0 var(--Spacing-x5);
gap: var(--Spacing-x3);
}
.form {
display: grid;
gap: var(--Spacing-x5);
grid-template-columns: repeat(6, auto);
align-items: center;
} }
} }

View File

@@ -1,62 +1,11 @@
"use client" import Form from "../Forms/BookingWidget"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { dt } from "@/lib/dt"
import Button from "../TempDesignSystem/Button"
import { bookingWidgetSchema } from "./schema"
import styles from "./bookingWidget.module.css" import styles from "./bookingWidget.module.css"
import { type BookingWidgetSchema } from "@/types/components/bookingWidget"
export function BookingWidget() { export function BookingWidget() {
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: {
stayType: "",
stayValue: "",
},
nights: {
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
// This is specifically to handle timezones falling in different dates.
fromDate: dt().utc().format("DD/MM/YYYY"),
toDate: dt().utc().add(1, "day").format("DD/MM/YYYY"),
},
bookingCode: "",
redemption: false,
voucher: false,
rooms: [
{
adults: 1,
childs: [],
},
],
},
mode: "all",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onChange",
})
function onSubmit(data: BookingWidgetSchema) {
console.log(data)
// Parse data and route accordignly to Select hotel or select room-rate page
console.log("to be routing")
}
return ( return (
<div id="booking-widget" className={styles.container}> <section className={styles.container}>
<form onSubmit={methods.handleSubmit(onSubmit)} className={styles.form}> <Form />
<FormProvider {...methods}> </section>
<div>Search</div>
<div>Nights</div>
<div>Rooms</div>
<div>Bonus code</div>
<div>Bonus cheque or reward nights</div>
<Button type="submit">Search</Button>
</FormProvider>
</form>
</div>
) )
} }

View File

@@ -18,11 +18,16 @@ export default async function HotelPage() {
return null return null
} }
const lang = getLang() const lang = getLang()
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
const hotelData = await serverClient().hotel.getHotel({
hotelId: hotelPageIdentifierData.hotel_page_id, hotelId: hotelPageIdentifierData.hotel_page_id,
language: lang, language: lang,
include: ["RoomCategories"], include: ["RoomCategories"],
}) })
if (!hotelData) {
return null
}
const { hotel, roomCategories } = hotelData
return ( return (
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
@@ -30,14 +35,14 @@ export default async function HotelPage() {
<main className={styles.mainSection}> <main className={styles.mainSection}>
<div className={styles.introContainer}> <div className={styles.introContainer}>
<IntroSection <IntroSection
hotelName={attributes.name} hotelName={hotel.name}
hotelDescription={attributes.hotelContent.texts.descriptions.short} hotelDescription={hotel.hotelContent.texts.descriptions.short}
location={attributes.location} location={hotel.location}
address={attributes.address} address={hotel.address}
tripAdvisor={attributes.ratings.tripAdvisor} tripAdvisor={hotel.ratings?.tripAdvisor}
/> />
<SidePeeks /> <SidePeeks />
<AmenitiesList detailedFacilities={attributes.detailedFacilities} /> <AmenitiesList detailedFacilities={hotel.detailedFacilities} />
</div> </div>
<Rooms rooms={roomCategories} /> <Rooms rooms={roomCategories} />
</main> </main>

View File

@@ -30,10 +30,17 @@ export default async function IntroSection({
) )
const lang = getLang() const lang = getLang()
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})` const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
const formattedTripAdvisorText = intl.formatMessage( const hasTripAdvisorData = !!(
{ id: "Tripadvisor reviews" }, tripAdvisor?.rating &&
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews } tripAdvisor?.numberOfReviews &&
tripAdvisor?.webUrl
) )
const formattedTripAdvisorText = hasTripAdvisorData
? intl.formatMessage(
{ id: "Tripadvisor reviews" },
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
)
: ""
return ( return (
<section className={styles.introSection}> <section className={styles.introSection}>
@@ -45,17 +52,19 @@ export default async function IntroSection({
<Title level="h2">{hotelName}</Title> <Title level="h2">{hotelName}</Title>
</div> </div>
<Body color="textMediumContrast">{formattedLocationText}</Body> <Body color="textMediumContrast">{formattedLocationText}</Body>
<Link {hasTripAdvisorData && (
className={styles.introLink} <Link
target="_blank" className={styles.introLink}
variant="icon" target="_blank"
textDecoration="underline" variant="icon"
color="peach80" textDecoration="underline"
href={tripAdvisor.webUrl} color="peach80"
> href={tripAdvisor.webUrl}
<TripAdvisorIcon color="peach80" /> >
{formattedTripAdvisorText} <TripAdvisorIcon color="peach80" />
</Link> {formattedTripAdvisorText}
</Link>
)}
</div> </div>
<div className={styles.subtitleContent}> <div className={styles.subtitleContent}>
<Preamble>{hotelDescription}</Preamble> <Preamble>{hotelDescription}</Preamble>

View File

@@ -19,7 +19,7 @@ export function RoomCard({
subtitle, subtitle,
title, title,
}: RoomCardProps) { }: RoomCardProps) {
const { formatMessage } = useIntl() const intl = useIntl()
const mainImage = images[0] const mainImage = images[0]
function handleImageClick() { function handleImageClick() {
@@ -35,11 +35,12 @@ export function RoomCard({
return ( return (
<article className={styles.roomCard}> <article className={styles.roomCard}>
<button className={styles.imageWrapper} onClick={handleImageClick}> <button className={styles.imageWrapper} onClick={handleImageClick}>
{badgeTextTransKey && ( {/* TODO: re-enable once we have support for badge text from API team. */}
<span className={styles.badge}> {/* {badgeTextTransKey && ( */}
{formatMessage({ id: badgeTextTransKey })} {/* <span className={styles.badge}> */}
</span> {/* {intl.formatMessage({ id: badgeTextTransKey })} */}
)} {/* </span> */}
{/* )} */}
<span className={styles.imageCount}> <span className={styles.imageCount}>
<ImageIcon color="white" /> <ImageIcon color="white" />
{images.length} {images.length}
@@ -67,7 +68,7 @@ export function RoomCard({
variant="underscored" variant="underscored"
onClick={handleRoomCtaClick} onClick={handleRoomCtaClick}
> >
{formatMessage({ id: "hotelPages.rooms.roomCard.seeRoomDetails" })} {intl.formatMessage({ id: "See room details" })}
</Link> </Link>
</div> </div>
</article> </article>

View File

@@ -10,7 +10,7 @@ import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
export default function TabNavigation() { export default function TabNavigation() {
const hash = useHash() const hash = useHash()
const { formatMessage } = useIntl() const intl = useIntl()
const hotelTabLinks: { href: HotelHashValues; text: string }[] = [ const hotelTabLinks: { href: HotelHashValues; text: string }[] = [
{ href: HotelHashValues.overview, text: "Overview" }, { href: HotelHashValues.overview, text: "Overview" },
{ href: HotelHashValues.rooms, text: "Rooms" }, { href: HotelHashValues.rooms, text: "Rooms" },
@@ -35,7 +35,7 @@ export default function TabNavigation() {
color="burgundy" color="burgundy"
textDecoration="none" textDecoration="none"
> >
{formatMessage({ id: link.text })} {intl.formatMessage({ id: link.text })}
</Link> </Link>
) )
})} })}

View File

@@ -13,6 +13,7 @@ const facilityToIconMap: { [key: string]: IconName } = {
"Meeting rooms": IconName.People2, "Meeting rooms": IconName.People2,
"Meeting / conference facilities": IconName.People2, "Meeting / conference facilities": IconName.People2,
"Pet-friendly rooms": IconName.Pets, "Pet-friendly rooms": IconName.Pets,
Sauna: IconName.Sauna,
Restaurant: IconName.Restaurant, Restaurant: IconName.Restaurant,
} }

View File

@@ -28,7 +28,7 @@ export default async function Header({
/** /**
* ToDo: Create logic to get this info from ContentStack based on page * ToDo: Create logic to get this info from ContentStack based on page
* */ * */
const hideBookingWidget = true const hideBookingWidget = false
if (!data) { if (!data) {
return null return null

View File

@@ -0,0 +1,40 @@
.input {
display: flex;
gap: var(--Spacing-x2);
}
.input input[type="text"] {
border: none;
padding: var(--Spacing-x1) var(--Spacing-x0);
}
.where {
width: 100%;
max-width: 280px;
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
}
.when,
.rooms {
width: 100%;
max-width: 240px;
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
}
.vouchers {
width: 100%;
max-width: 200px;
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
}
.options {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
max-width: 158px;
}
.option {
display: flex;
}

View File

@@ -0,0 +1,54 @@
"use client"
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./formContent.module.css"
export default function FormContent() {
const intl = useIntl()
const where = intl.formatMessage({ id: "Where to" })
const when = intl.formatMessage({ id: "When" })
const rooms = intl.formatMessage({ id: "Rooms & Guests" })
const vouchers = intl.formatMessage({ id: "Booking codes and vouchers" })
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
const reward = intl.formatMessage({ id: "Book reward night" })
return (
<div className={styles.input}>
<div className={styles.where}>
<Caption color="red">{where}</Caption>
<input type="text" placeholder={where} />
</div>
<div className={styles.when}>
<Caption color="red" textTransform="bold">
{when}
</Caption>
<input type="text" placeholder={when} />
</div>
<div className={styles.rooms}>
<Caption color="red" textTransform="bold">
{rooms}
</Caption>
<input type="text" placeholder={rooms} />
</div>
<div className={styles.vouchers}>
<Caption color="textMediumContrast" textTransform="bold">
{vouchers}
</Caption>
<input type="text" placeholder={vouchers} />
</div>
<div className={styles.options}>
<div className={styles.option}>
<input type="checkbox" />
<Caption color="textMediumContrast">{bonus}</Caption>
</div>
<div className={styles.option}>
<input type="checkbox" />
<Caption color="textMediumContrast">{reward}</Caption>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
.section {
display: flex;
align-items: center;
max-width: 1432px;
padding: var(--Spacing-x2) var(--Spacing-x5);
}
.form {
width: 100%;
}
.button {
width: 118px;
justify-content: center;
}

View File

@@ -0,0 +1,80 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import FormContent from "./FormContent"
import { bookingWidgetSchema } from "./schema"
import styles from "./form.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
const formId = "booking-widget"
export default function Form() {
const intl = useIntl()
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: {
stayType: "",
stayValue: "",
},
nights: {
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
// This is specifically to handle timezones falling in different dates.
fromDate: dt().utc().format("DD/MM/YYYY"),
toDate: dt().utc().add(1, "day").format("DD/MM/YYYY"),
},
bookingCode: "",
redemption: false,
voucher: false,
rooms: [
{
adults: 1,
childs: [],
},
],
},
mode: "all",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onChange",
})
function onSubmit(data: BookingWidgetSchema) {
console.log(data)
// Parse data and route accordignly to Select hotel or select room-rate page
console.log("to be routing")
}
return (
<section className={styles.section}>
<form
onSubmit={methods.handleSubmit(onSubmit)}
className={styles.form}
id={formId}
>
<FormProvider {...methods}>
<FormContent />
</FormProvider>
</form>
<Button
type="submit"
form={formId}
size="small"
theme="base"
intent="primary"
className={styles.button}
>
<Caption color="white" textTransform="bold">
{intl.formatMessage({ id: "Find hotels" })}
</Caption>
</Button>
</section>
)
}

View File

@@ -0,0 +1,40 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function CloseLargeIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_1756_2612"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_1756_2612)">
<path
d="M12 13.5422L6.34057 19.2016C6.12719 19.415 5.87017 19.5193 5.5695 19.5144C5.26882 19.5096 5.0118 19.4004 4.79842 19.1871C4.59474 18.9737 4.49532 18.7191 4.50017 18.4233C4.50502 18.1274 4.60928 17.8777 4.81297 17.674L10.4578 12L4.81297 6.32606C4.60928 6.12237 4.50744 5.87262 4.50744 5.5768C4.50744 5.28098 4.60928 5.02638 4.81297 4.813C5.01665 4.59961 5.26882 4.4905 5.5695 4.48565C5.87017 4.4808 6.12719 4.58507 6.34057 4.79845L12 10.4579L17.6594 4.79845C17.8728 4.58507 18.1298 4.4808 18.4305 4.48565C18.7312 4.4905 18.9882 4.59961 19.2016 4.813C19.4053 5.02638 19.5047 5.28098 19.4998 5.5768C19.495 5.87262 19.3907 6.12237 19.187 6.32606L13.5422 12L19.187 17.674C19.3907 17.8777 19.4926 18.1274 19.4926 18.4233C19.4926 18.7191 19.3907 18.9737 19.187 19.1871C18.9834 19.4004 18.7312 19.5096 18.4305 19.5144C18.1298 19.5193 17.8728 19.415 17.6594 19.2016L12 13.5422Z"
fill="#57514E"
/>
</g>
</svg>
)
}

View File

@@ -0,0 +1,40 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function CrossCircleIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_1756_2637"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_1756_2637)">
<path
d="M12 13.3L14.9 16.2C15.075 16.375 15.2917 16.4625 15.55 16.4625C15.8083 16.4625 16.025 16.375 16.2 16.2C16.375 16.025 16.4625 15.8083 16.4625 15.55C16.4625 15.2917 16.375 15.075 16.2 14.9L13.3 12L16.2 9.1C16.375 8.925 16.4625 8.70833 16.4625 8.45C16.4625 8.19167 16.375 7.975 16.2 7.8C16.025 7.625 15.8083 7.5375 15.55 7.5375C15.2917 7.5375 15.075 7.625 14.9 7.8L12 10.7L9.1 7.8C8.925 7.625 8.70833 7.5375 8.45 7.5375C8.19167 7.5375 7.975 7.625 7.8 7.8C7.625 7.975 7.5375 8.19167 7.5375 8.45C7.5375 8.70833 7.625 8.925 7.8 9.1L10.7 12L7.8 14.9C7.625 15.075 7.5375 15.2917 7.5375 15.55C7.5375 15.8083 7.625 16.025 7.8 16.2C7.975 16.375 8.19167 16.4625 8.45 16.4625C8.70833 16.4625 8.925 16.375 9.1 16.2L12 13.3ZM12 21.75C10.6516 21.75 9.38434 21.4936 8.19838 20.9809C7.01239 20.4682 5.98075 19.7724 5.10345 18.8934C4.22615 18.0145 3.53125 16.9826 3.01875 15.7978C2.50625 14.613 2.25 13.3471 2.25 12C2.25 10.6516 2.50636 9.38434 3.01908 8.19838C3.53179 7.01239 4.22762 5.98075 5.10658 5.10345C5.98553 4.22615 7.01739 3.53125 8.20218 3.01875C9.38698 2.50625 10.6529 2.25 12 2.25C13.3484 2.25 14.6157 2.50636 15.8016 3.01908C16.9876 3.53179 18.0193 4.22762 18.8966 5.10658C19.7739 5.98553 20.4688 7.01739 20.9813 8.20217C21.4938 9.38697 21.75 10.6529 21.75 12C21.75 13.3484 21.4936 14.6157 20.9809 15.8016C20.4682 16.9876 19.7724 18.0193 18.8934 18.8966C18.0145 19.7739 16.9826 20.4688 15.7978 20.9813C14.613 21.4938 13.3471 21.75 12 21.75Z"
fill="white"
/>
</g>
</svg>
)
}

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function SaunaIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_83_359"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_83_359)">
<path
d="M4 22C3.45 22 2.97917 21.8042 2.5875 21.4125C2.19583 21.0208 2 20.55 2 20V4C2 3.45 2.19583 2.97917 2.5875 2.5875C2.97917 2.19583 3.45 2 4 2H20C20.55 2 21.0208 2.19583 21.4125 2.5875C21.8042 2.97917 22 3.45 22 4V20C22 20.55 21.8042 21.0208 21.4125 21.4125C21.0208 21.8042 20.55 22 20 22H4ZM4 16V20H6V18H5V16H4ZM4 14H5C5 13.7167 5.09583 13.4792 5.2875 13.2875C5.47917 13.0958 5.71667 13 6 13V10.5C6 10.0833 6.14583 9.72917 6.4375 9.4375C6.72917 9.14583 7.08333 9 7.5 9H9.5C9.91667 9 10.2708 9.14583 10.5625 9.4375C10.8542 9.72917 11 10.0833 11 10.5V13C11.2833 13 11.5208 13.0958 11.7125 13.2875C11.9042 13.4792 12 13.7167 12 14H20V4H4V14ZM6.5 16.5H10.5V14.5H6.5V16.5ZM8.5 8C8.08333 8 7.72917 7.85417 7.4375 7.5625C7.14583 7.27083 7 6.91667 7 6.5C7 6.08333 7.14583 5.72917 7.4375 5.4375C7.72917 5.14583 8.08333 5 8.5 5C8.91667 5 9.27083 5.14583 9.5625 5.4375C9.85417 5.72917 10 6.08333 10 6.5C10 6.91667 9.85417 7.27083 9.5625 7.5625C9.27083 7.85417 8.91667 8 8.5 8ZM8 20H9V18H8V20ZM11 20H20V16H12V18H11V20ZM12.25 10C12.2833 9.78333 12.3042 9.62083 12.3125 9.5125C12.3208 9.40417 12.325 9.29167 12.325 9.175C12.325 8.99167 12.2875 8.81667 12.2125 8.65C12.1375 8.48333 11.9833 8.25833 11.75 7.975C11.5 7.65833 11.3125 7.34167 11.1875 7.025C11.0625 6.70833 11 6.38333 11 6.05C11 5.91667 11.0083 5.77917 11.025 5.6375L11.1 5H12.6C12.5667 5.18333 12.5417 5.35417 12.525 5.5125C12.5083 5.67083 12.5 5.85 12.5 6.05C12.5 6.23333 12.5375 6.4125 12.6125 6.5875C12.6875 6.7625 12.8167 6.95 13 7.15C13.3 7.51667 13.5167 7.8625 13.65 8.1875C13.7833 8.5125 13.85 8.84167 13.85 9.175C13.85 9.35833 13.8417 9.50833 13.825 9.625C13.8083 9.74167 13.7833 9.86667 13.75 10H12.25ZM14.75 10C14.7833 9.78333 14.8042 9.62083 14.8125 9.5125C14.8208 9.40417 14.825 9.29167 14.825 9.175C14.825 8.99167 14.7875 8.81667 14.7125 8.65C14.6375 8.48333 14.4833 8.25833 14.25 7.975C14 7.65833 13.8125 7.34167 13.6875 7.025C13.5625 6.70833 13.5 6.38333 13.5 6.05C13.5 5.91667 13.5083 5.77917 13.525 5.6375L13.6 5H15.1C15.0667 5.18333 15.0417 5.35417 15.025 5.5125C15.0083 5.67083 15 5.85 15 6.05C15 6.23333 15.0375 6.4125 15.1125 6.5875C15.1875 6.7625 15.3167 6.95 15.5 7.15C15.8 7.51667 16.0167 7.8625 16.15 8.1875C16.2833 8.5125 16.35 8.84167 16.35 9.175C16.35 9.35833 16.3417 9.50833 16.325 9.625C16.3083 9.74167 16.2833 9.86667 16.25 10H14.75ZM17.3 10C17.3333 9.78333 17.3542 9.62083 17.3625 9.5125C17.3708 9.40417 17.375 9.29167 17.375 9.175C17.375 8.99167 17.3375 8.81667 17.2625 8.65C17.1875 8.48333 17.0333 8.25833 16.8 7.975C16.55 7.65833 16.3625 7.34167 16.2375 7.025C16.1125 6.70833 16.05 6.38333 16.05 6.05C16.05 5.91667 16.0583 5.77917 16.075 5.6375L16.15 5H17.65C17.6167 5.18333 17.5917 5.35417 17.575 5.5125C17.5583 5.67083 17.55 5.85 17.55 6.05C17.55 6.23333 17.5875 6.4125 17.6625 6.5875C17.7375 6.7625 17.8667 6.95 18.05 7.15C18.35 7.51667 18.5667 7.8625 18.7 8.1875C18.8333 8.5125 18.9 8.84167 18.9 9.175C18.9 9.35833 18.8917 9.50833 18.875 9.625C18.8583 9.74167 18.8333 9.86667 18.8 10H17.3Z"
fill="#26201E"
/>
</g>
</svg>
)
}

View File

@@ -0,0 +1,40 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function WarningTriangleIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_1756_2606"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_1756_2606)">
<path
d="M2.95563 20.775C2.7768 20.775 2.61564 20.7315 2.47216 20.6444C2.32869 20.5573 2.2171 20.4425 2.13738 20.3C2.05405 20.1583 2.0103 20.0063 2.00613 19.8438C2.00196 19.6813 2.04571 19.5208 2.13738 19.3625L11.1874 3.75001C11.279 3.59167 11.3986 3.47501 11.546 3.40001C11.6934 3.32501 11.8447 3.28751 11.9999 3.28751C12.155 3.28751 12.3063 3.32501 12.4538 3.40001C12.6012 3.47501 12.7207 3.59167 12.8124 3.75001L21.8624 19.3625C21.954 19.5208 21.9978 19.6813 21.9936 19.8438C21.9895 20.0063 21.9457 20.1583 21.8624 20.3C21.779 20.4417 21.6663 20.5563 21.524 20.6438C21.3818 20.7313 21.2237 20.775 21.0499 20.775H2.95563ZM11.9973 17.875C12.2657 17.875 12.4915 17.7842 12.6749 17.6026C12.8582 17.4211 12.9499 17.1961 12.9499 16.9276C12.9499 16.6592 12.8591 16.4333 12.6775 16.25C12.4959 16.0667 12.2709 15.975 12.0025 15.975C11.7341 15.975 11.5082 16.0658 11.3249 16.2474C11.1415 16.4289 11.0499 16.6539 11.0499 16.9224C11.0499 17.1908 11.1407 17.4167 11.3223 17.6C11.5038 17.7833 11.7288 17.875 11.9973 17.875ZM12.0124 15C12.2707 15 12.4915 14.9083 12.6749 14.725C12.8582 14.5417 12.9499 14.3208 12.9499 14.0625V11.0125C12.9499 10.7542 12.8582 10.5333 12.6749 10.35C12.4915 10.1667 12.2707 10.075 12.0124 10.075C11.754 10.075 11.5332 10.1667 11.3499 10.35C11.1665 10.5333 11.0749 10.7542 11.0749 11.0125V14.0625C11.0749 14.3208 11.1665 14.5417 11.3499 14.725C11.5332 14.9083 11.754 15 12.0124 15Z"
fill="white"
/>
</g>
</svg>
)
}

View File

@@ -14,8 +14,10 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon, ChevronRightIcon,
CloseIcon, CloseIcon,
CloseLarge,
CoffeeIcon, CoffeeIcon,
ConciergeIcon, ConciergeIcon,
CrossCircle,
DoorOpenIcon, DoorOpenIcon,
ElectricBikeIcon, ElectricBikeIcon,
EmailIcon, EmailIcon,
@@ -33,7 +35,9 @@ import {
PhoneIcon, PhoneIcon,
PlusCircleIcon, PlusCircleIcon,
RestaurantIcon, RestaurantIcon,
SaunaIcon,
TshirtWashIcon, TshirtWashIcon,
WarningTriangle,
WifiIcon, WifiIcon,
} from "." } from "."
@@ -59,6 +63,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return CellphoneIcon return CellphoneIcon
case IconName.Check: case IconName.Check:
return CheckIcon return CheckIcon
case IconName.CrossCircle:
return CrossCircle
case IconName.CheckCircle: case IconName.CheckCircle:
return CheckCircleIcon return CheckCircleIcon
case IconName.ChevronDown: case IconName.ChevronDown:
@@ -67,6 +73,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return ChevronRightIcon return ChevronRightIcon
case IconName.Close: case IconName.Close:
return CloseIcon return CloseIcon
case IconName.CloseLarge:
return CloseLarge
case IconName.Coffee: case IconName.Coffee:
return CoffeeIcon return CoffeeIcon
case IconName.Concierge: case IconName.Concierge:
@@ -105,8 +113,12 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return PlusCircleIcon return PlusCircleIcon
case IconName.Restaurant: case IconName.Restaurant:
return RestaurantIcon return RestaurantIcon
case IconName.Sauna:
return SaunaIcon
case IconName.TshirtWash: case IconName.TshirtWash:
return TshirtWashIcon return TshirtWashIcon
case IconName.WarningTriangle:
return WarningTriangle
case IconName.Wifi: case IconName.Wifi:
return WifiIcon return WifiIcon
default: default:

View File

@@ -44,5 +44,5 @@
.white, .white,
.white * { .white * {
fill: var(--Scandic-Opacity-White-100); fill: var(--UI-Opacity-White-100);
} }

View File

@@ -11,9 +11,11 @@ export { default as CheckCircleIcon } from "./CheckCircle"
export { default as ChevronDownIcon } from "./ChevronDown" export { default as ChevronDownIcon } from "./ChevronDown"
export { default as ChevronRightIcon } from "./ChevronRight" export { default as ChevronRightIcon } from "./ChevronRight"
export { default as CloseIcon } from "./Close" export { default as CloseIcon } from "./Close"
export { default as CloseLarge } from "./CloseLarge"
export { default as CoffeeIcon } from "./Coffee" export { default as CoffeeIcon } from "./Coffee"
export { default as ConciergeIcon } from "./Concierge" export { default as ConciergeIcon } from "./Concierge"
export { default as CreditCard } from "./CreditCard" export { default as CreditCard } from "./CreditCard"
export { default as CrossCircle } from "./CrossCircle"
export { default as Delete } from "./Delete" export { default as Delete } from "./Delete"
export { default as DoorOpenIcon } from "./DoorOpen" export { default as DoorOpenIcon } from "./DoorOpen"
export { default as ElectricBikeIcon } from "./ElectricBike" export { default as ElectricBikeIcon } from "./ElectricBike"
@@ -32,6 +34,8 @@ export { default as PetsIcon } from "./Pets"
export { default as PhoneIcon } from "./Phone" export { default as PhoneIcon } from "./Phone"
export { default as PlusCircleIcon } from "./PlusCircle" export { default as PlusCircleIcon } from "./PlusCircle"
export { default as RestaurantIcon } from "./Restaurant" export { default as RestaurantIcon } from "./Restaurant"
export { default as SaunaIcon } from "./Sauna"
export { default as ScandicLogoIcon } from "./ScandicLogo" export { default as ScandicLogoIcon } from "./ScandicLogo"
export { default as TshirtWashIcon } from "./TshirtWash" export { default as TshirtWashIcon } from "./TshirtWash"
export { default as WarningTriangle } from "./WarningTriangle"
export { default as WifiIcon } from "./Wifi" export { default as WifiIcon } from "./Wifi"

View File

@@ -13,7 +13,7 @@
"title": "10 % rabatt på mat under helger" "title": "10 % rabatt på mat under helger"
}, },
{ {
"title": "Kostnadsfri mocktail för barn under vistelse" "title": "Fri mocktail för barn under vistelse"
} }
] ]
}, },

View File

@@ -1,76 +0,0 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
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 async function DesktopTable({ transactions }: TableProps) {
const { formatMessage } = await getIntl()
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}>
<Body textTransform="bold">
{formatMessage({ id: heading })}
</Body>
</th>
))}
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<Row
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,55 @@
"use client"
import { keepPreviousData } from "@tanstack/react-query"
import { useState } from "react"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import DesktopTable from "./Desktop"
import MobileTable from "./Mobile"
import Pagination from "./Pagination"
import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
export default function TransactionTable({
initialJourneyTransactions,
}: {
initialJourneyTransactions: {
data: { transactions: Transactions }
meta: { totalPages: number }
}
}) {
const limit = 5
const [page, setPage] = useState(1)
const { data, isFetching, isLoading } =
trpc.user.transaction.friendTransactions.useQuery(
{
limit,
page,
},
{
// TODO: fix the initial data issues on page load
// initialData: initialJourneyTransactions,
placeholderData: keepPreviousData,
}
)
return isLoading ? (
<LoadingSpinner />
) : (
<>
<MobileTable transactions={data?.data.transactions || []} />
<DesktopTable transactions={data?.data.transactions || []} />
{data && data.meta.totalPages > 1 ? (
<Pagination
handlePageChange={setPage}
pageCount={data.meta.totalPages}
isFetching={isFetching}
currentPage={page}
/>
) : null}
</>
)
}

View File

@@ -1,7 +1,10 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { getIntl } from "@/i18n" import useLang from "@/hooks/useLang"
import { getLang } from "@/i18n/serverContext"
import AwardPoints from "./AwardPoints" import AwardPoints from "./AwardPoints"
@@ -9,17 +12,16 @@ import styles from "./row.module.css"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn" import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default async function Row({ transaction }: RowProps) { export default function Row({ transaction }: RowProps) {
const { formatMessage } = await getIntl() const intl = useIntl()
const lang = useLang()
const description = const description =
transaction.hotelName && transaction.city transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${transaction.nights} ${formatMessage({ id: "nights" })}` ? `${transaction.hotelName}, ${transaction.city} ${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
: `${transaction.nights} ${formatMessage({ id: "nights" })}` : `${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
const arrival = dt(transaction.checkinDate) const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
.locale(getLang())
.format("DD MMM YYYY")
const departure = dt(transaction.checkoutDate) const departure = dt(transaction.checkoutDate)
.locale(getLang()) .locale(lang)
.format("DD MMM YYYY") .format("DD MMM YYYY")
return ( return (
<tr className={styles.tr}> <tr className={styles.tr}>

View File

@@ -28,6 +28,25 @@
background-color: #fff; background-color: #fff;
} }
.footer {
background-color: var(--Scandic-Brand-Pale-Peach);
border-left: 1px solid var(--Scandic-Brand-Pale-Peach);
border-right: 1px solid var(--Scandic-Brand-Pale-Peach);
display: flex;
padding: 20px 32px;
justify-content: center;
}
.loadMoreButton {
border: none;
background-color: transparent;
color: var(--Main-Brand-Burgundy);
font-size: var(--typography-Caption-Bold-Desktop-fontSize);
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
cursor: pointer;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.container { .container {
display: flex; display: flex;

View File

@@ -0,0 +1,70 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
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({ transactions }: TableProps) {
const intl = useIntl()
return (
<div className={styles.container}>
{transactions.length ? (
<div>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
<Body textTransform="bold">
{intl.formatMessage({ id: heading })}
</Body>
</th>
))}
</tr>
</thead>
<tbody>
{transactions.map((transaction, idx) => (
<Row
key={`${transaction.confirmationNumber}-${idx}`}
transaction={transaction}
/>
))}
</tbody>
</table>
</div>
) : (
<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>
)
}

View File

@@ -1,16 +1,18 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/Desktop/Row/AwardPoints" import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Desktop/Row/AwardPoints"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n" import useLang from "@/hooks/useLang"
import { getLang } from "@/i18n/serverContext"
import styles from "./mobile.module.css" import styles from "./mobile.module.css"
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn" import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default async function MobileTable({ transactions }: TableProps) { export default function MobileTable({ transactions }: TableProps) {
const { formatMessage } = await getIntl() const intl = useIntl()
const lang = useLang()
return ( return (
<div className={styles.container}> <div className={styles.container}>
<table className={styles.table}> <table className={styles.table}>
@@ -18,29 +20,34 @@ export default async function MobileTable({ transactions }: TableProps) {
<tr> <tr>
<Body asChild> <Body asChild>
<th className={styles.th}> <th className={styles.th}>
{formatMessage({ id: "Transactions" })} {intl.formatMessage({ id: "Transactions" })}
</th> </th>
</Body> </Body>
<Body asChild> <Body asChild>
<th className={styles.th}>{formatMessage({ id: "Points" })}</th> <th className={styles.th}>
{intl.formatMessage({ id: "Points" })}
</th>
</Body> </Body>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{transactions.length ? ( {transactions.length ? (
transactions.map((transaction) => ( transactions.map((transaction, idx) => (
<tr className={styles.tr} key={transaction.confirmationNumber}> <tr
className={styles.tr}
key={`${transaction.confirmationNumber}-${idx}`}
>
<td className={`${styles.td} ${styles.transactionDetails}`}> <td className={`${styles.td} ${styles.transactionDetails}`}>
<span className={styles.transactionDate}> <span className={styles.transactionDate}>
{dt(transaction.checkinDate) {dt(transaction.checkinDate)
.locale(getLang()) .locale(lang)
.format("DD MMM YYYY")} .format("DD MMM YYYY")}
</span> </span>
{transaction.hotelName && transaction.city ? ( {transaction.hotelName && transaction.city ? (
<span>{`${transaction.hotelName}, ${transaction.city}`}</span> <span>{`${transaction.hotelName}, ${transaction.city}`}</span>
) : null} ) : null}
<span> <span>
{`${transaction.nights} ${formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`} {`${transaction.nights} ${intl.formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`}
</span> </span>
</td> </td>
<AwardPoints awardPoints={transaction.awardPoints} /> <AwardPoints awardPoints={transaction.awardPoints} />
@@ -49,7 +56,9 @@ export default async function MobileTable({ transactions }: TableProps) {
) : ( ) : (
<tr> <tr>
<td className={styles.placeholder} colSpan={2}> <td className={styles.placeholder} colSpan={2}>
{formatMessage({ id: "There are no transactions to display" })} {intl.formatMessage({
id: "There are no transactions to display",
})}
</td> </td>
</tr> </tr>
)} )}

View File

@@ -34,6 +34,16 @@
padding: var(--Spacing-x4); padding: var(--Spacing-x4);
border: 1px solid var(--Main-Grey-10); border: 1px solid var(--Main-Grey-10);
} }
.loadMoreButton {
background-color: var(--Main-Grey-10);
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: var(--Spacing-x-half);
padding: var(--Spacing-x2);
width: 100%;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.container { .container {

View File

@@ -0,0 +1,68 @@
import { ChevronRightIcon } from "@/components/Icons"
import styles from "./pagination.module.css"
import {
PaginationButtonProps,
PaginationProps,
} from "@/types/components/myPages/myPage/earnAndBurn"
function PaginationButton({
children,
isActive,
handleClick,
disabled,
}: React.PropsWithChildren<PaginationButtonProps>) {
return (
<button
type={"button"}
disabled={disabled}
onClick={handleClick}
className={`${styles.paginationButton} ${isActive ? styles.paginationButtonActive : ""}`}
>
{children}
</button>
)
}
export default function Pagination({
pageCount,
isFetching,
handlePageChange,
currentPage,
}: PaginationProps) {
const isOnFirstPage = currentPage === 1
const isOnLastPage = currentPage === pageCount
return (
<div className={styles.pagination}>
<PaginationButton
disabled={isFetching || isOnFirstPage}
handleClick={() => {
handlePageChange(currentPage - 1)
}}
>
<ChevronRightIcon className={styles.chevronLeft} />
</PaginationButton>
{[...Array(pageCount)].map((_, idx) => (
<PaginationButton
isActive={currentPage === idx + 1}
disabled={isFetching || currentPage === idx + 1}
key={idx}
handleClick={() => {
handlePageChange(idx + 1)
}}
>
{idx + 1}
</PaginationButton>
))}
<PaginationButton
disabled={isFetching || isOnLastPage}
handleClick={() => {
handlePageChange(currentPage + 1)
}}
>
<ChevronRightIcon />
</PaginationButton>
</div>
)
}

View File

@@ -0,0 +1,40 @@
.pagination {
display: flex;
justify-content: left;
padding: var(--Spacing-x2);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Rounded);
margin: auto;
gap: var(--Spacing-x5);
max-width: 100%;
overflow-x: auto;
}
.paginationButton {
background-color: transparent;
border: none;
cursor: pointer;
height: 32px;
width: 32px;
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.paginationButton[disabled] {
cursor: not-allowed;
}
.chevronLeft {
transform: rotate(180deg);
height: 100%;
}
.paginationButtonActive {
color: var(--WHITE);
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-Rounded);
}

View File

@@ -0,0 +1,18 @@
import { serverClient } from "@/lib/trpc/server"
import ClientJourney from "./Client"
export default async function JourneyTable() {
const initialJourneyTransactions =
await serverClient().user.transaction.friendTransactions({
page: 1,
limit: 5,
})
if (!initialJourneyTransactions?.data) {
return null
}
return (
<ClientJourney initialJourneyTransactions={initialJourneyTransactions} />
)
}

View File

@@ -1,4 +0,0 @@
.container {
display: grid;
gap: var(--Spacing-x3);
}

View File

@@ -1,11 +1,8 @@
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container" import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header" import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link" import SectionLink from "@/components/Section/Link"
import DesktopTable from "./Desktop" import JourneyTable from "./JourneyTable"
import MobileTable from "./Mobile"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage" import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
@@ -14,16 +11,10 @@ export default async function EarnAndBurn({
subtitle, subtitle,
title, title,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const transactions =
await serverClient().user.transaction.friendTransactions()
if (!transactions) {
return null
}
return ( return (
<SectionContainer> <SectionContainer>
<SectionHeader title={title} link={link} subtitle={subtitle} /> <SectionHeader title={title} link={link} subtitle={subtitle} />
<MobileTable transactions={transactions.data} /> <JourneyTable />
<DesktopTable transactions={transactions.data} />
<SectionLink link={link} variant="mobile" /> <SectionLink link={link} variant="mobile" />
</SectionContainer> </SectionContainer>
) )

View File

@@ -0,0 +1,3 @@
.addCreditCardButton {
justify-self: flex-start;
}

View File

@@ -1,31 +1,83 @@
"use client" "use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { toast } from "sonner"
import { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { PlusCircleIcon } from "@/components/Icons" import { PlusCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang"
export default function AddCreditCardButton() { import styles from "./addCreditCardButton.module.css"
const { formatMessage } = useIntl()
import { type AddCreditCardButtonProps } from "@/types/components/myPages/myProfile/addCreditCardButton"
let hasRunOnce = false
function useAddCardResultToast() {
const intl = useIntl()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (hasRunOnce) return
const success = searchParams.get("success")
const failure = searchParams.get("failure")
if (success) {
// setTimeout is used to make sure DOM is loaded before triggering toast. See documentation for more info: https://sonner.emilkowal.ski/toast#render-toast-on-page-load
setTimeout(() => {
toast.success(
intl.formatMessage({ id: "Your card was successfully saved!" })
)
})
} else if (failure) {
setTimeout(() => {
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
})
}
router.replace(pathname)
hasRunOnce = true
}, [intl, pathname, router, searchParams])
}
export default function AddCreditCardButton({
redirectUrl,
}: AddCreditCardButtonProps) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
useAddCardResultToast()
const initiateAddCard = trpc.user.initiateSaveCard.useMutation({
onSuccess: (result) => (result ? router.push(result.attribute.link) : null),
onError: () =>
toast.error(intl.formatMessage({ id: "Something went wrong!" })),
})
async function handleAddCreditCard() {
// TODO: initiate add credit card flow and redirect user to planet:
// const { url } = trpc.user.creditCard.add.useMutation()
// router.redirect(url)
console.log("Credit card added!")
}
return ( return (
<Button <Button
className={styles.addCreditCardButton}
variant="icon" variant="icon"
theme="base" theme="base"
intent="text" intent="text"
onClick={handleAddCreditCard} onClick={() =>
initiateAddCard.mutate({
language: lang,
mobileToken: false,
redirectUrl,
})
}
wrapping wrapping
> >
<PlusCircleIcon color="burgundy" /> <PlusCircleIcon color="burgundy" />
{formatMessage({ id: "Add new card" })} {intl.formatMessage({ id: "Add new card" })}
</Button> </Button>
) )
} }

View File

@@ -63,7 +63,7 @@
} }
.textMediumContrast { .textMediumContrast {
color: var(--Base-Text-UI-Medium-contrast); color: var(--UI-Text-Medium-contrast);
} }
.white { .white {

View File

@@ -41,6 +41,14 @@
color: var(--UI-Text-Medium-contrast); color: var(--UI-Text-Medium-contrast);
} }
.red {
color: var(--Scandic-Brand-Scandic-Red);
}
.white {
color: var(--UI-Opacity-White-100);
}
.center { .center {
text-align: center; text-align: center;
} }

View File

@@ -9,6 +9,8 @@ const config = {
burgundy: styles.burgundy, burgundy: styles.burgundy,
pale: styles.pale, pale: styles.pale,
textMediumContrast: styles.textMediumContrast, textMediumContrast: styles.textMediumContrast,
red: styles.red,
white: styles.white,
}, },
textTransform: { textTransform: {
bold: styles.bold, bold: styles.bold,

View File

@@ -0,0 +1,96 @@
import { ExternalToast, toast as sonnerToast, Toaster } from "sonner"
import {
CheckCircleIcon,
CloseLarge,
CrossCircle,
InfoCircleIcon,
WarningTriangle,
} from "@/components/Icons"
import Button from "../Button"
import Body from "../Text/Body"
import { ToastsProps } from "./toasts"
import { toastVariants } from "./variants"
import styles from "./toasts.module.css"
export function ToastHandler() {
return <Toaster />
}
function getIcon(variant: ToastsProps["variant"]) {
switch (variant) {
case "error":
return CrossCircle
case "info":
return InfoCircleIcon
case "success":
return CheckCircleIcon
case "warning":
return WarningTriangle
}
}
export function Toast({ message, onClose, variant }: ToastsProps) {
const className = toastVariants({ variant })
const Icon = getIcon(variant)
return (
<div className={className}>
<div className={styles.iconContainer}>
{Icon && <Icon color="white" height={24} width={24} />}
</div>
<Body className={styles.message}>{message}</Body>
<Button onClick={onClose} variant="icon" intent="text">
<CloseLarge />
</Button>
</div>
)
}
export const toast = {
success: (message: string, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
variant="success"
message={message}
onClose={() => sonnerToast.dismiss(t)}
/>
),
options
),
info: (message: string, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
variant="info"
message={message}
onClose={() => sonnerToast.dismiss(t)}
/>
),
options
),
error: (message: string, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
variant="error"
message={message}
onClose={() => sonnerToast.dismiss(t)}
/>
),
options
),
warning: (message: string, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
variant="warning"
message={message}
onClose={() => sonnerToast.dismiss(t)}
/>
),
options
),
}

View File

@@ -0,0 +1,38 @@
.toast {
display: grid;
grid-template-columns: auto 1fr auto;
border-radius: var(--Corner-radius-Large);
overflow: hidden;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, 0.08);
align-items: center;
width: var(--width);
}
.message {
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
}
.success {
--icon-background-color: var(--UI-Semantic-Success);
}
.error {
--icon-background-color: var(--UI-Semantic-Error);
}
.warning {
--icon-background-color: var(--UI-Semantic-Warning);
}
.info {
--icon-background-color: var(--UI-Semantic-Information);
}
.iconContainer {
display: flex;
background-color: var(--icon-background-color);
padding: var(--Spacing-x2);
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,10 @@
import { toastVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
export interface ToastsProps
extends Omit<React.AnchorHTMLAttributes<HTMLDivElement>, "color">,
VariantProps<typeof toastVariants> {
message: string
onClose: () => void
}

View File

@@ -0,0 +1,14 @@
import { cva } from "class-variance-authority"
import styles from "./toasts.module.css"
export const toastVariants = cva(styles.toast, {
variants: {
variant: {
success: styles.success,
info: styles.info,
warning: styles.warning,
error: styles.error,
},
},
})

View File

@@ -5,13 +5,17 @@ import { overview } from "@/constants/routes/webviews"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { webviewSearchParams } from "@/utils/webviews"
import styles from "./linkToOverview.module.css" import styles from "./linkToOverview.module.css"
export default async function LinkToOverview() { export default async function LinkToOverview() {
const { formatMessage } = await getIntl() const { formatMessage } = await getIntl()
const searchParams = webviewSearchParams()
const overviewHref = `${overview[getLang()]}?${searchParams.toString()}`
return ( return (
<Link className={styles.overviewLink} href={overview[getLang()]}> <Link className={styles.overviewLink} href={overviewHref}>
<ArrowLeft height={20} width={20} />{" "} <ArrowLeft height={20} width={20} />{" "}
{formatMessage({ id: "Go back to overview" })} {formatMessage({ id: "Go back to overview" })}
</Link> </Link>

View File

@@ -17,7 +17,7 @@ export const logout = {
da: "/da/log-ud", da: "/da/log-ud",
de: "/de/ausloggen", de: "/de/ausloggen",
en: "/en/logout", en: "/en/logout",
fi: "/fi/kirjautua-ulos", fi: "/fi/kirjaudu-ulos",
no: "/no/logg-ut", no: "/no/logg-ut",
sv: "/sv/logga-ut", sv: "/sv/logga-ut",
} }

6
env/server.ts vendored
View File

@@ -21,8 +21,11 @@ export const env = createEnv({
CMS_PREVIEW_URL: z.string(), CMS_PREVIEW_URL: z.string(),
CMS_URL: z.string(), CMS_URL: z.string(),
CURITY_CLIENT_ID_USER: z.string(), CURITY_CLIENT_ID_USER: z.string(),
CURITY_CLIENT_ID_SERVICE: z.string(),
CURITY_CLIENT_SECRET_SERVICE: z.string(),
CURITY_CLIENT_SECRET_USER: z.string(), CURITY_CLIENT_SECRET_USER: z.string(),
CURITY_ISSUER_USER: z.string(), CURITY_ISSUER_USER: z.string(),
CURITY_ISSUER_SERVICE: z.string(),
CYPRESS_BASE_URL: z.string().default("http://127.0.0.1:3000"), CYPRESS_BASE_URL: z.string().default("http://127.0.0.1:3000"),
DESIGN_SYSTEM_ACCESS_TOKEN: z.string(), DESIGN_SYSTEM_ACCESS_TOKEN: z.string(),
ENVTEST: z.string().optional(), ENVTEST: z.string().optional(),
@@ -76,8 +79,11 @@ export const env = createEnv({
CMS_PREVIEW_URL: process.env.CMS_PREVIEW_URL, CMS_PREVIEW_URL: process.env.CMS_PREVIEW_URL,
CMS_URL: process.env.CMS_URL, CMS_URL: process.env.CMS_URL,
CURITY_CLIENT_ID_USER: process.env.CURITY_CLIENT_ID_USER, CURITY_CLIENT_ID_USER: process.env.CURITY_CLIENT_ID_USER,
CURITY_CLIENT_ID_SERVICE: process.env.CURITY_CLIENT_ID_SERVICE,
CURITY_CLIENT_SECRET_SERVICE: process.env.CURITY_CLIENT_SECRET_SERVICE,
CURITY_CLIENT_SECRET_USER: process.env.CURITY_CLIENT_SECRET_USER, CURITY_CLIENT_SECRET_USER: process.env.CURITY_CLIENT_SECRET_USER,
CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER, CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER,
CURITY_ISSUER_SERVICE: process.env.CURITY_ISSUER_SERVICE,
CYPRESS_BASE_URL: process.env.CYPRESS_TEST_URL, CYPRESS_BASE_URL: process.env.CYPRESS_TEST_URL,
DESIGN_SYSTEM_ACCESS_TOKEN: process.env.DESIGN_SYSTEM_ACCESS_TOKEN, DESIGN_SYSTEM_ACCESS_TOKEN: process.env.DESIGN_SYSTEM_ACCESS_TOKEN,
ENVTEST: process.env.ENVTEST, ENVTEST: process.env.ENVTEST,

View File

@@ -12,8 +12,8 @@
"As our": "Som vores", "As our": "Som vores",
"As our Close Friend": "Som vores nære ven", "As our Close Friend": "Som vores nære ven",
"At the hotel": "På hotellet", "At the hotel": "På hotellet",
"Book": "Bestil", "Book": "Book",
"Booking number": "Bestillingsnummer", "Booking number": "Bookingnummer",
"Breakfast": "Morgenmad", "Breakfast": "Morgenmad",
"by": "inden", "by": "inden",
"Cancel": "Afbestille", "Cancel": "Afbestille",
@@ -24,7 +24,7 @@
"City/State": "By/Stat", "City/State": "By/Stat",
"Click here to log in": "Klik her for at logge ind", "Click here to log in": "Klik her for at logge ind",
"Close": "Tæt", "Close": "Tæt",
"Coming up": "Kommer op", "Coming up": "Er lige om hjørnet",
"Compare all levels": "Sammenlign alle niveauer", "Compare all levels": "Sammenlign alle niveauer",
"Contact us": "Kontakt os", "Contact us": "Kontakt os",
"Continue": "Blive ved", "Continue": "Blive ved",
@@ -47,7 +47,7 @@
"Find booking": "Find booking", "Find booking": "Find booking",
"Flexibility": "Fleksibilitet", "Flexibility": "Fleksibilitet",
"From": "Fra", "From": "Fra",
"Get inspired": "Blive inspireret", "Get inspired": "Bliv inspireret",
"Go back to overview": "Gå tilbage til oversigten", "Go back to overview": "Gå tilbage til oversigten",
"Highest level": "Højeste niveau", "Highest level": "Højeste niveau",
"How do you want to sleep?": "Hvordan vil du sove?", "How do you want to sleep?": "Hvordan vil du sove?",
@@ -55,7 +55,7 @@
"Join Scandic Friends": "Tilmeld dig Scandic Friends", "Join Scandic Friends": "Tilmeld dig Scandic Friends",
"Language": "Sprog", "Language": "Sprog",
"Level": "Niveau", "Level": "Niveau",
"Level up to unlock": "Niveau op for at låse op", "Level up to unlock": "Stig i niveau for at låse op",
"Log in": "Log på", "Log in": "Log på",
"Log in here": "Log ind her", "Log in here": "Log ind her",
"Log out": "Log ud", "Log out": "Log ud",
@@ -88,7 +88,7 @@
"Phone is required": "Telefonnummer er påkrævet", "Phone is required": "Telefonnummer er påkrævet",
"Phone number": "Telefonnummer", "Phone number": "Telefonnummer",
"Please enter a valid phone number": "Indtast venligst et gyldigt telefonnummer", "Please enter a valid phone number": "Indtast venligst et gyldigt telefonnummer",
"Points": "Points", "Points": "Point",
"Points may take up to 10 days to be displayed.": "Det kan tage op til 10 dage at få vist point.", "Points may take up to 10 days to be displayed.": "Det kan tage op til 10 dage at få vist point.",
"Points needed to level up": "Point nødvendige for at komme i niveau", "Points needed to level up": "Point nødvendige for at komme i niveau",
"Points needed to stay on level": "Point nødvendige for at holde sig på niveau", "Points needed to stay on level": "Point nødvendige for at holde sig på niveau",
@@ -123,16 +123,25 @@
"Welcome": "Velkommen", "Welcome": "Velkommen",
"Welcome to": "Velkommen til", "Welcome to": "Velkommen til",
"Wellness & Exercise": "Velvære & Motion", "Wellness & Exercise": "Velvære & Motion",
"Where should you go next?": "Hvor skal du tage hen næste gang?", "Where should you go next?": "Find inspiration til dit næste ophold",
"Which room class suits you the best?": "Hvilken rumklasse passer bedst til dig", "Which room class suits you the best?": "Hvilken rumklasse passer bedst til dig",
"Year": "År", "Year": "År",
"You have no previous stays.": "Du har ingen tidligere ophold.", "You have no previous stays.": "Du har ingen tidligere ophold.",
"You have no upcoming stays.": "Du har ingen kommende ophold.", "You have no upcoming stays.": "Du har ingen kommende ophold.",
"Your card was successfully saved!": "Dit kort blev gemt!",
"Your Challenges Conquer & Earn!": "Dine udfordringer Overvind og tjen!", "Your Challenges Conquer & Earn!": "Dine udfordringer Overvind og tjen!",
"Your level": "Dit niveau", "Your level": "Dit niveau",
"Zip code": "Postnummer", "Zip code": "Postnummer",
"Room facilities": "Værelsesfaciliteter", "Room facilities": "Værelsesfaciliteter",
"Hotel facilities": "Hotel faciliteter", "Hotel facilities": "Hotel faciliteter",
"Hotel surroundings": "Hotel omgivelser", "Hotel surroundings": "Hotel omgivelser",
"Show map": "Vis kort" "Show map": "Vis kort",
"Where to": "Hvorhen",
"When": "Hvornår",
"Rooms & Guests": "Værelser & gæster",
"Booking codes and vouchers": "Bestillingskoder og værdibeviser",
"Add code": "Tilføj kode",
"Use bonus cheque": "Brug bonuscheck",
"Book reward night": "Book belønningsaften",
"Find hotels": "Find hoteller"
} }

View File

@@ -8,10 +8,10 @@
"Amenities": "Annehmlichkeiten", "Amenities": "Annehmlichkeiten",
"Arrival date": "Ankunftsdatum", "Arrival date": "Ankunftsdatum",
"as of today": "Ab heute", "as of today": "Ab heute",
"As our": "Als unsere", "As our": "Als unser",
"As our Close Friend": "Als unser enger Freund", "As our Close Friend": "Als unser enger Freund",
"At the hotel": "Im Hotel", "At the hotel": "Im Hotel",
"Book": "Buch", "Book": "Buchen",
"Booking number": "Buchungsnummer", "Booking number": "Buchungsnummer",
"Breakfast": "Frühstück", "Breakfast": "Frühstück",
"by": "bis", "by": "bis",
@@ -46,7 +46,7 @@
"Find booking": "Buchung finden", "Find booking": "Buchung finden",
"Flexibility": "Flexibilität", "Flexibility": "Flexibilität",
"From": "Fromm", "From": "Fromm",
"Get inspired": "Lass dich inspirieren", "Get inspired": "Lassen Sie sich inspieren",
"Go back to overview": "Zurück zur Übersicht", "Go back to overview": "Zurück zur Übersicht",
"Highest level": "Höchstes Level", "Highest level": "Höchstes Level",
"How do you want to sleep?": "Wie möchtest du schlafen?", "How do you want to sleep?": "Wie möchtest du schlafen?",
@@ -69,7 +69,7 @@
"My wishes": "Meine Wünsche", "My wishes": "Meine Wünsche",
"New password": "Neues Kennwort", "New password": "Neues Kennwort",
"Next": "Nächste", "Next": "Nächste",
"next level:": "Nächste Ebene:", "next level:": "Nächstes Level:",
"No content published": "Kein Inhalt veröffentlicht", "No content published": "Kein Inhalt veröffentlicht",
"No transactions available": "Keine Transaktionen verfügbar", "No transactions available": "Keine Transaktionen verfügbar",
"Not found": "Nicht gefunden", "Not found": "Nicht gefunden",
@@ -117,16 +117,25 @@
"Visiting address": "Besuchsadresse", "Visiting address": "Besuchsadresse",
"Welcome to": "Willkommen zu", "Welcome to": "Willkommen zu",
"Welcome": "Willkommen", "Welcome": "Willkommen",
"Where should you go next?": "Wohin soll es als nächstes gehen?", "Where should you go next?": "Wo geht es als Nächstes hin?",
"Which room class suits you the best?": "Welche Zimmerklasse passt am besten zu Ihnen?", "Which room class suits you the best?": "Welche Zimmerklasse passt am besten zu Ihnen?",
"Year": "Jahr", "Year": "Jahr",
"You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.", "You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.",
"You have no upcoming stays.": "Sie haben keine bevorstehenden Aufenthalte.", "You have no upcoming stays.": "Sie haben keine bevorstehenden Aufenthalte.",
"Your card was successfully saved!": "Ihre Karte wurde erfolgreich gespeichert!",
"Your Challenges Conquer & Earn!": "Meistern Sie Ihre Herausforderungen und verdienen Sie Geld!", "Your Challenges Conquer & Earn!": "Meistern Sie Ihre Herausforderungen und verdienen Sie Geld!",
"Your level": "Dein level", "Your level": "Dein level",
"Zip code": "PLZ", "Zip code": "PLZ",
"Room facilities": "Zimmerausstattung", "Room facilities": "Zimmerausstattung",
"Hotel facilities": "Hotel-Infos", "Hotel facilities": "Hotel-Infos",
"Hotel surroundings": "Umgebung des Hotels", "Hotel surroundings": "Umgebung des Hotels",
"Show map": "Karte anzeigen" "Show map": "Karte anzeigen",
"Where to": "Wohin",
"When": "Wann",
"Rooms & Guests": "Zimmer & Gäste",
"Booking codes and vouchers": "Buchungscodes und Gutscheine",
"Add code": "Code hinzufügen",
"Use bonus cheque": "Bonusscheck nutzen",
"Book reward night": "Bonusnacht buchen",
"Find hotels": "Hotels finden"
} }

View File

@@ -105,6 +105,7 @@
"Retype new password": "Retype new password", "Retype new password": "Retype new password",
"Rooms": "Rooms", "Rooms": "Rooms",
"Save": "Save", "Save": "Save",
"See room details": "See room details",
"Select a country": "Select a country", "Select a country": "Select a country",
"Select country of residence": "Select country of residence", "Select country of residence": "Select country of residence",
"Select date of birth": "Select date of birth", "Select date of birth": "Select date of birth",
@@ -133,11 +134,20 @@
"Year": "Year", "Year": "Year",
"You have no previous stays.": "You have no previous stays.", "You have no previous stays.": "You have no previous stays.",
"You have no upcoming stays.": "You have no upcoming stays.", "You have no upcoming stays.": "You have no upcoming stays.",
"Your card was successfully saved!": "Your card was successfully saved!",
"Your Challenges Conquer & Earn!": "Your Challenges Conquer & Earn!", "Your Challenges Conquer & Earn!": "Your Challenges Conquer & Earn!",
"Your level": "Your level", "Your level": "Your level",
"Zip code": "Zip code", "Zip code": "Zip code",
"Room facilities": "Room facilities", "Room facilities": "Room facilities",
"Hotel facilities": "Hotel facilities", "Hotel facilities": "Hotel facilities",
"Hotel surroundings": "Hotel surroundings", "Hotel surroundings": "Hotel surroundings",
"Show map": "Show map" "Show map": "Show map",
"Where to": "Where to",
"When": "When",
"Rooms & Guests": "Rooms & Guests",
"Booking codes and vouchers": "Booking codes and vouchers",
"Add code": "Add code",
"Use bonus cheque": "Use bonus cheque",
"Book reward night": "Book reward night",
"Find hotels": "Find hotels"
} }

View File

@@ -12,7 +12,7 @@
"As our": "Kuin meidän", "As our": "Kuin meidän",
"As our Close Friend": "Läheisenä ystävänämme", "As our Close Friend": "Läheisenä ystävänämme",
"At the hotel": "Hotellissa", "At the hotel": "Hotellissa",
"Book": "Kirja", "Book": "Varaa",
"Booking number": "Varausnumero", "Booking number": "Varausnumero",
"Breakfast": "Aamiainen", "Breakfast": "Aamiainen",
"by": "mennessä", "by": "mennessä",
@@ -58,7 +58,7 @@
"Level up to unlock": "Nosta taso avataksesi lukituksen", "Level up to unlock": "Nosta taso avataksesi lukituksen",
"Log in": "Kirjaudu sisään", "Log in": "Kirjaudu sisään",
"Log in here": "Kirjaudu sisään", "Log in here": "Kirjaudu sisään",
"Log out": "Kirjautua ulos", "Log out": "Kirjaudu ulos",
"Meetings & Conferences": "Kokoukset & Konferenssit", "Meetings & Conferences": "Kokoukset & Konferenssit",
"Members": "Jäsenet", "Members": "Jäsenet",
"Membership cards": "Jäsenkortit", "Membership cards": "Jäsenkortit",
@@ -123,16 +123,25 @@
"Welcome": "Tervetuloa", "Welcome": "Tervetuloa",
"Welcome to": "Tervetuloa", "Welcome to": "Tervetuloa",
"Wellness & Exercise": "Hyvinvointi & Liikunta", "Wellness & Exercise": "Hyvinvointi & Liikunta",
"Where should you go next?": "Minne sinun pitäisi mennä seuraavaksi?", "Where should you go next?": "Mihin menisit seuraavaksi?",
"Which room class suits you the best?": "Mikä huoneluokka sopii sinulle parhaiten?", "Which room class suits you the best?": "Mikä huoneluokka sopii sinulle parhaiten?",
"Year": "Vuosi", "Year": "Vuosi",
"You have no previous stays.": "Sinulla ei ole aiempaa oleskelua.", "You have no previous stays.": "Sinulla ei ole aiempaa oleskelua.",
"You have no upcoming stays.": "Sinulla ei ole tulevia oleskeluja.", "You have no upcoming stays.": "Sinulla ei ole tulevia oleskeluja.",
"Your card was successfully saved!": "Korttisi tallennettu onnistuneesti!",
"Your Challenges Conquer & Earn!": "Voita ja ansaitse haasteesi!", "Your Challenges Conquer & Earn!": "Voita ja ansaitse haasteesi!",
"Your level": "Tasosi", "Your level": "Tasosi",
"Zip code": "Postinumero", "Zip code": "Postinumero",
"Room facilities": "Huoneen varustelu", "Room facilities": "Huoneen varustelu",
"Hotel facilities": "Hotellin palvelut", "Hotel facilities": "Hotellin palvelut",
"Hotel surroundings": "Hotellin ympäristö", "Hotel surroundings": "Hotellin ympäristö",
"Show map": "Näytä kartta" "Show map": "Näytä kartta",
"Where to": "Minne",
"When": "Kun",
"Rooms & Guestss": "Huoneet & Vieraat",
"Booking codes and vouchers": "Varauskoodit ja kupongit",
"Add code": "Lisää koodi",
"Use bonus cheque": "Käytä bonussekkiä",
"Book reward night": "Kirjapalkinto-ilta",
"Find hotels": "Etsi hotelleja"
} }

View File

@@ -5,7 +5,7 @@
"Address": "Adresse", "Address": "Adresse",
"All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alle sengene våre er fra Bliss, slik at du kan justere fastheten for din perfekte komfort.", "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alle sengene våre er fra Bliss, slik at du kan justere fastheten for din perfekte komfort.",
"All rooms comes with standard amenities": "Alle rommene har standard fasiliteter", "All rooms comes with standard amenities": "Alle rommene har standard fasiliteter",
"Already a friend?": "Allerede en venn?", "Already a friend?": "Allerede Friend?",
"Amenities": "Fasiliteter", "Amenities": "Fasiliteter",
"Arrival date": "Ankomstdato", "Arrival date": "Ankomstdato",
"as of today": "per idag", "as of today": "per idag",
@@ -123,16 +123,25 @@
"Welcome": "Velkommen", "Welcome": "Velkommen",
"Welcome to": "Velkommen til", "Welcome to": "Velkommen til",
"Wellness & Exercise": "Velvære & Trening", "Wellness & Exercise": "Velvære & Trening",
"Where should you go next?": "Hvor bør du gå videre?", "Where should you go next?": "Hvor ønsker du å reise neste gang?",
"Which room class suits you the best?": "Hvilken romklasse passer deg best?", "Which room class suits you the best?": "Hvilken romklasse passer deg best?",
"Year": "År", "Year": "År",
"You have no previous stays.": "Du har ingen tidligere opphold.", "You have no previous stays.": "Du har ingen tidligere opphold.",
"You have no upcoming stays.": "Du har ingen kommende opphold.", "You have no upcoming stays.": "Du har ingen kommende opphold.",
"Your card was successfully saved!": "Kortet ditt ble lagret!",
"Your Challenges Conquer & Earn!": "Dine utfordringer Erobre og tjen!", "Your Challenges Conquer & Earn!": "Dine utfordringer Erobre og tjen!",
"Your level": "Ditt nivå", "Your level": "Ditt nivå",
"Zip code": "Post kode", "Zip code": "Post kode",
"Room facilities": "Romfasiliteter", "Room facilities": "Romfasiliteter",
"Hotel facilities": "Hotelfaciliteter", "Hotel facilities": "Hotelfaciliteter",
"Hotel surroundings": "Hotellomgivelser", "Hotel surroundings": "Hotellomgivelser",
"Show map": "Vis kart" "Show map": "Vis kart",
"Where to": "Hvor skal du",
"When": "Når",
"Rooms & Guests": "Rom og gjester",
"Booking codes and vouchers": "Bestillingskoder og kuponger",
"Add code": "Legg til kode",
"Use bonus cheque": "Bruk bonussjekk",
"Book reward night": "Bestill belønningskveld",
"Find hotels": "Finn hotell"
} }

View File

@@ -54,11 +54,11 @@
"How it works": "Hur det fungerar", "How it works": "Hur det fungerar",
"hotelPages.rooms.roomCard.person": "person", "hotelPages.rooms.roomCard.person": "person",
"hotelPages.rooms.roomCard.persons": "personer", "hotelPages.rooms.roomCard.persons": "personer",
"hotelPages.rooms.roomCard.seeRoomDetails": "Se rumsdetaljer", "See room details": "Se rumsdetaljer",
"Join Scandic Friends": "Gå med i Scandic Friends", "Join Scandic Friends": "Gå med i Scandic Friends",
"Language": "Språk", "Language": "Språk",
"Level": "Nivå", "Level": "Nivå",
"Level up to unlock": "Nivå upp för att låsa upp", "Level up to unlock": "Levla upp för att låsa upp",
"Log in": "Logga in", "Log in": "Logga in",
"Log in here": "Logga in här", "Log in here": "Logga in här",
"Log out": "Logga ut", "Log out": "Logga ut",
@@ -114,7 +114,7 @@
"Something went wrong!": "Något gick fel!", "Something went wrong!": "Något gick fel!",
"Street": "Gata", "Street": "Gata",
"special character": "speciell karaktär", "special character": "speciell karaktär",
"Total Points": "Total poäng", "Total Points": "Poäng totalt",
"Your points to spend": "Dina spenderbara poäng", "Your points to spend": "Dina spenderbara poäng",
"Transaction date": "Transaktionsdatum", "Transaction date": "Transaktionsdatum",
"Transactions": "Transaktioner", "Transactions": "Transaktioner",
@@ -125,16 +125,25 @@
"Visiting address": "Besöksadress", "Visiting address": "Besöksadress",
"Welcome": "Välkommen", "Welcome": "Välkommen",
"Wellness & Exercise": "Hälsa & Träning", "Wellness & Exercise": "Hälsa & Träning",
"Where should you go next?": "Vart ska du gå härnäst?", "Where should you go next?": "Låter inte en spontanweekend härligt?",
"Which room class suits you the best?": "Vilken rumsklass passar dig bäst?", "Which room class suits you the best?": "Vilken rumsklass passar dig bäst?",
"Year": "År", "Year": "År",
"You have no previous stays.": "Du har inga tidigare vistelser.", "You have no previous stays.": "Du har inga tidigare vistelser.",
"You have no upcoming stays.": "Du har inga kommande vistelser.", "You have no upcoming stays.": "Du har inga planerade resor.",
"Your card was successfully saved!": "Ditt kort har sparats!",
"Your Challenges Conquer & Earn!": "Dina utmaningar Erövra och tjäna!", "Your Challenges Conquer & Earn!": "Dina utmaningar Erövra och tjäna!",
"Your level": "Din nivå", "Your level": "Din nivå",
"Zip code": "Postnummer", "Zip code": "Postnummer",
"Room facilities": "Rumlfaciliteter", "Room facilities": "Rumfaciliteter",
"Hotel facilities": "Hotellfaciliteter", "Hotel facilities": "Hotellfaciliteter",
"Hotel surroundings": "Hotellomgivning", "Hotel surroundings": "Hotellomgivning",
"Show map": "Visa karta" "Show map": "Visa karta",
"Where to": "Vart",
"When": "När",
"Rooms & Guests": "Rum och gäster",
"Booking codes and vouchers": "Bokningskoder och kuponger",
"Add code": "Add code",
"Use bonus cheque": "Use bonus cheque",
"Book reward night": "Book reward night",
"Find hotels": "Hitta hotell"
} }

View File

@@ -8,10 +8,11 @@ export namespace endpoints {
export const enum v1 { export const enum v1 {
profile = "profile/v1/Profile", profile = "profile/v1/Profile",
creditCards = `${profile}/creditCards`, creditCards = `${profile}/creditCards`,
initiateSaveCard = `${creditCards}/initiateSaveCard`,
friendTransactions = "profile/v1/Transaction/friendTransactions", friendTransactions = "profile/v1/Transaction/friendTransactions",
upcomingStays = "booking/v1/Stays/future", upcomingStays = "booking/v1/Stays/future",
previousStays = "booking/v1/Stays/past", previousStays = "booking/v1/Stays/past",
hotel = "hotel/v1/Hotels", hotels = "hotel/v1/Hotels",
} }
} }

View File

@@ -7,7 +7,7 @@ import type {
RequestOptionsWithJSONBody, RequestOptionsWithJSONBody,
RequestOptionsWithOutBody, RequestOptionsWithOutBody,
} from "@/types/fetch" } from "@/types/fetch"
import type { Endpoint } from "./endpoints" import type { Endpoint, endpoints } from "./endpoints"
export { endpoints } from "./endpoints" export { endpoints } from "./endpoints"
@@ -27,7 +27,7 @@ const fetch = fetchRetry(global.fetch, {
}) })
export async function get( export async function get(
endpoint: Endpoint, endpoint: Endpoint | `${endpoints.v1.hotels}/${string}`,
options: RequestOptionsWithOutBody, options: RequestOptionsWithOutBody,
params?: URLSearchParams params?: URLSearchParams
) { ) {
@@ -53,7 +53,7 @@ export async function patch(
} }
export async function post( export async function post(
endpoint: Endpoint, endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithJSONBody options: RequestOptionsWithJSONBody
) { ) {
const { body, ...requestOptions } = options const { body, ...requestOptions } = options

View File

@@ -68,7 +68,7 @@ export const middleware = auth(async (request) => {
if (isLoggedIn && isMFAPath && isMFAInvalid()) { if (isLoggedIn && isMFAPath && isMFAInvalid()) {
const headers = new Headers(request.headers) const headers = new Headers(request.headers)
headers.set("x-mfa-login", "true") headers.set("x-mfa-login", "true")
headers.set("x-returnurl", request.nextUrl.href) headers.set("x-returnurl", nextUrlClone.href)
return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), { return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), {
request: { request: {
headers, headers,

View File

@@ -21,6 +21,29 @@ export const middleware: NextMiddleware = async (request) => {
const { nextUrl } = request const { nextUrl } = request
const lang = findLang(nextUrl.pathname) const lang = findLang(nextUrl.pathname)
const loginTypeHeader = request.headers.get("loginType")
const loginTypeSearchParam = nextUrl.searchParams.get("loginType")
const adobeMc = nextUrl.searchParams.get("adobe_mc")
const headers = getDefaultRequestHeaders(request)
// LoginType is passed from the mobile app as a header and needs to be passed around with each subsequent
// request within webviews due to tracking. We set the loginType as a header and pass it along to future
// requests, and to read it from TRPC side (which is where the tracking object is created).
// The value is appended to all webview links as a search param, just like adobe_mc.
const loginType = loginTypeHeader || loginTypeSearchParam
if (loginType) {
headers.set("loginType", loginType)
}
// adobe_mc (Experience Cloud ID) needs to be passed around as a search param, which will be read
// from the URL by the tracking SDK. Adobe_mc is passed from the mobile app as a searchParam, and
// then passed to nextjs as a header. In the RSC, the adobe_mc is appended as a searchParam on each webview link.
if (adobeMc) {
headers.set("adobe_mc", adobeMc)
}
// If user is redirected to /lang/webview/refresh/, the webview token is invalid and we remove the cookie // If user is redirected to /lang/webview/refresh/, the webview token is invalid and we remove the cookie
if (refreshWebviews.includes(nextUrl.pathname)) { if (refreshWebviews.includes(nextUrl.pathname)) {
return NextResponse.rewrite( return NextResponse.rewrite(
@@ -44,7 +67,6 @@ export const middleware: NextMiddleware = async (request) => {
`Unable to resolve CMS entry for locale "${lang}": ${pathNameWithoutLang}` `Unable to resolve CMS entry for locale "${lang}": ${pathNameWithoutLang}`
) )
} }
const headers = getDefaultRequestHeaders(request)
headers.set("x-uid", uid) headers.set("x-uid", uid)
const webviewToken = request.cookies.get("webviewToken") const webviewToken = request.cookies.get("webviewToken")

10
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"react-international-phone": "^4.2.6", "react-international-phone": "^4.2.6",
"react-intl": "^6.6.8", "react-intl": "^6.6.8",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^1.5.0",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^4.5.2" "zustand": "^4.5.2"
@@ -16538,6 +16539,15 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/sonner": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz",
"integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==",
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -57,6 +57,7 @@
"react-international-phone": "^4.2.6", "react-international-phone": "^4.2.6",
"react-intl": "^6.6.8", "react-intl": "^6.6.8",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^1.5.0",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^4.5.2" "zustand": "^4.5.2"

View File

@@ -44,6 +44,7 @@ export function createContext() {
const cookie = cookies() const cookie = cookies()
const webviewTokenCookie = cookie.get("webviewToken") const webviewTokenCookie = cookie.get("webviewToken")
const loginType = h.get("loginType")
return createContextInner({ return createContextInner({
auth: async () => { auth: async () => {
@@ -53,7 +54,12 @@ export function createContext() {
return null return null
} }
return session || ({ token: { access_token: webToken } } as Session) return (
session ||
({
token: { access_token: webToken, loginType },
} as Session)
)
}, },
lang: h.get("x-lang") as Lang, lang: h.get("x-lang") as Lang,
pathname: h.get("x-pathname")!, pathname: h.get("x-pathname")!,

View File

@@ -1,94 +1,98 @@
import { z } from "zod" import { z } from "zod"
import { fromUppercaseToLangEnum } from "@/utils/languages" import { toLang } from "@/server/utils"
const RatingsSchema = z.object({ const ratingsSchema = z
tripAdvisor: z.object({ .object({
numberOfReviews: z.number(), tripAdvisor: z.object({
rating: z.number(), numberOfReviews: z.number(),
ratingImageUrl: z.string(), rating: z.number(),
webUrl: z.string(), ratingImageUrl: z.string(),
awards: z.array( webUrl: z.string(),
z.object({ awards: z.array(
displayName: z.string(), z.object({
images: z.object({ displayName: z.string(),
small: z.string(), images: z.object({
medium: z.string(), small: z.string(),
large: z.string(), medium: z.string(),
}), large: z.string(),
}) }),
), })
reviews: z.object({ ),
widgetHtmlTagId: z.string(), reviews: z
widgetScriptEmbedUrlIframe: z.string(), .object({
widgetScriptEmbedUrlJavaScript: z.string(), widgetHtmlTagId: z.string(),
widgetScriptEmbedUrlIframe: z.string(),
widgetScriptEmbedUrlJavaScript: z.string(),
})
.optional(),
}), }),
}), })
}) .optional()
const AddressSchema = z.object({ const addressSchema = z.object({
streetAddress: z.string(), streetAddress: z.string(),
city: z.string(), city: z.string(),
zipCode: z.string(), zipCode: z.string(),
country: z.string(), country: z.string(),
}) })
const ContactInformationSchema = z.object({ const contactInformationSchema = z.object({
phoneNumber: z.string(), phoneNumber: z.string(),
faxNumber: z.string(), faxNumber: z.string().optional(),
email: z.string(), email: z.string(),
websiteUrl: z.string(), websiteUrl: z.string(),
}) })
const CheckinSchema = z.object({ const checkinSchema = z.object({
checkInTime: z.string(), checkInTime: z.string(),
checkOutTime: z.string(), checkOutTime: z.string(),
onlineCheckOutAvailableFrom: z.string().nullable().optional(), onlineCheckOutAvailableFrom: z.string().nullable().optional(),
onlineCheckout: z.boolean(), onlineCheckout: z.boolean(),
}) })
const EcoLabelsSchema = z.object({ const ecoLabelsSchema = z.object({
euEcoLabel: z.boolean(), euEcoLabel: z.boolean(),
greenGlobeLabel: z.boolean(), greenGlobeLabel: z.boolean(),
nordicEcoLabel: z.boolean(), nordicEcoLabel: z.boolean(),
svanenEcoLabelCertificateNumber: z.string().optional(), svanenEcoLabelCertificateNumber: z.string().optional(),
}) })
const HotelFacilityDetailSchema = z.object({ const hotelFacilityDetailSchema = z.object({
heading: z.string(), heading: z.string(),
description: z.string(), description: z.string(),
}) })
const HotelFacilitySchema = z.object({ const hotelFacilitySchema = z.object({
breakfast: HotelFacilityDetailSchema, breakfast: hotelFacilityDetailSchema,
checkout: HotelFacilityDetailSchema, checkout: hotelFacilityDetailSchema,
gym: HotelFacilityDetailSchema, gym: hotelFacilityDetailSchema,
internet: HotelFacilityDetailSchema, internet: hotelFacilityDetailSchema,
laundry: HotelFacilityDetailSchema, laundry: hotelFacilityDetailSchema,
luggage: HotelFacilityDetailSchema, luggage: hotelFacilityDetailSchema,
shop: HotelFacilityDetailSchema, shop: hotelFacilityDetailSchema,
telephone: HotelFacilityDetailSchema, telephone: hotelFacilityDetailSchema,
}) })
const HotelInformationDetailSchema = z.object({ const hotelInformationDetailSchema = z.object({
heading: z.string(), heading: z.string(),
description: z.string(), description: z.string(),
link: z.string().optional(), link: z.string().optional(),
}) })
const HotelInformationSchema = z.object({ const hotelInformationSchema = z.object({
accessibility: HotelInformationDetailSchema, accessibility: hotelInformationDetailSchema,
safety: HotelInformationDetailSchema, safety: hotelInformationDetailSchema,
sustainability: HotelInformationDetailSchema, sustainability: hotelInformationDetailSchema,
}) })
const InteriorSchema = z.object({ const interiorSchema = z.object({
numberOfBeds: z.number(), numberOfBeds: z.number(),
numberOfCribs: z.number(), numberOfCribs: z.number(),
numberOfFloors: z.number(), numberOfFloors: z.number(),
numberOfRooms: z.object({ numberOfRooms: z.object({
connected: z.number(), connected: z.number(),
forEllergics: z.number(), forAllergics: z.number().optional(),
forDisabled: z.number(), forDisabled: z.number(),
nonSmoking: z.number(), nonSmoking: z.number(),
pet: z.number(), pet: z.number(),
@@ -97,37 +101,37 @@ const InteriorSchema = z.object({
}), }),
}) })
const ReceptionHoursSchema = z.object({ const receptionHoursSchema = z.object({
alwaysOpen: z.boolean(), alwaysOpen: z.boolean(),
isClosed: z.boolean(), isClosed: z.boolean(),
openingTime: z.string().optional(), openingTime: z.string().optional(),
closingTime: z.string().optional(), closingTime: z.string().optional(),
}) })
const LocationSchema = z.object({ const locationSchema = z.object({
distanceToCentre: z.number(), distanceToCentre: z.number(),
latitude: z.number(), latitude: z.number(),
longitude: z.number(), longitude: z.number(),
}) })
const ImageMetaDataSchema = z.object({ const imageMetaDataSchema = z.object({
title: z.string(), title: z.string(),
altText: z.string(), altText: z.string(),
altText_En: z.string(), altText_En: z.string(),
copyRight: z.string(), copyRight: z.string(),
}) })
const ImageSizesSchema = z.object({ const imageSizesSchema = z.object({
tiny: z.string(), tiny: z.string(),
small: z.string(), small: z.string(),
medium: z.string(), medium: z.string(),
large: z.string(), large: z.string(),
}) })
const HotelContentSchema = z.object({ const hotelContentSchema = z.object({
images: z.object({ images: z.object({
metaData: ImageMetaDataSchema, metaData: imageMetaDataSchema,
imageSizes: ImageSizesSchema, imageSizes: imageSizesSchema,
}), }),
texts: z.object({ texts: z.object({
facilityInformation: z.string(), facilityInformation: z.string(),
@@ -145,24 +149,24 @@ const HotelContentSchema = z.object({
}), }),
}) })
const DetailedFacilitySchema = z.object({ const detailedFacilitySchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
code: z.string().optional(), code: z.string().optional(),
applyToAllHotels: z.boolean(), applyToAllHotels: z.boolean(),
public: z.boolean(), public: z.boolean(),
icon: z.number(), icon: z.string(),
iconName: z.string().optional(), iconName: z.string().optional(),
sortOrder: z.number(), sortOrder: z.number(),
}) })
const HealthFacilitySchema = z.object({ const healthFacilitySchema = z.object({
type: z.string(), type: z.string(),
content: z.object({ content: z.object({
images: z.array( images: z.array(
z.object({ z.object({
metaData: ImageMetaDataSchema, metaData: imageMetaDataSchema,
imageSizes: ImageSizesSchema, imageSizes: imageSizesSchema,
}) })
), ),
texts: z.object({ texts: z.object({
@@ -181,15 +185,15 @@ const HealthFacilitySchema = z.object({
ordinary: z.object({ ordinary: z.object({
alwaysOpen: z.boolean(), alwaysOpen: z.boolean(),
isClosed: z.boolean(), isClosed: z.boolean(),
openingTime: z.string(), openingTime: z.string().optional(),
closingTime: z.string(), closingTime: z.string().optional(),
sortOrder: z.number().optional(), sortOrder: z.number().optional(),
}), }),
weekends: z.object({ weekends: z.object({
alwaysOpen: z.boolean(), alwaysOpen: z.boolean(),
isClosed: z.boolean(), isClosed: z.boolean(),
openingTime: z.string(), openingTime: z.string().optional(),
closingTime: z.string(), closingTime: z.string().optional(),
sortOrder: z.number().optional(), sortOrder: z.number().optional(),
}), }),
}), }),
@@ -203,7 +207,7 @@ const HealthFacilitySchema = z.object({
), ),
}) })
const RewardNightSchema = z.object({ const rewardNightSchema = z.object({
points: z.number(), points: z.number(),
campaign: z.object({ campaign: z.object({
start: z.string(), start: z.string(),
@@ -212,30 +216,30 @@ const RewardNightSchema = z.object({
}), }),
}) })
const PointsOfInterestSchema = z.object({ const pointsOfInterestSchema = z.object({
name: z.string(), name: z.string(),
distance: z.number(), distance: z.number(),
category: z.object({ category: z.object({
name: z.string(), name: z.string(),
group: z.string(), group: z.string(),
}), }),
location: LocationSchema, location: locationSchema,
isHighlighted: z.boolean(), isHighlighted: z.boolean(),
}) })
const ParkingPricingSchema = z.object({ const parkingPricingSchema = z.object({
freeParking: z.boolean(), freeParking: z.boolean(),
paymentType: z.string(), paymentType: z.string(),
localCurrency: z.object({ localCurrency: z.object({
currency: z.string(), currency: z.string(),
range: z.object({ range: z.object({
min: z.number(), min: z.number(),
max: z.number(), max: z.number().optional(),
}), }),
ordinary: z.array( ordinary: z.array(
z.object({ z.object({
period: z.string(), period: z.string(),
amount: z.number(), amount: z.number().optional(),
startTime: z.string(), startTime: z.string(),
endTime: z.string(), endTime: z.string(),
}) })
@@ -243,38 +247,40 @@ const ParkingPricingSchema = z.object({
weekend: z.array( weekend: z.array(
z.object({ z.object({
period: z.string(), period: z.string(),
amount: z.number(), amount: z.number().optional(),
startTime: z.string(),
endTime: z.string(),
})
),
}),
requestedCurrency: z.object({
currency: z.string(),
range: z.object({
min: z.number(),
max: z.number(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
weekend: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(), startTime: z.string(),
endTime: z.string(), endTime: z.string(),
}) })
), ),
}), }),
requestedCurrency: z
.object({
currency: z.string(),
range: z.object({
min: z.number(),
max: z.number(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
weekend: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
})
.optional(),
}) })
const ParkingSchema = z.object({ const parkingSchema = z.object({
type: z.string(), type: z.string(),
name: z.string(), name: z.string(),
address: z.string(), address: z.string(),
@@ -282,37 +288,37 @@ const ParkingSchema = z.object({
numberOfChargingSpaces: z.number(), numberOfChargingSpaces: z.number(),
distanceToHotel: z.number(), distanceToHotel: z.number(),
canMakeReservation: z.boolean(), canMakeReservation: z.boolean(),
pricing: ParkingPricingSchema, pricing: parkingPricingSchema,
}) })
const SpecialNeedSchema = z.object({ const specialNeedSchema = z.object({
name: z.string(), name: z.string(),
details: z.string(), details: z.string(),
}) })
const SpecialNeedGroupSchema = z.object({ const specialNeedGroupSchema = z.object({
name: z.string(), name: z.string(),
specialNeeds: z.array(SpecialNeedSchema), specialNeeds: z.array(specialNeedSchema),
}) })
const SocialMediaSchema = z.object({ const socialMediaSchema = z.object({
instagram: z.string().optional(), instagram: z.string().optional(),
facebook: z.string().optional(), facebook: z.string().optional(),
}) })
const MetaSpecialAlertSchema = z.object({ const metaSpecialAlertSchema = z.object({
type: z.string(), type: z.string(),
description: z.string(), description: z.string().optional(),
displayInBookingFlow: z.boolean(), displayInBookingFlow: z.boolean(),
startDate: z.string(), startDate: z.string(),
endDate: z.string(), endDate: z.string(),
}) })
const MetaSchema = z.object({ const metaSchema = z.object({
specialAlerts: z.array(MetaSpecialAlertSchema), specialAlerts: z.array(metaSpecialAlertSchema),
}) })
const RelationshipsSchema = z.object({ const relationshipsSchema = z.object({
restaurants: z.object({ restaurants: z.object({
links: z.object({ links: z.object({
related: z.string(), related: z.string(),
@@ -335,11 +341,11 @@ const RelationshipsSchema = z.object({
}), }),
}) })
const RoomContentSchema = z.object({ const roomContentSchema = z.object({
images: z.array( images: z.array(
z.object({ z.object({
metaData: ImageMetaDataSchema, metaData: imageMetaDataSchema,
imageSizes: ImageSizesSchema, imageSizes: imageSizesSchema,
}) })
), ),
texts: z.object({ texts: z.object({
@@ -350,7 +356,7 @@ const RoomContentSchema = z.object({
}), }),
}) })
const RoomTypesSchema = z.object({ const roomTypesSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
code: z.string(), code: z.string(),
@@ -384,20 +390,20 @@ const RoomTypesSchema = z.object({
isLackingExtraBeds: z.boolean(), isLackingExtraBeds: z.boolean(),
}) })
const RoomFacilitiesSchema = z.object({ const roomFacilitiesSchema = z.object({
availableInAllRooms: z.boolean(), availableInAllRooms: z.boolean(),
name: z.string(), name: z.string(),
isUniqueSellingPoint: z.boolean(), isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(), sortOrder: z.number(),
}) })
export const RoomSchema = z.object({ export const roomSchema = z.object({
attributes: z.object({ attributes: z.object({
name: z.string(), name: z.string(),
sortOrder: z.number(), sortOrder: z.number(),
content: RoomContentSchema, content: roomContentSchema,
roomTypes: z.array(RoomTypesSchema), roomTypes: z.array(roomTypesSchema),
roomFacilities: z.array(RoomFacilitiesSchema), roomFacilities: z.array(roomFacilitiesSchema),
occupancy: z.object({ occupancy: z.object({
total: z.number(), total: z.number(),
adults: z.number(), adults: z.number(),
@@ -417,18 +423,13 @@ export const getHotelDataSchema = z.object({
data: z.object({ data: z.object({
id: z.string(), id: z.string(),
type: z.string(), // No enum here but the standard return appears to be "hotels". type: z.string(), // No enum here but the standard return appears to be "hotels".
language: z language: z.string().transform((val) => {
.string() const lang = toLang(val)
.refine((val) => fromUppercaseToLangEnum(val) !== undefined, { if (!lang) {
message: "Invalid language", throw new Error("Invalid language")
}) }
.transform((val) => { return lang
const lang = fromUppercaseToLangEnum(val) }),
if (!lang) {
throw new Error("Invalid language")
}
return lang
}),
attributes: z.object({ attributes: z.object({
name: z.string(), name: z.string(),
operaId: z.string(), operaId: z.string(),
@@ -436,36 +437,35 @@ export const getHotelDataSchema = z.object({
isPublished: z.boolean(), isPublished: z.boolean(),
cityId: z.string(), cityId: z.string(),
cityName: z.string(), cityName: z.string(),
ratings: RatingsSchema, ratings: ratingsSchema,
address: AddressSchema, address: addressSchema,
contactInformation: ContactInformationSchema, contactInformation: contactInformationSchema,
hotelFacts: z.object({ hotelFacts: z.object({
checkin: CheckinSchema, checkin: checkinSchema,
ecoLabels: EcoLabelsSchema, ecoLabels: ecoLabelsSchema,
hotelFacilityDetail: HotelFacilitySchema, hotelFacilityDetail: hotelFacilitySchema,
hotelInformation: HotelInformationSchema, hotelInformation: hotelInformationSchema,
interior: InteriorSchema, interior: interiorSchema,
receptionHours: ReceptionHoursSchema, receptionHours: receptionHoursSchema,
yearBuilt: z.string(), yearBuilt: z.string(),
}), }),
location: LocationSchema, location: locationSchema,
hotelContent: HotelContentSchema, hotelContent: hotelContentSchema,
detailedFacilities: z.array(DetailedFacilitySchema), detailedFacilities: z.array(detailedFacilitySchema),
healthFacilities: z.array(HealthFacilitySchema), healthFacilities: z.array(healthFacilitySchema),
rewardNight: RewardNightSchema, rewardNight: rewardNightSchema,
pointsOfInterest: z.array(PointsOfInterestSchema), pointsOfInterest: z.array(pointsOfInterestSchema),
parking: z.array(ParkingSchema), parking: z.array(parkingSchema),
specialNeedGroups: z.array(SpecialNeedGroupSchema), specialNeedGroups: z.array(specialNeedGroupSchema),
socialMedia: SocialMediaSchema, socialMedia: socialMediaSchema,
meta: MetaSchema, meta: metaSchema.optional(),
isActive: z.boolean(), isActive: z.boolean(),
}), }),
relationships: RelationshipsSchema, relationships: relationshipsSchema,
}), }),
// NOTE: We can pass an "include" param to the hotel API to retrieve // NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel. // additional data for an individual hotel.
// Example "included" data can be found in our tempHotelData file. included: z.array(roomSchema).optional(),
included: z.array(RoomSchema).optional(),
}) })
const rate = z.object({ const rate = z.object({

View File

@@ -1,6 +1,7 @@
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { badRequestError } from "@/server/errors/trpc" import { badRequestError } from "@/server/errors/trpc"
import { publicProcedure, router } from "@/server/trpc" import { publicProcedure, router, serviceProcedure } from "@/server/trpc"
import { toApiLang } from "@/server/utils"
import { import {
getFiltersInputSchema, getFiltersInputSchema,
@@ -11,60 +12,58 @@ import {
getFiltersSchema, getFiltersSchema,
getHotelDataSchema, getHotelDataSchema,
getRatesSchema, getRatesSchema,
RoomSchema, roomSchema,
} from "./output" } from "./output"
import tempFilterData from "./tempFilterData.json" import tempFilterData from "./tempFilterData.json"
import tempHotelData from "./tempHotelData.json"
import tempRatesData from "./tempRatesData.json" import tempRatesData from "./tempRatesData.json"
import { toApiLang } from "./utils"
export const hotelQueryRouter = router({ export const hotelQueryRouter = router({
getHotel: publicProcedure getHotel: serviceProcedure
.input(getHotelInputSchema) .input(getHotelInputSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { hotelId, language, include } = input const { hotelId, language, include } = input
const params = new URLSearchParams() const params = new URLSearchParams()
const apiLang = toApiLang(language) const apiLang = toApiLang(language)
params.set("hotelId", hotelId.toString())
params.set("language", apiLang) params.set("language", apiLang)
if (include) { if (include) {
params.set("include", include.join(",")) params.set("include", include.join(","))
} }
// TODO: Enable once we have authorized API access. const apiResponse = await api.get(
// const apiResponse = await api.get( `${api.endpoints.v1.hotels}/${hotelId}`,
// api.endpoints.v1.hotel, {
// {}, // Include token. cache: "no-store",
// params headers: {
// ) Authorization: `Bearer ${ctx.serviceToken}`,
// },
// if (!apiResponse.ok) { },
// console.info(`API Response Failed - Getting Hotel`) params
// console.error(apiResponse)
// return null
// }
// const apiJson = await apiResponse.json()
// NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel.
// Example "included" data can be found in our tempHotelData file.
const { included, ...apiJsonWithoutIncluded } = tempHotelData
const validatedHotelData = getHotelDataSchema.safeParse(
apiJsonWithoutIncluded
) )
if (!apiResponse.ok) {
console.info(`API Response Failed - Getting Hotel`)
console.error(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const validatedHotelData = getHotelDataSchema.safeParse(apiJson)
if (!validatedHotelData.success) { if (!validatedHotelData.success) {
console.error(`Get Individual Hotel Data - Verified Data Error`) console.error(`Get Individual Hotel Data - Verified Data Error`)
console.error(validatedHotelData.error) console.error(validatedHotelData.error)
throw badRequestError() throw badRequestError()
} }
const included = validatedHotelData.data.included || []
const roomCategories = included const roomCategories = included
? included ? included
.filter((item) => item.type === "roomcategories") .filter((item) => item.type === "roomcategories")
.map((roomCategory) => { .map((roomCategory) => {
const validatedRoom = RoomSchema.safeParse(roomCategory) const validatedRoom = roomSchema.safeParse(roomCategory)
if (!validatedRoom.success) { if (!validatedRoom.success) {
console.error(`Get Room Category Data - Verified Data Error`) console.error(`Get Room Category Data - Verified Data Error`)
console.error(validatedRoom.error) console.error(validatedRoom.error)
@@ -75,7 +74,7 @@ export const hotelQueryRouter = router({
: [] : []
return { return {
attributes: validatedHotelData.data.data.attributes, hotel: validatedHotelData.data.data.attributes,
roomCategories: roomCategories, roomCategories: roomCategories,
} }
}), }),

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
import { Lang } from "@/constants/languages"
const langMap: { [key in Lang]: string } = {
[Lang.en]: "En",
[Lang.sv]: "Sv",
[Lang.no]: "No",
[Lang.fi]: "Fi",
[Lang.da]: "Da",
[Lang.de]: "De",
}
/**
* Helper function to convert Lang enum to uppercase
* Needed for the Hotel endpoint.
*/
export const toApiLang = (lang: Lang): string => {
const result = langMap[lang]
if (!result) {
throw new Error("Invalid language")
}
return result
}

View File

@@ -18,3 +18,20 @@ export const soonestUpcomingStaysInput = z
limit: z.number().int().positive(), limit: z.number().int().positive(),
}) })
.default({ limit: 3 }) .default({ limit: 3 })
export const initiateSaveCardInput = z.object({
language: z.string(),
mobileToken: z.boolean(),
redirectUrl: z.string(),
})
export const saveCardInput = z.object({
transactionId: z.string(),
merchantId: z.string().optional(),
})
export const friendTransactionsInput = z
.object({
limit: z.number().int().positive(),
page: z.number().int().positive(),
})
.default({ limit: 5, page: 1 })

View File

@@ -180,6 +180,8 @@ export const getCreditCardsSchema = z.object({
expirationDate: z.string(), expirationDate: z.string(),
cardType: z.string(), cardType: z.string(),
}), }),
id: z.string(),
type: z.string(),
}) })
), ),
}) })
@@ -193,3 +195,14 @@ export const getMembershipCardsSchema = z.array(
membershipType: z.string(), membershipType: z.string(),
}) })
) )
export const initiateSaveCardSchema = z.object({
data: z.object({
attribute: z.object({
transactionId: z.string(),
link: z.string(),
mobileToken: z.string().optional(),
}),
type: z.string(),
}),
})

View File

@@ -1,6 +1,12 @@
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { internalServerError } from "@/server/errors/next"
import {
badRequestError,
forbiddenError,
unauthorizedError,
} from "@/server/errors/trpc"
import { import {
protectedProcedure, protectedProcedure,
router, router,
@@ -12,13 +18,20 @@ import * as maskValue from "@/utils/maskValue"
import { getMembership, getMembershipCards } from "@/utils/user" import { getMembership, getMembershipCards } from "@/utils/user"
import encryptValue from "../utils/encryptValue" import encryptValue from "../utils/encryptValue"
import { getUserInputSchema, staysInput } from "./input" import {
friendTransactionsInput,
getUserInputSchema,
initiateSaveCardInput,
saveCardInput,
staysInput,
} from "./input"
import { import {
getCreditCardsSchema, getCreditCardsSchema,
getFriendTransactionsSchema, getFriendTransactionsSchema,
getMembershipCardsSchema, getMembershipCardsSchema,
getStaysSchema, getStaysSchema,
getUserSchema, getUserSchema,
initiateSaveCardSchema,
Stay, Stay,
} from "./output" } from "./output"
import { benefits, extendedUser, nextLevelPerks } from "./temp" import { benefits, extendedUser, nextLevelPerks } from "./temp"
@@ -441,53 +454,68 @@ export const userQueryRouter = router({
}), }),
}), }),
transaction: router({ transaction: router({
friendTransactions: protectedProcedure.query(async (opts) => { friendTransactions: protectedProcedure
const apiResponse = await api.get(api.endpoints.v1.friendTransactions, { .input(friendTransactionsInput)
headers: { .query(async ({ ctx, input }) => {
Authorization: `Bearer ${opts.ctx.session.token.access_token}`, const { limit, page } = input
}, const apiResponse = await api.get(api.endpoints.v1.friendTransactions, {
}) cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 30 * 60 * 1000 },
})
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.error(`API Response Failed - Getting Friend Transactions`) console.error(`API Response Failed - Getting Friend Transactions`)
console.error(`User: (${JSON.stringify(opts.ctx.session.user)})`) console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse) console.error(apiResponse)
return null return null
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson) const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) { if (!verifiedData.success) {
console.error(`Failed to validate Friend Transactions Data`) console.error(`Failed to validate Friend Transactions Data`)
console.error(`User: (${JSON.stringify(opts.ctx.session.user)})`) console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(verifiedData.error) console.error(verifiedData.error)
return null return null
} }
return { const pageData = verifiedData.data.data.slice(
data: verifiedData.data.data.map(({ attributes }) => { limit * (page - 1),
return { limit * page
awardPoints: attributes.awardPoints, )
checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate, return {
city: attributes.hotelInformation?.city, data: {
confirmationNumber: attributes.confirmationNumber, transactions: pageData.map(({ attributes }) => {
hotelName: attributes.hotelInformation?.name, return {
nights: attributes.nights, awardPoints: attributes.awardPoints,
} checkinDate: attributes.checkinDate,
}), checkoutDate: attributes.checkoutDate,
} city: attributes.hotelInformation?.city,
}), confirmationNumber: attributes.confirmationNumber,
hotelName: attributes.hotelInformation?.name,
nights: attributes.nights,
}
}),
},
meta: {
totalPages: Math.ceil(verifiedData.data.data.length / limit),
},
}
}),
}), }),
creditCards: protectedProcedure.query(async function ({ ctx }) { creditCards: protectedProcedure.query(async function ({ ctx }) {
@@ -517,6 +545,69 @@ export const userQueryRouter = router({
return verifiedData.data.data return verifiedData.data.data
}), }),
initiateSaveCard: protectedProcedure
.input(initiateSaveCardInput)
.mutation(async function ({ ctx, input }) {
const apiResponse = await api.post(api.endpoints.v1.initiateSaveCard, {
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
body: {
language: input.language,
mobileToken: input.mobileToken,
redirectUrl: input.redirectUrl,
},
})
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)
}
}
const apiJson = await apiResponse.json()
const verifiedData = initiateSaveCardSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.error(`Failed to initiate save card data`)
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(verifiedData.error)
return null
}
return verifiedData.data.data
}),
saveCard: protectedProcedure.input(saveCardInput).mutation(async function ({
ctx,
input,
}) {
const apiResponse = await api.post(
`${api.endpoints.v1.creditCards}/${input.transactionId}`,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
body: {},
}
)
if (!apiResponse.ok) {
console.error(`API Response Failed - Save card`)
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse)
return null
}
return true
}),
membershipCards: protectedProcedure.query(async function ({ ctx }) { membershipCards: protectedProcedure.query(async function ({ ctx }) {
const apiResponse = await api.get(api.endpoints.v1.profile, { const apiResponse = await api.get(api.endpoints.v1.profile, {
cache: "no-store", cache: "no-store",

35
server/tokenManager.ts Normal file
View File

@@ -0,0 +1,35 @@
import { env } from "@/env/server"
import { ServiceTokenResponse } from "@/types/tokens"
const SERVICE_TOKEN_REVALIDATE_SECONDS = 3599 // 59 minutes and 59 seconds.
export async function fetchServiceToken(): Promise<ServiceTokenResponse> {
try {
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: env.CURITY_CLIENT_ID_SERVICE,
client_secret: env.CURITY_CLIENT_SECRET_SERVICE,
scope: ["hotel"].join(","),
}),
next: {
revalidate: SERVICE_TOKEN_REVALIDATE_SECONDS,
},
})
if (!response.ok) {
throw new Error("Failed to obtain service token")
}
return response.json()
} catch (error) {
console.error("Error fetching service token:", error)
throw error
}
}

View File

@@ -4,9 +4,11 @@ import { env } from "@/env/server"
import { import {
badRequestError, badRequestError,
internalServerError,
sessionExpiredError, sessionExpiredError,
unauthorizedError, unauthorizedError,
} from "./errors/trpc" } from "./errors/trpc"
import { fetchServiceToken } from "./tokenManager"
import { transformer } from "./transformer" import { transformer } from "./transformer"
import { langInput } from "./utils" import { langInput } from "./utils"
@@ -99,3 +101,15 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
}, },
}) })
}) })
export const serviceProcedure = t.procedure.use(async (opts) => {
const { access_token } = await fetchServiceToken()
if (!access_token) {
throw internalServerError("Failed to obtain service token")
}
return opts.next({
ctx: {
serviceToken: access_token,
},
})
})

View File

@@ -5,3 +5,31 @@ import { Lang } from "@/constants/languages"
export const langInput = z.object({ export const langInput = z.object({
lang: z.nativeEnum(Lang), lang: z.nativeEnum(Lang),
}) })
/**
* Helper function to convert Lang enum to API lang enum.
*/
export const toApiLang = (lang: Lang): string => {
const result = toApiLangMap[lang]
if (!result) {
throw new Error("Invalid language")
}
return result
}
const toApiLangMap: { [key in Lang]: string } = {
[Lang.en]: "En",
[Lang.sv]: "Sv",
[Lang.no]: "No",
[Lang.fi]: "Fi",
[Lang.da]: "Da",
[Lang.de]: "De",
}
/**
* Helper function to convert lang string to Lang enum.
*/
export function toLang(lang: string): Lang | undefined {
const lowerCaseLang = lang.toLowerCase()
return Object.values(Lang).find((l) => l === lowerCaseLang)
}

View File

@@ -1,5 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { bookingWidgetSchema } from "@/components/BookingWidget/schema" import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema> export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema>

View File

@@ -16,10 +16,12 @@ export enum IconName {
Camera = "Camera", Camera = "Camera",
Cellphone = "Cellphone", Cellphone = "Cellphone",
Check = "Check", Check = "Check",
CrossCircle = "CrossCircle",
CheckCircle = "CheckCircle", CheckCircle = "CheckCircle",
ChevronDown = "ChevronDown", ChevronDown = "ChevronDown",
ChevronRight = "ChevronRight", ChevronRight = "ChevronRight",
Close = "Close", Close = "Close",
CloseLarge = "CloseLarge",
Coffee = "Coffee", Coffee = "Coffee",
Concierge = "Concierge", Concierge = "Concierge",
DoorOpen = "DoorOpen", DoorOpen = "DoorOpen",
@@ -39,6 +41,8 @@ export enum IconName {
Phone = "Phone", Phone = "Phone",
PlusCircle = "PlusCircle", PlusCircle = "PlusCircle",
Restaurant = "Restaurant", Restaurant = "Restaurant",
Sauna = "Sauna",
TshirtWash = "TshirtWash", TshirtWash = "TshirtWash",
Wifi = "Wifi", Wifi = "Wifi",
WarningTriangle = "WarningTriangle",
} }

View File

@@ -1,4 +1,4 @@
import { awardPointsVariants } from "@/components/MyPages/Blocks/Points/EarnAndBurn/Desktop/Row/awardPointsVariants" import { awardPointsVariants } from "@/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Desktop/Row/awardPointsVariants"
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority"
@@ -10,9 +10,9 @@ export type TransactionResponse = Awaited<
> >
export type TransactionsNonNullResponseObject = NonNullable<TransactionResponse> export type TransactionsNonNullResponseObject = NonNullable<TransactionResponse>
export type Transactions = export type Transactions =
NonNullable<TransactionsNonNullResponseObject>["data"] NonNullable<TransactionsNonNullResponseObject>["data"]["transactions"]
export type Transaction = export type Transaction =
NonNullable<TransactionsNonNullResponseObject>["data"][number] NonNullable<TransactionsNonNullResponseObject>["data"]["transactions"][number]
export type ClientEarnAndBurnProps = { export type ClientEarnAndBurnProps = {
initialData: TransactionsNonNullResponseObject initialData: TransactionsNonNullResponseObject
@@ -31,6 +31,19 @@ export interface RowProps {
transaction: Transaction transaction: Transaction
} }
export interface PaginationProps {
pageCount: number
isFetching: boolean
handlePageChange: (page: number) => void
currentPage: number
}
export interface PaginationButtonProps {
disabled: boolean
isActive?: boolean
handleClick: () => void
}
export interface AwardPointsProps extends Pick<Transaction, "awardPoints"> {} export interface AwardPointsProps extends Pick<Transaction, "awardPoints"> {}
export interface AwardPointsVariantProps export interface AwardPointsVariantProps

View File

@@ -0,0 +1,3 @@
export type AddCreditCardButtonProps = {
redirectUrl: string
}

View File

@@ -1,13 +1,16 @@
import { z } from "zod" import { z } from "zod"
import { getHotelDataSchema,RoomSchema } from "@/server/routers/hotels/output" import { getHotelDataSchema, roomSchema } from "@/server/routers/hotels/output"
export type HotelData = z.infer<typeof getHotelDataSchema> export type HotelData = z.infer<typeof getHotelDataSchema>
export type Hotel = HotelData["data"]["attributes"] export type Hotel = HotelData["data"]["attributes"]
export type HotelAddress = HotelData["data"]["attributes"]["address"] export type HotelAddress = HotelData["data"]["attributes"]["address"]
export type HotelLocation = HotelData["data"]["attributes"]["location"] export type HotelLocation = HotelData["data"]["attributes"]["location"]
export type HotelTripAdvisor =
HotelData["data"]["attributes"]["ratings"]["tripAdvisor"]
export type RoomData = z.infer<typeof RoomSchema> type HotelRatings = HotelData["data"]["attributes"]["ratings"]
export type HotelTripAdvisor =
| NonNullable<HotelRatings>["tripAdvisor"]
| undefined
export type RoomData = z.infer<typeof roomSchema>

6
types/tokens.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface ServiceTokenResponse {
access_token: string
scope?: string
token_type: string
expires_in: number
}

View File

@@ -5,12 +5,3 @@ export function findLang(pathname: string) {
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}` (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
) )
} }
/**
* Helper function to convert langs in uppercase or capitalized format (e.g. the Hotel endpoint)
* to to Lang enum.
*/
export function fromUppercaseToLangEnum(lang: string): Lang | undefined {
const lowerCaseLang = lang.toLowerCase()
return Object.values(Lang).find((l) => l === lowerCaseLang)
}

View File

@@ -1,7 +1,7 @@
import { TrackingPosition } from "@/types/components/tracking" import { TrackingPosition } from "@/types/components/tracking"
export function trackClick(name: string) { export function trackClick(name: string) {
if (window.adobeDataLayer) { if (typeof window !== "undefined" && window.adobeDataLayer) {
window.adobeDataLayer.push({ window.adobeDataLayer.push({
event: "linkClick", event: "linkClick",
cta: { cta: {
@@ -11,8 +11,16 @@ export function trackClick(name: string) {
} }
} }
export function trackPageViewStart() {
if (typeof window !== "undefined" && window.adobeDataLayer) {
window.adobeDataLayer.push({
event: "pageViewStart",
})
}
}
export function trackLoginClick(position: TrackingPosition) { export function trackLoginClick(position: TrackingPosition) {
if (window.adobeDataLayer) { if (typeof window !== "undefined" && window.adobeDataLayer) {
const loginEvent = { const loginEvent = {
event: "loginStart", event: "loginStart",
login: { login: {

View File

@@ -1,13 +1,32 @@
import "server-only"
import { headers } from "next/headers"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { webviews } from "@/constants/routes/webviews" import { webviews } from "@/constants/routes/webviews"
export function webviewSearchParams() {
const searchParams = new URLSearchParams()
const loginType = headers().get("loginType")
if (loginType) {
searchParams.set("loginType", loginType)
}
const adobeMc = headers().get("adobe_mc")
if (adobeMc) {
searchParams.set("adobe_mc", adobeMc)
}
return searchParams
}
export function modWebviewLink(url: string, lang: Lang) { export function modWebviewLink(url: string, lang: Lang) {
const searchParams = webviewSearchParams()
const urlWithoutLang = url.replace(`/${lang}`, "") const urlWithoutLang = url.replace(`/${lang}`, "")
const webviewUrl = `/${lang}/webview${urlWithoutLang}` const webviewUrl = `/${lang}/webview${urlWithoutLang}`
if (webviews.includes(webviewUrl) || url.startsWith("/webview")) { if (webviews.includes(webviewUrl) || url.startsWith("/webview")) {
return webviewUrl return `${webviewUrl}?${searchParams.toString()}`
} else { } else {
return url return `${url}?${searchParams.toString()}`
} }
} }