Merge branch 'develop' into feat/SW-266-seo-loyalty-pages
This commit is contained in:
@@ -11,6 +11,7 @@ CURITY_CLIENT_SECRET_SERVICE=""
|
||||
CURITY_CLIENT_ID_USER=""
|
||||
CURITY_CLIENT_SECRET_USER=""
|
||||
CURITY_ISSUER_USER="https://testlogin.scandichotels.com"
|
||||
CURITY_ISSUER_SERVICE="https://testlogin.scandichotels.com"
|
||||
CYPRESS_BASE_URL="http://localhost:3000"
|
||||
# See next.config.js for info
|
||||
DEPLOY_PRIME_URL="http://localhost:3000"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { CreditCard, Delete } from "@/components/Icons"
|
||||
@@ -18,6 +19,8 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const creditCards = await serverClient().user.creditCards()
|
||||
|
||||
const { lang } = params
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<article className={styles.content}>
|
||||
@@ -41,7 +44,9 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<AddCreditCardButton />
|
||||
<AddCreditCardButton
|
||||
redirectUrl={`${env.PUBLIC_URL}/api/web/add-card-callback/${lang}`}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
|
||||
|
||||
import HotelCard from "@/components/HotelReservation/HotelCard"
|
||||
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
|
||||
@@ -18,14 +19,12 @@ export default async function SelectHotelPage({
|
||||
const intl = await getIntl()
|
||||
setLang(params.lang)
|
||||
|
||||
const { attributes } = await serverClient().hotel.getHotel({
|
||||
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
|
||||
language: getLang(),
|
||||
})
|
||||
const hotels = [attributes]
|
||||
// TODO: Use real endpoint.
|
||||
const hotel = tempHotelData.data.attributes
|
||||
const hotels = [hotel]
|
||||
|
||||
const hotelFilters = await serverClient().hotel.getFilters({
|
||||
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
|
||||
hotelId: "879",
|
||||
})
|
||||
|
||||
const tempSearchTerm = "Stockholm"
|
||||
@@ -40,12 +39,7 @@ export default async function SelectHotelPage({
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
/>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
variant="underscored"
|
||||
href="#"
|
||||
>
|
||||
<Link className={styles.link} color="burgundy" href="#">
|
||||
{intl.formatMessage({ id: "Show map" })}
|
||||
<ChevronRightIcon color="burgundy" className={styles.icon} />
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
|
||||
|
||||
import HotelCard from "@/components/HotelReservation/HotelCard"
|
||||
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>) {
|
||||
setLang(params.lang)
|
||||
|
||||
// TODO: pass the correct hotel ID
|
||||
const { attributes: hotel } = await serverClient().hotel.getHotel({
|
||||
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
|
||||
language: getLang(),
|
||||
})
|
||||
// TODO: Use real endpoint.
|
||||
const hotel = tempHotelData.data.attributes
|
||||
|
||||
const rooms = await serverClient().hotel.getRates({
|
||||
// TODO: pass the correct hotel ID and all other parameters that should be included in the search
|
||||
hotelId: "1",
|
||||
|
||||
@@ -9,6 +9,7 @@ import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
||||
import Footer from "@/components/Current/Footer"
|
||||
import VwoScript from "@/components/Current/VwoScript"
|
||||
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
|
||||
import { preloadUserTracking } from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
import ServerIntlProvider from "@/i18n/Provider"
|
||||
@@ -55,6 +56,7 @@ export default async function RootLayout({
|
||||
<TrpcProvider>
|
||||
{header}
|
||||
{children}
|
||||
<ToastHandler />
|
||||
<Footer />
|
||||
<TokenRefresher />
|
||||
</TrpcProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { headers } from "next/headers"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
@@ -28,7 +29,11 @@ export default async function ContentTypePage({
|
||||
case "unauthorized": // fall through
|
||||
case "forbidden": // fall through
|
||||
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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
app/api/web/add-card-callback/[lang]/route.ts
Normal file
47
app/api/web/add-card-callback/[lang]/route.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,10 @@
|
||||
.container {
|
||||
display: none;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the styles after mobile UX is ready
|
||||
*/
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@media screen and (max-width: 1367px) {
|
||||
.container {
|
||||
display: grid;
|
||||
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;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,11 @@
|
||||
"use client"
|
||||
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 Form from "../Forms/BookingWidget"
|
||||
|
||||
import styles from "./bookingWidget.module.css"
|
||||
|
||||
import { type BookingWidgetSchema } from "@/types/components/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 (
|
||||
<div id="booking-widget" className={styles.container}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} className={styles.form}>
|
||||
<FormProvider {...methods}>
|
||||
<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>
|
||||
<section className={styles.container}>
|
||||
<Form />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,16 @@ export default async function HotelPage() {
|
||||
return null
|
||||
}
|
||||
const lang = getLang()
|
||||
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
|
||||
|
||||
const hotelData = await serverClient().hotel.getHotel({
|
||||
hotelId: hotelPageIdentifierData.hotel_page_id,
|
||||
language: lang,
|
||||
include: ["RoomCategories"],
|
||||
})
|
||||
if (!hotelData) {
|
||||
return null
|
||||
}
|
||||
const { hotel, roomCategories } = hotelData
|
||||
|
||||
return (
|
||||
<div className={styles.pageContainer}>
|
||||
@@ -30,14 +35,14 @@ export default async function HotelPage() {
|
||||
<main className={styles.mainSection}>
|
||||
<div className={styles.introContainer}>
|
||||
<IntroSection
|
||||
hotelName={attributes.name}
|
||||
hotelDescription={attributes.hotelContent.texts.descriptions.short}
|
||||
location={attributes.location}
|
||||
address={attributes.address}
|
||||
tripAdvisor={attributes.ratings.tripAdvisor}
|
||||
hotelName={hotel.name}
|
||||
hotelDescription={hotel.hotelContent.texts.descriptions.short}
|
||||
location={hotel.location}
|
||||
address={hotel.address}
|
||||
tripAdvisor={hotel.ratings?.tripAdvisor}
|
||||
/>
|
||||
<SidePeeks />
|
||||
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
||||
<AmenitiesList detailedFacilities={hotel.detailedFacilities} />
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
</main>
|
||||
|
||||
@@ -30,10 +30,17 @@ export default async function IntroSection({
|
||||
)
|
||||
const lang = getLang()
|
||||
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
||||
const formattedTripAdvisorText = intl.formatMessage(
|
||||
{ id: "Tripadvisor reviews" },
|
||||
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
||||
const hasTripAdvisorData = !!(
|
||||
tripAdvisor?.rating &&
|
||||
tripAdvisor?.numberOfReviews &&
|
||||
tripAdvisor?.webUrl
|
||||
)
|
||||
const formattedTripAdvisorText = hasTripAdvisorData
|
||||
? intl.formatMessage(
|
||||
{ id: "Tripadvisor reviews" },
|
||||
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
||||
)
|
||||
: ""
|
||||
|
||||
return (
|
||||
<section className={styles.introSection}>
|
||||
@@ -45,17 +52,19 @@ export default async function IntroSection({
|
||||
<Title level="h2">{hotelName}</Title>
|
||||
</div>
|
||||
<Body color="textMediumContrast">{formattedLocationText}</Body>
|
||||
<Link
|
||||
className={styles.introLink}
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
textDecoration="underline"
|
||||
color="peach80"
|
||||
href={tripAdvisor.webUrl}
|
||||
>
|
||||
<TripAdvisorIcon color="peach80" />
|
||||
{formattedTripAdvisorText}
|
||||
</Link>
|
||||
{hasTripAdvisorData && (
|
||||
<Link
|
||||
className={styles.introLink}
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
textDecoration="underline"
|
||||
color="peach80"
|
||||
href={tripAdvisor.webUrl}
|
||||
>
|
||||
<TripAdvisorIcon color="peach80" />
|
||||
{formattedTripAdvisorText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.subtitleContent}>
|
||||
<Preamble>{hotelDescription}</Preamble>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function RoomCard({
|
||||
subtitle,
|
||||
title,
|
||||
}: RoomCardProps) {
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
const mainImage = images[0]
|
||||
|
||||
function handleImageClick() {
|
||||
@@ -35,11 +35,12 @@ export function RoomCard({
|
||||
return (
|
||||
<article className={styles.roomCard}>
|
||||
<button className={styles.imageWrapper} onClick={handleImageClick}>
|
||||
{badgeTextTransKey && (
|
||||
<span className={styles.badge}>
|
||||
{formatMessage({ id: badgeTextTransKey })}
|
||||
</span>
|
||||
)}
|
||||
{/* TODO: re-enable once we have support for badge text from API team. */}
|
||||
{/* {badgeTextTransKey && ( */}
|
||||
{/* <span className={styles.badge}> */}
|
||||
{/* {intl.formatMessage({ id: badgeTextTransKey })} */}
|
||||
{/* </span> */}
|
||||
{/* )} */}
|
||||
<span className={styles.imageCount}>
|
||||
<ImageIcon color="white" />
|
||||
{images.length}
|
||||
@@ -67,7 +68,7 @@ export function RoomCard({
|
||||
variant="underscored"
|
||||
onClick={handleRoomCtaClick}
|
||||
>
|
||||
{formatMessage({ id: "hotelPages.rooms.roomCard.seeRoomDetails" })}
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export default function TabNavigation() {
|
||||
const hash = useHash()
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
const hotelTabLinks: { href: HotelHashValues; text: string }[] = [
|
||||
{ href: HotelHashValues.overview, text: "Overview" },
|
||||
{ href: HotelHashValues.rooms, text: "Rooms" },
|
||||
@@ -35,7 +35,7 @@ export default function TabNavigation() {
|
||||
color="burgundy"
|
||||
textDecoration="none"
|
||||
>
|
||||
{formatMessage({ id: link.text })}
|
||||
{intl.formatMessage({ id: link.text })}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -13,6 +13,7 @@ const facilityToIconMap: { [key: string]: IconName } = {
|
||||
"Meeting rooms": IconName.People2,
|
||||
"Meeting / conference facilities": IconName.People2,
|
||||
"Pet-friendly rooms": IconName.Pets,
|
||||
Sauna: IconName.Sauna,
|
||||
Restaurant: IconName.Restaurant,
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function Header({
|
||||
/**
|
||||
* ToDo: Create logic to get this info from ContentStack based on page
|
||||
* */
|
||||
const hideBookingWidget = true
|
||||
const hideBookingWidget = false
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
54
components/Forms/BookingWidget/FormContent/index.tsx
Normal file
54
components/Forms/BookingWidget/FormContent/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
components/Forms/BookingWidget/form.module.css
Normal file
15
components/Forms/BookingWidget/form.module.css
Normal 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;
|
||||
}
|
||||
80
components/Forms/BookingWidget/index.tsx
Normal file
80
components/Forms/BookingWidget/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
components/Icons/CloseLarge.tsx
Normal file
40
components/Icons/CloseLarge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
components/Icons/CrossCircle.tsx
Normal file
40
components/Icons/CrossCircle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
components/Icons/Sauna.tsx
Normal file
36
components/Icons/Sauna.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
components/Icons/WarningTriangle.tsx
Normal file
40
components/Icons/WarningTriangle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CloseIcon,
|
||||
CloseLarge,
|
||||
CoffeeIcon,
|
||||
ConciergeIcon,
|
||||
CrossCircle,
|
||||
DoorOpenIcon,
|
||||
ElectricBikeIcon,
|
||||
EmailIcon,
|
||||
@@ -33,7 +35,9 @@ import {
|
||||
PhoneIcon,
|
||||
PlusCircleIcon,
|
||||
RestaurantIcon,
|
||||
SaunaIcon,
|
||||
TshirtWashIcon,
|
||||
WarningTriangle,
|
||||
WifiIcon,
|
||||
} from "."
|
||||
|
||||
@@ -59,6 +63,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
|
||||
return CellphoneIcon
|
||||
case IconName.Check:
|
||||
return CheckIcon
|
||||
case IconName.CrossCircle:
|
||||
return CrossCircle
|
||||
case IconName.CheckCircle:
|
||||
return CheckCircleIcon
|
||||
case IconName.ChevronDown:
|
||||
@@ -67,6 +73,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
|
||||
return ChevronRightIcon
|
||||
case IconName.Close:
|
||||
return CloseIcon
|
||||
case IconName.CloseLarge:
|
||||
return CloseLarge
|
||||
case IconName.Coffee:
|
||||
return CoffeeIcon
|
||||
case IconName.Concierge:
|
||||
@@ -105,8 +113,12 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
|
||||
return PlusCircleIcon
|
||||
case IconName.Restaurant:
|
||||
return RestaurantIcon
|
||||
case IconName.Sauna:
|
||||
return SaunaIcon
|
||||
case IconName.TshirtWash:
|
||||
return TshirtWashIcon
|
||||
case IconName.WarningTriangle:
|
||||
return WarningTriangle
|
||||
case IconName.Wifi:
|
||||
return WifiIcon
|
||||
default:
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
|
||||
.white,
|
||||
.white * {
|
||||
fill: var(--Scandic-Opacity-White-100);
|
||||
fill: var(--UI-Opacity-White-100);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ export { default as CheckCircleIcon } from "./CheckCircle"
|
||||
export { default as ChevronDownIcon } from "./ChevronDown"
|
||||
export { default as ChevronRightIcon } from "./ChevronRight"
|
||||
export { default as CloseIcon } from "./Close"
|
||||
export { default as CloseLarge } from "./CloseLarge"
|
||||
export { default as CoffeeIcon } from "./Coffee"
|
||||
export { default as ConciergeIcon } from "./Concierge"
|
||||
export { default as CreditCard } from "./CreditCard"
|
||||
export { default as CrossCircle } from "./CrossCircle"
|
||||
export { default as Delete } from "./Delete"
|
||||
export { default as DoorOpenIcon } from "./DoorOpen"
|
||||
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 PlusCircleIcon } from "./PlusCircle"
|
||||
export { default as RestaurantIcon } from "./Restaurant"
|
||||
export { default as SaunaIcon } from "./Sauna"
|
||||
export { default as ScandicLogoIcon } from "./ScandicLogo"
|
||||
export { default as TshirtWashIcon } from "./TshirtWash"
|
||||
export { default as WarningTriangle } from "./WarningTriangle"
|
||||
export { default as WifiIcon } from "./Wifi"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"title": "10 % rabatt på mat under helger"
|
||||
},
|
||||
{
|
||||
"title": "Kostnadsfri mocktail för barn under vistelse"
|
||||
"title": "Fri mocktail för barn under vistelse"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import AwardPoints from "./AwardPoints"
|
||||
|
||||
@@ -9,17 +12,16 @@ import styles from "./row.module.css"
|
||||
|
||||
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
|
||||
|
||||
export default async function Row({ transaction }: RowProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
export default function Row({ transaction }: RowProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const description =
|
||||
transaction.hotelName && transaction.city
|
||||
? `${transaction.hotelName}, ${transaction.city} ${transaction.nights} ${formatMessage({ id: "nights" })}`
|
||||
: `${transaction.nights} ${formatMessage({ id: "nights" })}`
|
||||
const arrival = dt(transaction.checkinDate)
|
||||
.locale(getLang())
|
||||
.format("DD MMM YYYY")
|
||||
? `${transaction.hotelName}, ${transaction.city} ${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
|
||||
: `${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
|
||||
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
|
||||
const departure = dt(transaction.checkoutDate)
|
||||
.locale(getLang())
|
||||
.locale(lang)
|
||||
.format("DD MMM YYYY")
|
||||
return (
|
||||
<tr className={styles.tr}>
|
||||
@@ -28,6 +28,25 @@
|
||||
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) {
|
||||
.container {
|
||||
display: flex;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
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 { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
|
||||
|
||||
export default async function MobileTable({ transactions }: TableProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
export default function MobileTable({ transactions }: TableProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<table className={styles.table}>
|
||||
@@ -18,29 +20,34 @@ export default async function MobileTable({ transactions }: TableProps) {
|
||||
<tr>
|
||||
<Body asChild>
|
||||
<th className={styles.th}>
|
||||
{formatMessage({ id: "Transactions" })}
|
||||
{intl.formatMessage({ id: "Transactions" })}
|
||||
</th>
|
||||
</Body>
|
||||
<Body asChild>
|
||||
<th className={styles.th}>{formatMessage({ id: "Points" })}</th>
|
||||
<th className={styles.th}>
|
||||
{intl.formatMessage({ id: "Points" })}
|
||||
</th>
|
||||
</Body>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.length ? (
|
||||
transactions.map((transaction) => (
|
||||
<tr className={styles.tr} key={transaction.confirmationNumber}>
|
||||
transactions.map((transaction, idx) => (
|
||||
<tr
|
||||
className={styles.tr}
|
||||
key={`${transaction.confirmationNumber}-${idx}`}
|
||||
>
|
||||
<td className={`${styles.td} ${styles.transactionDetails}`}>
|
||||
<span className={styles.transactionDate}>
|
||||
{dt(transaction.checkinDate)
|
||||
.locale(getLang())
|
||||
.locale(lang)
|
||||
.format("DD MMM YYYY")}
|
||||
</span>
|
||||
{transaction.hotelName && transaction.city ? (
|
||||
<span>{`${transaction.hotelName}, ${transaction.city}`}</span>
|
||||
) : null}
|
||||
<span>
|
||||
{`${transaction.nights} ${formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`}
|
||||
{`${transaction.nights} ${intl.formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`}
|
||||
</span>
|
||||
</td>
|
||||
<AwardPoints awardPoints={transaction.awardPoints} />
|
||||
@@ -49,7 +56,9 @@ export default async function MobileTable({ transactions }: TableProps) {
|
||||
) : (
|
||||
<tr>
|
||||
<td className={styles.placeholder} colSpan={2}>
|
||||
{formatMessage({ id: "There are no transactions to display" })}
|
||||
{intl.formatMessage({
|
||||
id: "There are no transactions to display",
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -34,6 +34,16 @@
|
||||
padding: var(--Spacing-x4);
|
||||
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) {
|
||||
.container {
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import SectionLink from "@/components/Section/Link"
|
||||
|
||||
import DesktopTable from "./Desktop"
|
||||
import MobileTable from "./Mobile"
|
||||
import JourneyTable from "./JourneyTable"
|
||||
|
||||
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
|
||||
@@ -14,16 +11,10 @@ export default async function EarnAndBurn({
|
||||
subtitle,
|
||||
title,
|
||||
}: AccountPageComponentProps) {
|
||||
const transactions =
|
||||
await serverClient().user.transaction.friendTransactions()
|
||||
if (!transactions) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeader title={title} link={link} subtitle={subtitle} />
|
||||
<MobileTable transactions={transactions.data} />
|
||||
<DesktopTable transactions={transactions.data} />
|
||||
<JourneyTable />
|
||||
<SectionLink link={link} variant="mobile" />
|
||||
</SectionContainer>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.addCreditCardButton {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
@@ -1,31 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { PlusCircleIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
export default function AddCreditCardButton() {
|
||||
const { formatMessage } = useIntl()
|
||||
import styles from "./addCreditCardButton.module.css"
|
||||
|
||||
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 (
|
||||
<Button
|
||||
className={styles.addCreditCardButton}
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
onClick={handleAddCreditCard}
|
||||
onClick={() =>
|
||||
initiateAddCard.mutate({
|
||||
language: lang,
|
||||
mobileToken: false,
|
||||
redirectUrl,
|
||||
})
|
||||
}
|
||||
wrapping
|
||||
>
|
||||
<PlusCircleIcon color="burgundy" />
|
||||
{formatMessage({ id: "Add new card" })}
|
||||
{intl.formatMessage({ id: "Add new card" })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
.textMediumContrast {
|
||||
color: var(--Base-Text-UI-Medium-contrast);
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.white {
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.red {
|
||||
color: var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
|
||||
.white {
|
||||
color: var(--UI-Opacity-White-100);
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const config = {
|
||||
burgundy: styles.burgundy,
|
||||
pale: styles.pale,
|
||||
textMediumContrast: styles.textMediumContrast,
|
||||
red: styles.red,
|
||||
white: styles.white,
|
||||
},
|
||||
textTransform: {
|
||||
bold: styles.bold,
|
||||
|
||||
96
components/TempDesignSystem/Toasts/index.tsx
Normal file
96
components/TempDesignSystem/Toasts/index.tsx
Normal 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
|
||||
),
|
||||
}
|
||||
38
components/TempDesignSystem/Toasts/toasts.module.css
Normal file
38
components/TempDesignSystem/Toasts/toasts.module.css
Normal 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;
|
||||
}
|
||||
10
components/TempDesignSystem/Toasts/toasts.ts
Normal file
10
components/TempDesignSystem/Toasts/toasts.ts
Normal 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
|
||||
}
|
||||
14
components/TempDesignSystem/Toasts/variants.ts
Normal file
14
components/TempDesignSystem/Toasts/variants.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,13 +5,17 @@ import { overview } from "@/constants/routes/webviews"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { webviewSearchParams } from "@/utils/webviews"
|
||||
|
||||
import styles from "./linkToOverview.module.css"
|
||||
|
||||
export default async function LinkToOverview() {
|
||||
const { formatMessage } = await getIntl()
|
||||
const searchParams = webviewSearchParams()
|
||||
|
||||
const overviewHref = `${overview[getLang()]}?${searchParams.toString()}`
|
||||
return (
|
||||
<Link className={styles.overviewLink} href={overview[getLang()]}>
|
||||
<Link className={styles.overviewLink} href={overviewHref}>
|
||||
<ArrowLeft height={20} width={20} />{" "}
|
||||
{formatMessage({ id: "Go back to overview" })}
|
||||
</Link>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const logout = {
|
||||
da: "/da/log-ud",
|
||||
de: "/de/ausloggen",
|
||||
en: "/en/logout",
|
||||
fi: "/fi/kirjautua-ulos",
|
||||
fi: "/fi/kirjaudu-ulos",
|
||||
no: "/no/logg-ut",
|
||||
sv: "/sv/logga-ut",
|
||||
}
|
||||
|
||||
6
env/server.ts
vendored
6
env/server.ts
vendored
@@ -21,8 +21,11 @@ export const env = createEnv({
|
||||
CMS_PREVIEW_URL: z.string(),
|
||||
CMS_URL: 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_ISSUER_USER: z.string(),
|
||||
CURITY_ISSUER_SERVICE: z.string(),
|
||||
CYPRESS_BASE_URL: z.string().default("http://127.0.0.1:3000"),
|
||||
DESIGN_SYSTEM_ACCESS_TOKEN: z.string(),
|
||||
ENVTEST: z.string().optional(),
|
||||
@@ -76,8 +79,11 @@ export const env = createEnv({
|
||||
CMS_PREVIEW_URL: process.env.CMS_PREVIEW_URL,
|
||||
CMS_URL: process.env.CMS_URL,
|
||||
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_ISSUER_USER: process.env.CURITY_ISSUER_USER,
|
||||
CURITY_ISSUER_SERVICE: process.env.CURITY_ISSUER_SERVICE,
|
||||
CYPRESS_BASE_URL: process.env.CYPRESS_TEST_URL,
|
||||
DESIGN_SYSTEM_ACCESS_TOKEN: process.env.DESIGN_SYSTEM_ACCESS_TOKEN,
|
||||
ENVTEST: process.env.ENVTEST,
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"As our": "Som vores",
|
||||
"As our Close Friend": "Som vores nære ven",
|
||||
"At the hotel": "På hotellet",
|
||||
"Book": "Bestil",
|
||||
"Booking number": "Bestillingsnummer",
|
||||
"Book": "Book",
|
||||
"Booking number": "Bookingnummer",
|
||||
"Breakfast": "Morgenmad",
|
||||
"by": "inden",
|
||||
"Cancel": "Afbestille",
|
||||
@@ -24,7 +24,7 @@
|
||||
"City/State": "By/Stat",
|
||||
"Click here to log in": "Klik her for at logge ind",
|
||||
"Close": "Tæt",
|
||||
"Coming up": "Kommer op",
|
||||
"Coming up": "Er lige om hjørnet",
|
||||
"Compare all levels": "Sammenlign alle niveauer",
|
||||
"Contact us": "Kontakt os",
|
||||
"Continue": "Blive ved",
|
||||
@@ -47,7 +47,7 @@
|
||||
"Find booking": "Find booking",
|
||||
"Flexibility": "Fleksibilitet",
|
||||
"From": "Fra",
|
||||
"Get inspired": "Blive inspireret",
|
||||
"Get inspired": "Bliv inspireret",
|
||||
"Go back to overview": "Gå tilbage til oversigten",
|
||||
"Highest level": "Højeste niveau",
|
||||
"How do you want to sleep?": "Hvordan vil du sove?",
|
||||
@@ -55,7 +55,7 @@
|
||||
"Join Scandic Friends": "Tilmeld dig Scandic Friends",
|
||||
"Language": "Sprog",
|
||||
"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 here": "Log ind her",
|
||||
"Log out": "Log ud",
|
||||
@@ -88,7 +88,7 @@
|
||||
"Phone is required": "Telefonnummer er påkrævet",
|
||||
"Phone number": "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 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",
|
||||
@@ -123,16 +123,25 @@
|
||||
"Welcome": "Velkommen",
|
||||
"Welcome to": "Velkommen til",
|
||||
"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",
|
||||
"Year": "År",
|
||||
"You have no previous stays.": "Du har ingen tidligere 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 level": "Dit niveau",
|
||||
"Zip code": "Postnummer",
|
||||
"Room facilities": "Værelsesfaciliteter",
|
||||
"Hotel facilities": "Hotel faciliteter",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
"Amenities": "Annehmlichkeiten",
|
||||
"Arrival date": "Ankunftsdatum",
|
||||
"as of today": "Ab heute",
|
||||
"As our": "Als unsere",
|
||||
"As our": "Als unser",
|
||||
"As our Close Friend": "Als unser enger Freund",
|
||||
"At the hotel": "Im Hotel",
|
||||
"Book": "Buch",
|
||||
"Book": "Buchen",
|
||||
"Booking number": "Buchungsnummer",
|
||||
"Breakfast": "Frühstück",
|
||||
"by": "bis",
|
||||
@@ -46,7 +46,7 @@
|
||||
"Find booking": "Buchung finden",
|
||||
"Flexibility": "Flexibilität",
|
||||
"From": "Fromm",
|
||||
"Get inspired": "Lass dich inspirieren",
|
||||
"Get inspired": "Lassen Sie sich inspieren",
|
||||
"Go back to overview": "Zurück zur Übersicht",
|
||||
"Highest level": "Höchstes Level",
|
||||
"How do you want to sleep?": "Wie möchtest du schlafen?",
|
||||
@@ -69,7 +69,7 @@
|
||||
"My wishes": "Meine Wünsche",
|
||||
"New password": "Neues Kennwort",
|
||||
"Next": "Nächste",
|
||||
"next level:": "Nächste Ebene:",
|
||||
"next level:": "Nächstes Level:",
|
||||
"No content published": "Kein Inhalt veröffentlicht",
|
||||
"No transactions available": "Keine Transaktionen verfügbar",
|
||||
"Not found": "Nicht gefunden",
|
||||
@@ -117,16 +117,25 @@
|
||||
"Visiting address": "Besuchsadresse",
|
||||
"Welcome to": "Willkommen zu",
|
||||
"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?",
|
||||
"Year": "Jahr",
|
||||
"You have no previous stays.": "Sie haben keine vorherigen 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 level": "Dein level",
|
||||
"Zip code": "PLZ",
|
||||
"Room facilities": "Zimmerausstattung",
|
||||
"Hotel facilities": "Hotel-Infos",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"Retype new password": "Retype new password",
|
||||
"Rooms": "Rooms",
|
||||
"Save": "Save",
|
||||
"See room details": "See room details",
|
||||
"Select a country": "Select a country",
|
||||
"Select country of residence": "Select country of residence",
|
||||
"Select date of birth": "Select date of birth",
|
||||
@@ -133,11 +134,20 @@
|
||||
"Year": "Year",
|
||||
"You have no previous stays.": "You have no previous 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 level": "Your level",
|
||||
"Zip code": "Zip code",
|
||||
"Room facilities": "Room facilities",
|
||||
"Hotel facilities": "Hotel facilities",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"As our": "Kuin meidän",
|
||||
"As our Close Friend": "Läheisenä ystävänämme",
|
||||
"At the hotel": "Hotellissa",
|
||||
"Book": "Kirja",
|
||||
"Book": "Varaa",
|
||||
"Booking number": "Varausnumero",
|
||||
"Breakfast": "Aamiainen",
|
||||
"by": "mennessä",
|
||||
@@ -58,7 +58,7 @@
|
||||
"Level up to unlock": "Nosta taso avataksesi lukituksen",
|
||||
"Log in": "Kirjaudu sisään",
|
||||
"Log in here": "Kirjaudu sisään",
|
||||
"Log out": "Kirjautua ulos",
|
||||
"Log out": "Kirjaudu ulos",
|
||||
"Meetings & Conferences": "Kokoukset & Konferenssit",
|
||||
"Members": "Jäsenet",
|
||||
"Membership cards": "Jäsenkortit",
|
||||
@@ -123,16 +123,25 @@
|
||||
"Welcome": "Tervetuloa",
|
||||
"Welcome to": "Tervetuloa",
|
||||
"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?",
|
||||
"Year": "Vuosi",
|
||||
"You have no previous stays.": "Sinulla ei ole aiempaa oleskelua.",
|
||||
"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 level": "Tasosi",
|
||||
"Zip code": "Postinumero",
|
||||
"Room facilities": "Huoneen varustelu",
|
||||
"Hotel facilities": "Hotellin palvelut",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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 rooms comes with standard amenities": "Alle rommene har standard fasiliteter",
|
||||
"Already a friend?": "Allerede en venn?",
|
||||
"Already a friend?": "Allerede Friend?",
|
||||
"Amenities": "Fasiliteter",
|
||||
"Arrival date": "Ankomstdato",
|
||||
"as of today": "per idag",
|
||||
@@ -123,16 +123,25 @@
|
||||
"Welcome": "Velkommen",
|
||||
"Welcome to": "Velkommen til",
|
||||
"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?",
|
||||
"Year": "År",
|
||||
"You have no previous stays.": "Du har ingen tidligere 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 level": "Ditt nivå",
|
||||
"Zip code": "Post kode",
|
||||
"Room facilities": "Romfasiliteter",
|
||||
"Hotel facilities": "Hotelfaciliteter",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@
|
||||
"How it works": "Hur det fungerar",
|
||||
"hotelPages.rooms.roomCard.person": "person",
|
||||
"hotelPages.rooms.roomCard.persons": "personer",
|
||||
"hotelPages.rooms.roomCard.seeRoomDetails": "Se rumsdetaljer",
|
||||
"See room details": "Se rumsdetaljer",
|
||||
"Join Scandic Friends": "Gå med i Scandic Friends",
|
||||
"Language": "Språk",
|
||||
"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 here": "Logga in här",
|
||||
"Log out": "Logga ut",
|
||||
@@ -114,7 +114,7 @@
|
||||
"Something went wrong!": "Något gick fel!",
|
||||
"Street": "Gata",
|
||||
"special character": "speciell karaktär",
|
||||
"Total Points": "Total poäng",
|
||||
"Total Points": "Poäng totalt",
|
||||
"Your points to spend": "Dina spenderbara poäng",
|
||||
"Transaction date": "Transaktionsdatum",
|
||||
"Transactions": "Transaktioner",
|
||||
@@ -125,16 +125,25 @@
|
||||
"Visiting address": "Besöksadress",
|
||||
"Welcome": "Välkommen",
|
||||
"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?",
|
||||
"Year": "År",
|
||||
"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 level": "Din nivå",
|
||||
"Zip code": "Postnummer",
|
||||
"Room facilities": "Rumlfaciliteter",
|
||||
"Room facilities": "Rumfaciliteter",
|
||||
"Hotel facilities": "Hotellfaciliteter",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ export namespace endpoints {
|
||||
export const enum v1 {
|
||||
profile = "profile/v1/Profile",
|
||||
creditCards = `${profile}/creditCards`,
|
||||
initiateSaveCard = `${creditCards}/initiateSaveCard`,
|
||||
friendTransactions = "profile/v1/Transaction/friendTransactions",
|
||||
upcomingStays = "booking/v1/Stays/future",
|
||||
previousStays = "booking/v1/Stays/past",
|
||||
hotel = "hotel/v1/Hotels",
|
||||
hotels = "hotel/v1/Hotels",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
RequestOptionsWithJSONBody,
|
||||
RequestOptionsWithOutBody,
|
||||
} from "@/types/fetch"
|
||||
import type { Endpoint } from "./endpoints"
|
||||
import type { Endpoint, endpoints } from "./endpoints"
|
||||
|
||||
export { endpoints } from "./endpoints"
|
||||
|
||||
@@ -27,7 +27,7 @@ const fetch = fetchRetry(global.fetch, {
|
||||
})
|
||||
|
||||
export async function get(
|
||||
endpoint: Endpoint,
|
||||
endpoint: Endpoint | `${endpoints.v1.hotels}/${string}`,
|
||||
options: RequestOptionsWithOutBody,
|
||||
params?: URLSearchParams
|
||||
) {
|
||||
@@ -53,7 +53,7 @@ export async function patch(
|
||||
}
|
||||
|
||||
export async function post(
|
||||
endpoint: Endpoint,
|
||||
endpoint: Endpoint | `${Endpoint}/${string}`,
|
||||
options: RequestOptionsWithJSONBody
|
||||
) {
|
||||
const { body, ...requestOptions } = options
|
||||
|
||||
@@ -68,7 +68,7 @@ export const middleware = auth(async (request) => {
|
||||
if (isLoggedIn && isMFAPath && isMFAInvalid()) {
|
||||
const headers = new Headers(request.headers)
|
||||
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), {
|
||||
request: {
|
||||
headers,
|
||||
|
||||
@@ -21,6 +21,29 @@ export const middleware: NextMiddleware = async (request) => {
|
||||
const { nextUrl } = request
|
||||
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 (refreshWebviews.includes(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
@@ -44,7 +67,6 @@ export const middleware: NextMiddleware = async (request) => {
|
||||
`Unable to resolve CMS entry for locale "${lang}": ${pathNameWithoutLang}`
|
||||
)
|
||||
}
|
||||
const headers = getDefaultRequestHeaders(request)
|
||||
headers.set("x-uid", uid)
|
||||
|
||||
const webviewToken = request.cookies.get("webviewToken")
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -41,6 +41,7 @@
|
||||
"react-international-phone": "^4.2.6",
|
||||
"react-intl": "^6.6.8",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.2"
|
||||
@@ -16538,6 +16539,15 @@
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"react-international-phone": "^4.2.6",
|
||||
"react-intl": "^6.6.8",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.2"
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createContext() {
|
||||
|
||||
const cookie = cookies()
|
||||
const webviewTokenCookie = cookie.get("webviewToken")
|
||||
const loginType = h.get("loginType")
|
||||
|
||||
return createContextInner({
|
||||
auth: async () => {
|
||||
@@ -53,7 +54,12 @@ export function createContext() {
|
||||
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,
|
||||
pathname: h.get("x-pathname")!,
|
||||
|
||||
@@ -1,94 +1,98 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { fromUppercaseToLangEnum } from "@/utils/languages"
|
||||
import { toLang } from "@/server/utils"
|
||||
|
||||
const RatingsSchema = z.object({
|
||||
tripAdvisor: z.object({
|
||||
numberOfReviews: z.number(),
|
||||
rating: z.number(),
|
||||
ratingImageUrl: z.string(),
|
||||
webUrl: z.string(),
|
||||
awards: z.array(
|
||||
z.object({
|
||||
displayName: z.string(),
|
||||
images: z.object({
|
||||
small: z.string(),
|
||||
medium: z.string(),
|
||||
large: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
reviews: z.object({
|
||||
widgetHtmlTagId: z.string(),
|
||||
widgetScriptEmbedUrlIframe: z.string(),
|
||||
widgetScriptEmbedUrlJavaScript: z.string(),
|
||||
const ratingsSchema = z
|
||||
.object({
|
||||
tripAdvisor: z.object({
|
||||
numberOfReviews: z.number(),
|
||||
rating: z.number(),
|
||||
ratingImageUrl: z.string(),
|
||||
webUrl: z.string(),
|
||||
awards: z.array(
|
||||
z.object({
|
||||
displayName: z.string(),
|
||||
images: z.object({
|
||||
small: z.string(),
|
||||
medium: z.string(),
|
||||
large: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
reviews: z
|
||||
.object({
|
||||
widgetHtmlTagId: z.string(),
|
||||
widgetScriptEmbedUrlIframe: z.string(),
|
||||
widgetScriptEmbedUrlJavaScript: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
|
||||
const AddressSchema = z.object({
|
||||
const addressSchema = z.object({
|
||||
streetAddress: z.string(),
|
||||
city: z.string(),
|
||||
zipCode: z.string(),
|
||||
country: z.string(),
|
||||
})
|
||||
|
||||
const ContactInformationSchema = z.object({
|
||||
const contactInformationSchema = z.object({
|
||||
phoneNumber: z.string(),
|
||||
faxNumber: z.string(),
|
||||
faxNumber: z.string().optional(),
|
||||
email: z.string(),
|
||||
websiteUrl: z.string(),
|
||||
})
|
||||
|
||||
const CheckinSchema = z.object({
|
||||
const checkinSchema = z.object({
|
||||
checkInTime: z.string(),
|
||||
checkOutTime: z.string(),
|
||||
onlineCheckOutAvailableFrom: z.string().nullable().optional(),
|
||||
onlineCheckout: z.boolean(),
|
||||
})
|
||||
|
||||
const EcoLabelsSchema = z.object({
|
||||
const ecoLabelsSchema = z.object({
|
||||
euEcoLabel: z.boolean(),
|
||||
greenGlobeLabel: z.boolean(),
|
||||
nordicEcoLabel: z.boolean(),
|
||||
svanenEcoLabelCertificateNumber: z.string().optional(),
|
||||
})
|
||||
|
||||
const HotelFacilityDetailSchema = z.object({
|
||||
const hotelFacilityDetailSchema = z.object({
|
||||
heading: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
|
||||
const HotelFacilitySchema = z.object({
|
||||
breakfast: HotelFacilityDetailSchema,
|
||||
checkout: HotelFacilityDetailSchema,
|
||||
gym: HotelFacilityDetailSchema,
|
||||
internet: HotelFacilityDetailSchema,
|
||||
laundry: HotelFacilityDetailSchema,
|
||||
luggage: HotelFacilityDetailSchema,
|
||||
shop: HotelFacilityDetailSchema,
|
||||
telephone: HotelFacilityDetailSchema,
|
||||
const hotelFacilitySchema = z.object({
|
||||
breakfast: hotelFacilityDetailSchema,
|
||||
checkout: hotelFacilityDetailSchema,
|
||||
gym: hotelFacilityDetailSchema,
|
||||
internet: hotelFacilityDetailSchema,
|
||||
laundry: hotelFacilityDetailSchema,
|
||||
luggage: hotelFacilityDetailSchema,
|
||||
shop: hotelFacilityDetailSchema,
|
||||
telephone: hotelFacilityDetailSchema,
|
||||
})
|
||||
|
||||
const HotelInformationDetailSchema = z.object({
|
||||
const hotelInformationDetailSchema = z.object({
|
||||
heading: z.string(),
|
||||
description: z.string(),
|
||||
link: z.string().optional(),
|
||||
})
|
||||
|
||||
const HotelInformationSchema = z.object({
|
||||
accessibility: HotelInformationDetailSchema,
|
||||
safety: HotelInformationDetailSchema,
|
||||
sustainability: HotelInformationDetailSchema,
|
||||
const hotelInformationSchema = z.object({
|
||||
accessibility: hotelInformationDetailSchema,
|
||||
safety: hotelInformationDetailSchema,
|
||||
sustainability: hotelInformationDetailSchema,
|
||||
})
|
||||
|
||||
const InteriorSchema = z.object({
|
||||
const interiorSchema = z.object({
|
||||
numberOfBeds: z.number(),
|
||||
numberOfCribs: z.number(),
|
||||
numberOfFloors: z.number(),
|
||||
numberOfRooms: z.object({
|
||||
connected: z.number(),
|
||||
forEllergics: z.number(),
|
||||
forAllergics: z.number().optional(),
|
||||
forDisabled: z.number(),
|
||||
nonSmoking: z.number(),
|
||||
pet: z.number(),
|
||||
@@ -97,37 +101,37 @@ const InteriorSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const ReceptionHoursSchema = z.object({
|
||||
const receptionHoursSchema = z.object({
|
||||
alwaysOpen: z.boolean(),
|
||||
isClosed: z.boolean(),
|
||||
openingTime: z.string().optional(),
|
||||
closingTime: z.string().optional(),
|
||||
})
|
||||
|
||||
const LocationSchema = z.object({
|
||||
const locationSchema = z.object({
|
||||
distanceToCentre: z.number(),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
})
|
||||
|
||||
const ImageMetaDataSchema = z.object({
|
||||
const imageMetaDataSchema = z.object({
|
||||
title: z.string(),
|
||||
altText: z.string(),
|
||||
altText_En: z.string(),
|
||||
copyRight: z.string(),
|
||||
})
|
||||
|
||||
const ImageSizesSchema = z.object({
|
||||
const imageSizesSchema = z.object({
|
||||
tiny: z.string(),
|
||||
small: z.string(),
|
||||
medium: z.string(),
|
||||
large: z.string(),
|
||||
})
|
||||
|
||||
const HotelContentSchema = z.object({
|
||||
const hotelContentSchema = z.object({
|
||||
images: z.object({
|
||||
metaData: ImageMetaDataSchema,
|
||||
imageSizes: ImageSizesSchema,
|
||||
metaData: imageMetaDataSchema,
|
||||
imageSizes: imageSizesSchema,
|
||||
}),
|
||||
texts: z.object({
|
||||
facilityInformation: z.string(),
|
||||
@@ -145,24 +149,24 @@ const HotelContentSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const DetailedFacilitySchema = z.object({
|
||||
const detailedFacilitySchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
code: z.string().optional(),
|
||||
applyToAllHotels: z.boolean(),
|
||||
public: z.boolean(),
|
||||
icon: z.number(),
|
||||
icon: z.string(),
|
||||
iconName: z.string().optional(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
|
||||
const HealthFacilitySchema = z.object({
|
||||
const healthFacilitySchema = z.object({
|
||||
type: z.string(),
|
||||
content: z.object({
|
||||
images: z.array(
|
||||
z.object({
|
||||
metaData: ImageMetaDataSchema,
|
||||
imageSizes: ImageSizesSchema,
|
||||
metaData: imageMetaDataSchema,
|
||||
imageSizes: imageSizesSchema,
|
||||
})
|
||||
),
|
||||
texts: z.object({
|
||||
@@ -181,15 +185,15 @@ const HealthFacilitySchema = z.object({
|
||||
ordinary: z.object({
|
||||
alwaysOpen: z.boolean(),
|
||||
isClosed: z.boolean(),
|
||||
openingTime: z.string(),
|
||||
closingTime: z.string(),
|
||||
openingTime: z.string().optional(),
|
||||
closingTime: z.string().optional(),
|
||||
sortOrder: z.number().optional(),
|
||||
}),
|
||||
weekends: z.object({
|
||||
alwaysOpen: z.boolean(),
|
||||
isClosed: z.boolean(),
|
||||
openingTime: z.string(),
|
||||
closingTime: z.string(),
|
||||
openingTime: z.string().optional(),
|
||||
closingTime: z.string().optional(),
|
||||
sortOrder: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
@@ -203,7 +207,7 @@ const HealthFacilitySchema = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
const RewardNightSchema = z.object({
|
||||
const rewardNightSchema = z.object({
|
||||
points: z.number(),
|
||||
campaign: z.object({
|
||||
start: z.string(),
|
||||
@@ -212,30 +216,30 @@ const RewardNightSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const PointsOfInterestSchema = z.object({
|
||||
const pointsOfInterestSchema = z.object({
|
||||
name: z.string(),
|
||||
distance: z.number(),
|
||||
category: z.object({
|
||||
name: z.string(),
|
||||
group: z.string(),
|
||||
}),
|
||||
location: LocationSchema,
|
||||
location: locationSchema,
|
||||
isHighlighted: z.boolean(),
|
||||
})
|
||||
|
||||
const ParkingPricingSchema = z.object({
|
||||
const parkingPricingSchema = z.object({
|
||||
freeParking: z.boolean(),
|
||||
paymentType: z.string(),
|
||||
localCurrency: z.object({
|
||||
currency: z.string(),
|
||||
range: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
max: z.number().optional(),
|
||||
}),
|
||||
ordinary: z.array(
|
||||
z.object({
|
||||
period: z.string(),
|
||||
amount: z.number(),
|
||||
amount: z.number().optional(),
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
})
|
||||
@@ -243,38 +247,40 @@ const ParkingPricingSchema = z.object({
|
||||
weekend: z.array(
|
||||
z.object({
|
||||
period: z.string(),
|
||||
amount: z.number(),
|
||||
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(),
|
||||
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(),
|
||||
endTime: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const ParkingSchema = z.object({
|
||||
const parkingSchema = z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
@@ -282,37 +288,37 @@ const ParkingSchema = z.object({
|
||||
numberOfChargingSpaces: z.number(),
|
||||
distanceToHotel: z.number(),
|
||||
canMakeReservation: z.boolean(),
|
||||
pricing: ParkingPricingSchema,
|
||||
pricing: parkingPricingSchema,
|
||||
})
|
||||
|
||||
const SpecialNeedSchema = z.object({
|
||||
const specialNeedSchema = z.object({
|
||||
name: z.string(),
|
||||
details: z.string(),
|
||||
})
|
||||
|
||||
const SpecialNeedGroupSchema = z.object({
|
||||
const specialNeedGroupSchema = z.object({
|
||||
name: z.string(),
|
||||
specialNeeds: z.array(SpecialNeedSchema),
|
||||
specialNeeds: z.array(specialNeedSchema),
|
||||
})
|
||||
|
||||
const SocialMediaSchema = z.object({
|
||||
const socialMediaSchema = z.object({
|
||||
instagram: z.string().optional(),
|
||||
facebook: z.string().optional(),
|
||||
})
|
||||
|
||||
const MetaSpecialAlertSchema = z.object({
|
||||
const metaSpecialAlertSchema = z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
description: z.string().optional(),
|
||||
displayInBookingFlow: z.boolean(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
})
|
||||
|
||||
const MetaSchema = z.object({
|
||||
specialAlerts: z.array(MetaSpecialAlertSchema),
|
||||
const metaSchema = z.object({
|
||||
specialAlerts: z.array(metaSpecialAlertSchema),
|
||||
})
|
||||
|
||||
const RelationshipsSchema = z.object({
|
||||
const relationshipsSchema = z.object({
|
||||
restaurants: z.object({
|
||||
links: z.object({
|
||||
related: z.string(),
|
||||
@@ -335,11 +341,11 @@ const RelationshipsSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const RoomContentSchema = z.object({
|
||||
const roomContentSchema = z.object({
|
||||
images: z.array(
|
||||
z.object({
|
||||
metaData: ImageMetaDataSchema,
|
||||
imageSizes: ImageSizesSchema,
|
||||
metaData: imageMetaDataSchema,
|
||||
imageSizes: imageSizesSchema,
|
||||
})
|
||||
),
|
||||
texts: z.object({
|
||||
@@ -350,7 +356,7 @@ const RoomContentSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const RoomTypesSchema = z.object({
|
||||
const roomTypesSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
code: z.string(),
|
||||
@@ -384,20 +390,20 @@ const RoomTypesSchema = z.object({
|
||||
isLackingExtraBeds: z.boolean(),
|
||||
})
|
||||
|
||||
const RoomFacilitiesSchema = z.object({
|
||||
const roomFacilitiesSchema = z.object({
|
||||
availableInAllRooms: z.boolean(),
|
||||
name: z.string(),
|
||||
isUniqueSellingPoint: z.boolean(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
|
||||
export const RoomSchema = z.object({
|
||||
export const roomSchema = z.object({
|
||||
attributes: z.object({
|
||||
name: z.string(),
|
||||
sortOrder: z.number(),
|
||||
content: RoomContentSchema,
|
||||
roomTypes: z.array(RoomTypesSchema),
|
||||
roomFacilities: z.array(RoomFacilitiesSchema),
|
||||
content: roomContentSchema,
|
||||
roomTypes: z.array(roomTypesSchema),
|
||||
roomFacilities: z.array(roomFacilitiesSchema),
|
||||
occupancy: z.object({
|
||||
total: z.number(),
|
||||
adults: z.number(),
|
||||
@@ -417,18 +423,13 @@ export const getHotelDataSchema = z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
type: z.string(), // No enum here but the standard return appears to be "hotels".
|
||||
language: z
|
||||
.string()
|
||||
.refine((val) => fromUppercaseToLangEnum(val) !== undefined, {
|
||||
message: "Invalid language",
|
||||
})
|
||||
.transform((val) => {
|
||||
const lang = fromUppercaseToLangEnum(val)
|
||||
if (!lang) {
|
||||
throw new Error("Invalid language")
|
||||
}
|
||||
return lang
|
||||
}),
|
||||
language: z.string().transform((val) => {
|
||||
const lang = toLang(val)
|
||||
if (!lang) {
|
||||
throw new Error("Invalid language")
|
||||
}
|
||||
return lang
|
||||
}),
|
||||
attributes: z.object({
|
||||
name: z.string(),
|
||||
operaId: z.string(),
|
||||
@@ -436,36 +437,35 @@ export const getHotelDataSchema = z.object({
|
||||
isPublished: z.boolean(),
|
||||
cityId: z.string(),
|
||||
cityName: z.string(),
|
||||
ratings: RatingsSchema,
|
||||
address: AddressSchema,
|
||||
contactInformation: ContactInformationSchema,
|
||||
ratings: ratingsSchema,
|
||||
address: addressSchema,
|
||||
contactInformation: contactInformationSchema,
|
||||
hotelFacts: z.object({
|
||||
checkin: CheckinSchema,
|
||||
ecoLabels: EcoLabelsSchema,
|
||||
hotelFacilityDetail: HotelFacilitySchema,
|
||||
hotelInformation: HotelInformationSchema,
|
||||
interior: InteriorSchema,
|
||||
receptionHours: ReceptionHoursSchema,
|
||||
checkin: checkinSchema,
|
||||
ecoLabels: ecoLabelsSchema,
|
||||
hotelFacilityDetail: hotelFacilitySchema,
|
||||
hotelInformation: hotelInformationSchema,
|
||||
interior: interiorSchema,
|
||||
receptionHours: receptionHoursSchema,
|
||||
yearBuilt: z.string(),
|
||||
}),
|
||||
location: LocationSchema,
|
||||
hotelContent: HotelContentSchema,
|
||||
detailedFacilities: z.array(DetailedFacilitySchema),
|
||||
healthFacilities: z.array(HealthFacilitySchema),
|
||||
rewardNight: RewardNightSchema,
|
||||
pointsOfInterest: z.array(PointsOfInterestSchema),
|
||||
parking: z.array(ParkingSchema),
|
||||
specialNeedGroups: z.array(SpecialNeedGroupSchema),
|
||||
socialMedia: SocialMediaSchema,
|
||||
meta: MetaSchema,
|
||||
location: locationSchema,
|
||||
hotelContent: hotelContentSchema,
|
||||
detailedFacilities: z.array(detailedFacilitySchema),
|
||||
healthFacilities: z.array(healthFacilitySchema),
|
||||
rewardNight: rewardNightSchema,
|
||||
pointsOfInterest: z.array(pointsOfInterestSchema),
|
||||
parking: z.array(parkingSchema),
|
||||
specialNeedGroups: z.array(specialNeedGroupSchema),
|
||||
socialMedia: socialMediaSchema,
|
||||
meta: metaSchema.optional(),
|
||||
isActive: z.boolean(),
|
||||
}),
|
||||
relationships: RelationshipsSchema,
|
||||
relationships: relationshipsSchema,
|
||||
}),
|
||||
// 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.
|
||||
included: z.array(RoomSchema).optional(),
|
||||
included: z.array(roomSchema).optional(),
|
||||
})
|
||||
|
||||
const rate = z.object({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as api from "@/lib/api"
|
||||
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 {
|
||||
getFiltersInputSchema,
|
||||
@@ -11,60 +12,58 @@ import {
|
||||
getFiltersSchema,
|
||||
getHotelDataSchema,
|
||||
getRatesSchema,
|
||||
RoomSchema,
|
||||
roomSchema,
|
||||
} from "./output"
|
||||
import tempFilterData from "./tempFilterData.json"
|
||||
import tempHotelData from "./tempHotelData.json"
|
||||
import tempRatesData from "./tempRatesData.json"
|
||||
import { toApiLang } from "./utils"
|
||||
|
||||
export const hotelQueryRouter = router({
|
||||
getHotel: publicProcedure
|
||||
getHotel: serviceProcedure
|
||||
.input(getHotelInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { hotelId, language, include } = input
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
const apiLang = toApiLang(language)
|
||||
params.set("hotelId", hotelId.toString())
|
||||
params.set("language", apiLang)
|
||||
|
||||
if (include) {
|
||||
params.set("include", include.join(","))
|
||||
}
|
||||
|
||||
// TODO: Enable once we have authorized API access.
|
||||
// const apiResponse = await api.get(
|
||||
// api.endpoints.v1.hotel,
|
||||
// {}, // Include token.
|
||||
// params
|
||||
// )
|
||||
//
|
||||
// if (!apiResponse.ok) {
|
||||
// console.info(`API Response Failed - Getting Hotel`)
|
||||
// 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
|
||||
const apiResponse = await api.get(
|
||||
`${api.endpoints.v1.hotels}/${hotelId}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
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) {
|
||||
console.error(`Get Individual Hotel Data - Verified Data Error`)
|
||||
console.error(validatedHotelData.error)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const included = validatedHotelData.data.included || []
|
||||
|
||||
const roomCategories = included
|
||||
? included
|
||||
.filter((item) => item.type === "roomcategories")
|
||||
.map((roomCategory) => {
|
||||
const validatedRoom = RoomSchema.safeParse(roomCategory)
|
||||
const validatedRoom = roomSchema.safeParse(roomCategory)
|
||||
if (!validatedRoom.success) {
|
||||
console.error(`Get Room Category Data - Verified Data Error`)
|
||||
console.error(validatedRoom.error)
|
||||
@@ -75,7 +74,7 @@ export const hotelQueryRouter = router({
|
||||
: []
|
||||
|
||||
return {
|
||||
attributes: validatedHotelData.data.data.attributes,
|
||||
hotel: validatedHotelData.data.data.attributes,
|
||||
roomCategories: roomCategories,
|
||||
}
|
||||
}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -18,3 +18,20 @@ export const soonestUpcomingStaysInput = z
|
||||
limit: z.number().int().positive(),
|
||||
})
|
||||
.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 })
|
||||
|
||||
@@ -180,6 +180,8 @@ export const getCreditCardsSchema = z.object({
|
||||
expirationDate: z.string(),
|
||||
cardType: z.string(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
@@ -193,3 +195,14 @@ export const getMembershipCardsSchema = z.array(
|
||||
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(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { internalServerError } from "@/server/errors/next"
|
||||
import {
|
||||
badRequestError,
|
||||
forbiddenError,
|
||||
unauthorizedError,
|
||||
} from "@/server/errors/trpc"
|
||||
import {
|
||||
protectedProcedure,
|
||||
router,
|
||||
@@ -12,13 +18,20 @@ import * as maskValue from "@/utils/maskValue"
|
||||
import { getMembership, getMembershipCards } from "@/utils/user"
|
||||
|
||||
import encryptValue from "../utils/encryptValue"
|
||||
import { getUserInputSchema, staysInput } from "./input"
|
||||
import {
|
||||
friendTransactionsInput,
|
||||
getUserInputSchema,
|
||||
initiateSaveCardInput,
|
||||
saveCardInput,
|
||||
staysInput,
|
||||
} from "./input"
|
||||
import {
|
||||
getCreditCardsSchema,
|
||||
getFriendTransactionsSchema,
|
||||
getMembershipCardsSchema,
|
||||
getStaysSchema,
|
||||
getUserSchema,
|
||||
initiateSaveCardSchema,
|
||||
Stay,
|
||||
} from "./output"
|
||||
import { benefits, extendedUser, nextLevelPerks } from "./temp"
|
||||
@@ -441,53 +454,68 @@ export const userQueryRouter = router({
|
||||
}),
|
||||
}),
|
||||
transaction: router({
|
||||
friendTransactions: protectedProcedure.query(async (opts) => {
|
||||
const apiResponse = await api.get(api.endpoints.v1.friendTransactions, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.ctx.session.token.access_token}`,
|
||||
},
|
||||
})
|
||||
friendTransactions: protectedProcedure
|
||||
.input(friendTransactionsInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
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) {
|
||||
// switch (apiResponse.status) {
|
||||
// case 400:
|
||||
// throw badRequestError()
|
||||
// case 401:
|
||||
// throw unauthorizedError()
|
||||
// case 403:
|
||||
// throw forbiddenError()
|
||||
// default:
|
||||
// throw internalServerError()
|
||||
// }
|
||||
console.error(`API Response Failed - Getting Friend Transactions`)
|
||||
console.error(`User: (${JSON.stringify(opts.ctx.session.user)})`)
|
||||
console.error(apiResponse)
|
||||
return null
|
||||
}
|
||||
if (!apiResponse.ok) {
|
||||
// switch (apiResponse.status) {
|
||||
// case 400:
|
||||
// throw badRequestError()
|
||||
// case 401:
|
||||
// throw unauthorizedError()
|
||||
// case 403:
|
||||
// throw forbiddenError()
|
||||
// default:
|
||||
// throw internalServerError()
|
||||
// }
|
||||
console.error(`API Response Failed - Getting Friend Transactions`)
|
||||
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
|
||||
console.error(apiResponse)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
console.error(`Failed to validate Friend Transactions Data`)
|
||||
console.error(`User: (${JSON.stringify(opts.ctx.session.user)})`)
|
||||
console.error(verifiedData.error)
|
||||
return null
|
||||
}
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
console.error(`Failed to validate Friend Transactions Data`)
|
||||
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
|
||||
console.error(verifiedData.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
data: verifiedData.data.data.map(({ attributes }) => {
|
||||
return {
|
||||
awardPoints: attributes.awardPoints,
|
||||
checkinDate: attributes.checkinDate,
|
||||
checkoutDate: attributes.checkoutDate,
|
||||
city: attributes.hotelInformation?.city,
|
||||
confirmationNumber: attributes.confirmationNumber,
|
||||
hotelName: attributes.hotelInformation?.name,
|
||||
nights: attributes.nights,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
const pageData = verifiedData.data.data.slice(
|
||||
limit * (page - 1),
|
||||
limit * page
|
||||
)
|
||||
|
||||
return {
|
||||
data: {
|
||||
transactions: pageData.map(({ attributes }) => {
|
||||
return {
|
||||
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 }) {
|
||||
@@ -517,6 +545,69 @@ export const userQueryRouter = router({
|
||||
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 }) {
|
||||
const apiResponse = await api.get(api.endpoints.v1.profile, {
|
||||
cache: "no-store",
|
||||
|
||||
35
server/tokenManager.ts
Normal file
35
server/tokenManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { env } from "@/env/server"
|
||||
|
||||
import {
|
||||
badRequestError,
|
||||
internalServerError,
|
||||
sessionExpiredError,
|
||||
unauthorizedError,
|
||||
} from "./errors/trpc"
|
||||
import { fetchServiceToken } from "./tokenManager"
|
||||
import { transformer } from "./transformer"
|
||||
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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,3 +5,31 @@ import { Lang } from "@/constants/languages"
|
||||
export const langInput = z.object({
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { bookingWidgetSchema } from "@/components/BookingWidget/schema"
|
||||
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
|
||||
export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema>
|
||||
|
||||
@@ -16,10 +16,12 @@ export enum IconName {
|
||||
Camera = "Camera",
|
||||
Cellphone = "Cellphone",
|
||||
Check = "Check",
|
||||
CrossCircle = "CrossCircle",
|
||||
CheckCircle = "CheckCircle",
|
||||
ChevronDown = "ChevronDown",
|
||||
ChevronRight = "ChevronRight",
|
||||
Close = "Close",
|
||||
CloseLarge = "CloseLarge",
|
||||
Coffee = "Coffee",
|
||||
Concierge = "Concierge",
|
||||
DoorOpen = "DoorOpen",
|
||||
@@ -39,6 +41,8 @@ export enum IconName {
|
||||
Phone = "Phone",
|
||||
PlusCircle = "PlusCircle",
|
||||
Restaurant = "Restaurant",
|
||||
Sauna = "Sauna",
|
||||
TshirtWash = "TshirtWash",
|
||||
Wifi = "Wifi",
|
||||
WarningTriangle = "WarningTriangle",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,9 +10,9 @@ export type TransactionResponse = Awaited<
|
||||
>
|
||||
export type TransactionsNonNullResponseObject = NonNullable<TransactionResponse>
|
||||
export type Transactions =
|
||||
NonNullable<TransactionsNonNullResponseObject>["data"]
|
||||
NonNullable<TransactionsNonNullResponseObject>["data"]["transactions"]
|
||||
export type Transaction =
|
||||
NonNullable<TransactionsNonNullResponseObject>["data"][number]
|
||||
NonNullable<TransactionsNonNullResponseObject>["data"]["transactions"][number]
|
||||
|
||||
export type ClientEarnAndBurnProps = {
|
||||
initialData: TransactionsNonNullResponseObject
|
||||
@@ -31,6 +31,19 @@ export interface RowProps {
|
||||
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 AwardPointsVariantProps
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type AddCreditCardButtonProps = {
|
||||
redirectUrl: string
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
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 Hotel = HotelData["data"]["attributes"]
|
||||
export type HotelAddress = HotelData["data"]["attributes"]["address"]
|
||||
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
6
types/tokens.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ServiceTokenResponse {
|
||||
access_token: string
|
||||
scope?: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
@@ -5,12 +5,3 @@ export function findLang(pathname: string) {
|
||||
(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)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TrackingPosition } from "@/types/components/tracking"
|
||||
|
||||
export function trackClick(name: string) {
|
||||
if (window.adobeDataLayer) {
|
||||
if (typeof window !== "undefined" && window.adobeDataLayer) {
|
||||
window.adobeDataLayer.push({
|
||||
event: "linkClick",
|
||||
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) {
|
||||
if (window.adobeDataLayer) {
|
||||
if (typeof window !== "undefined" && window.adobeDataLayer) {
|
||||
const loginEvent = {
|
||||
event: "loginStart",
|
||||
login: {
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import "server-only"
|
||||
|
||||
import { headers } from "next/headers"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
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) {
|
||||
const searchParams = webviewSearchParams()
|
||||
const urlWithoutLang = url.replace(`/${lang}`, "")
|
||||
|
||||
const webviewUrl = `/${lang}/webview${urlWithoutLang}`
|
||||
if (webviews.includes(webviewUrl) || url.startsWith("/webview")) {
|
||||
return webviewUrl
|
||||
return `${webviewUrl}?${searchParams.toString()}`
|
||||
} else {
|
||||
return url
|
||||
return `${url}?${searchParams.toString()}`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user