Merge branch 'develop' into feature/tracking
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|||||||
@@ -5,4 +5,6 @@
|
|||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.layout {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background-color: var(--Scandic-Brand-Warm-White);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Spacing-x3) var(--Spacing-x9);
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
margin: var(--Spacing-x5) auto 0;
|
||||||
|
max-width: var(--max-width-navigation);
|
||||||
|
padding: var(--Spacing-x6) var(--Spacing-x2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
align-self: flex-start;
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
grid-row: 1/-1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
|
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||||
|
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
|
||||||
|
import Summary from "@/components/HotelReservation/SelectRate/Summary"
|
||||||
|
|
||||||
|
import styles from "./layout.module.css"
|
||||||
|
|
||||||
|
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||||
|
|
||||||
|
export default async function StepLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||||
|
const hotel = await serverClient().hotel.hotelData.get({
|
||||||
|
hotelId: "811",
|
||||||
|
language: params.lang,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hotel?.data) {
|
||||||
|
redirect(`/${params.lang}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.layout}>
|
||||||
|
<HotelSelectionHeader hotel={hotel.data.attributes} />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<SelectedRoom />
|
||||||
|
{children}
|
||||||
|
<aside className={styles.summary}>
|
||||||
|
<Summary />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
.page {
|
|
||||||
min-height: 100dvh;
|
|
||||||
padding-top: var(--Spacing-x6);
|
|
||||||
padding-left: var(--Spacing-x2);
|
|
||||||
padding-right: var(--Spacing-x2);
|
|
||||||
background-color: var(--Scandic-Brand-Warm-White);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
max-width: 1134px;
|
|
||||||
margin-top: var(--Spacing-x5);
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--Spacing-x7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
max-width: 340px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
@@ -9,15 +9,11 @@ import { trpc } from "@/lib/trpc/client"
|
|||||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||||
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
|
|
||||||
import Payment from "@/components/HotelReservation/SelectRate/Payment"
|
import Payment from "@/components/HotelReservation/SelectRate/Payment"
|
||||||
import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion"
|
import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion"
|
||||||
import Summary from "@/components/HotelReservation/SelectRate/Summary"
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
import styles from "./page.module.css"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
import { LangParams, PageArgs } from "@/types/params"
|
|
||||||
|
|
||||||
enum StepEnum {
|
enum StepEnum {
|
||||||
selectBed = "select-bed",
|
selectBed = "select-bed",
|
||||||
@@ -75,51 +71,43 @@ export default function StepPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.page}>
|
<section>
|
||||||
<HotelSelectionHeader hotel={hotel.data.attributes} />
|
<SectionAccordion
|
||||||
<div className={styles.content}>
|
header="Select bed"
|
||||||
<section className={styles.section}>
|
isCompleted={true}
|
||||||
<SectionAccordion
|
isOpen={activeStep === StepEnum.selectBed}
|
||||||
header="Select bed"
|
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||||
isCompleted={true}
|
path="/select-bed"
|
||||||
isOpen={activeStep === StepEnum.selectBed}
|
>
|
||||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
<BedType />
|
||||||
path="/select-bed"
|
</SectionAccordion>
|
||||||
>
|
<SectionAccordion
|
||||||
<BedType />
|
header="Food options"
|
||||||
</SectionAccordion>
|
isCompleted={true}
|
||||||
<SectionAccordion
|
isOpen={activeStep === StepEnum.breakfast}
|
||||||
header="Food options"
|
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||||
isCompleted={true}
|
path="/breakfast"
|
||||||
isOpen={activeStep === StepEnum.breakfast}
|
>
|
||||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
<Breakfast />
|
||||||
path="/breakfast"
|
</SectionAccordion>
|
||||||
>
|
<SectionAccordion
|
||||||
<Breakfast />
|
header="Details"
|
||||||
</SectionAccordion>
|
isCompleted={false}
|
||||||
<SectionAccordion
|
isOpen={activeStep === StepEnum.details}
|
||||||
header="Details"
|
label={intl.formatMessage({ id: "Enter your details" })}
|
||||||
isCompleted={false}
|
path="/details"
|
||||||
isOpen={activeStep === StepEnum.details}
|
>
|
||||||
label={intl.formatMessage({ id: "Enter your details" })}
|
<Details user={null} />
|
||||||
path="/details"
|
</SectionAccordion>
|
||||||
>
|
<SectionAccordion
|
||||||
<Details user={null} />
|
header="Payment"
|
||||||
</SectionAccordion>
|
isCompleted={false}
|
||||||
<SectionAccordion
|
isOpen={activeStep === StepEnum.payment}
|
||||||
header="Payment"
|
label={intl.formatMessage({ id: "Select payment method" })}
|
||||||
isCompleted={false}
|
path="/hotelreservation/select-bed"
|
||||||
isOpen={activeStep === StepEnum.payment}
|
>
|
||||||
label={intl.formatMessage({ id: "Select payment method" })}
|
<Payment hotel={hotel.data.attributes} />
|
||||||
path="/hotelreservation/select-bed"
|
</SectionAccordion>
|
||||||
>
|
</section>
|
||||||
<Payment hotel={hotel.data.attributes} />
|
|
||||||
</SectionAccordion>
|
|
||||||
</section>
|
|
||||||
<aside className={styles.summary}>
|
|
||||||
<Summary />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
.layout {
|
.layout {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
background-color: var(--Base-Background-Primary-Normal);
|
background-color: var(--Base-Background-Primary-Normal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default async function AmenitiesList({
|
|||||||
className={styles.showAllAmenities}
|
className={styles.showAllAmenities}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "Show all amenities" })}
|
{intl.formatMessage({ id: "Show all amenities" })}
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon color="burgundy" />
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { about } from "@/constants/routes/hotelPageParams"
|
import { about } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
|
import { ChevronRightIcon } from "@/components/Icons"
|
||||||
import ArrowRight from "@/components/Icons/ArrowRight"
|
import ArrowRight from "@/components/Icons/ArrowRight"
|
||||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
@@ -76,7 +77,7 @@ export default async function IntroSection({
|
|||||||
scroll={false}
|
scroll={false}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "Read more about the hotel" })}
|
{intl.formatMessage({ id: "Read more about the hotel" })}
|
||||||
<ArrowRight color="burgundy" />
|
<ChevronRightIcon color="burgundy" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function RoomCard({
|
|||||||
textAlign="center"
|
textAlign="center"
|
||||||
type="one"
|
type="one"
|
||||||
color="black"
|
color="black"
|
||||||
|
className={styles.title}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
|
|||||||
@@ -45,6 +45,15 @@
|
|||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:first-child {
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
.imageWrapper {
|
.imageWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
.pageContainer {
|
.pageContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"hotelImages"
|
"hotelImages"
|
||||||
"tabNavigation"
|
"tabNavigation"
|
||||||
@@ -62,6 +60,7 @@
|
|||||||
); /* Full height without the header + booking widget */
|
); /* Full height without the header + booking widget */
|
||||||
max-height: 935px; /* Fixed max according to figma */
|
max-height: 935px; /* Fixed max according to figma */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageContainer > nav {
|
.pageContainer > nav {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 99999;
|
z-index: 99999;
|
||||||
height: var(--current-mobile-site-header-height);
|
height: var(--current-mobile-site-header-height);
|
||||||
|
max-width: var(--max-width-navigation);
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { EditIcon, ImageIcon } from "@/components/Icons"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
|
||||||
|
import styles from "./selectedRoom.module.css"
|
||||||
|
|
||||||
|
export default function SelectedRoom() {
|
||||||
|
const intl = useIntl()
|
||||||
|
return (
|
||||||
|
<article className={styles.container}>
|
||||||
|
<div className={styles.tempImage}>
|
||||||
|
<ImageIcon
|
||||||
|
color="baseButtonTertiaryOnFillNormal"
|
||||||
|
height={60}
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.textContainer}>
|
||||||
|
<Footnote
|
||||||
|
className={styles.label}
|
||||||
|
color="uiTextPlaceholder"
|
||||||
|
textTransform="uppercase"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Your room" })}
|
||||||
|
</Footnote>
|
||||||
|
<div className={styles.text}>
|
||||||
|
{/**
|
||||||
|
* [TEMP]
|
||||||
|
* No translation on Subtitles as they will be derived
|
||||||
|
* from Room selection.
|
||||||
|
*/}
|
||||||
|
<Subtitle
|
||||||
|
className={styles.room}
|
||||||
|
color="uiTextHighContrast"
|
||||||
|
type="two"
|
||||||
|
>
|
||||||
|
Cozy cabin
|
||||||
|
</Subtitle>
|
||||||
|
<Subtitle
|
||||||
|
className={styles.invertFontWeight}
|
||||||
|
color="uiTextMediumContrast"
|
||||||
|
type="two"
|
||||||
|
>
|
||||||
|
Free rebooking
|
||||||
|
</Subtitle>
|
||||||
|
<Subtitle
|
||||||
|
className={styles.invertFontWeight}
|
||||||
|
color="uiTextMediumContrast"
|
||||||
|
type="two"
|
||||||
|
>
|
||||||
|
Pay now
|
||||||
|
</Subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
intent="tertiary"
|
||||||
|
size="small"
|
||||||
|
theme="base"
|
||||||
|
variant="icon"
|
||||||
|
>
|
||||||
|
<Link href="#">
|
||||||
|
<EditIcon color="baseButtonTertiaryOnFillNormal" />
|
||||||
|
{intl.formatMessage({ id: "Modify" })}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.container {
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border-radius: var(--Corner-radius-Large);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 144px 1fr;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
padding: var(--Spacing-x2) var(--Spacing-x4) var(--Spacing-x2)
|
||||||
|
var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tempImage {
|
||||||
|
align-items: center;
|
||||||
|
background-color: lightgray;
|
||||||
|
border-radius: var(--Corner-radius-Medium);
|
||||||
|
display: flex;
|
||||||
|
height: auto;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textContainer {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.invertFontWeight {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invertFontWeight:not(:last-of-type)::after,
|
||||||
|
.room::after {
|
||||||
|
color: var(--UI-Text-Medium-contrast);
|
||||||
|
content: "∙";
|
||||||
|
padding-left: var(--Spacing-x1);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
border-radius: var(--Corner-radius-Medium);
|
border-radius: var(--Corner-radius-Medium);
|
||||||
width: 307px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imageContainer {
|
.imageContainer {
|
||||||
@@ -72,7 +72,6 @@
|
|||||||
"image hotel"
|
"image hotel"
|
||||||
"image prices";
|
"image prices";
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 1050px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
.hotelSelectionHeader {
|
.hotelSelectionHeader {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--Base-Surface-Subtle-Normal);
|
background-color: var(--Base-Surface-Subtle-Normal);
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||||
justify-content: center;
|
}
|
||||||
|
|
||||||
|
.hotelSelectionHeaderWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleContainer {
|
.titleContainer {
|
||||||
@@ -33,9 +36,15 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.hotelSelectionHeader {
|
.hotelSelectionHeader {
|
||||||
flex-direction: row;
|
|
||||||
padding: var(--Spacing-x4) var(--Spacing-x5);
|
padding: var(--Spacing-x4) var(--Spacing-x5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelSelectionHeaderWrapper {
|
||||||
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x6);
|
gap: var(--Spacing-x6);
|
||||||
|
max-width: var(--max-width-navigation);
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleContainer > h1 {
|
.titleContainer > h1 {
|
||||||
|
|||||||
@@ -19,35 +19,35 @@ export default function HotelSelectionHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.hotelSelectionHeader}>
|
<header className={styles.hotelSelectionHeader}>
|
||||||
<div className={styles.titleContainer}>
|
<div className={styles.hotelSelectionHeaderWrapper}>
|
||||||
<Title as="h3" level="h1">
|
<div className={styles.titleContainer}>
|
||||||
{hotel.name}
|
<Title as="h3" level="h1">
|
||||||
</Title>
|
{hotel.name}
|
||||||
<address className={styles.address}>
|
</Title>
|
||||||
<Caption color="textMediumContrast">
|
<address className={styles.address}>
|
||||||
{hotel.address.streetAddress}, {hotel.address.city}
|
<Caption color="textMediumContrast">
|
||||||
</Caption>
|
{hotel.address.streetAddress}, {hotel.address.city}
|
||||||
<div>
|
</Caption>
|
||||||
<Divider variant="vertical" color="subtle" />
|
<div>
|
||||||
</div>
|
<Divider variant="vertical" color="subtle" />
|
||||||
<Caption color="textMediumContrast">
|
</div>
|
||||||
{intl.formatMessage(
|
<Caption color="textMediumContrast">
|
||||||
{
|
{intl.formatMessage(
|
||||||
id: "Distance to city centre",
|
{ id: "Distance to city centre" },
|
||||||
},
|
{ number: hotel.location.distanceToCentre }
|
||||||
{ number: hotel.location.distanceToCentre }
|
)}
|
||||||
)}
|
</Caption>
|
||||||
</Caption>
|
</address>
|
||||||
</address>
|
</div>
|
||||||
</div>
|
<div className={styles.dividerContainer}>
|
||||||
<div className={styles.dividerContainer}>
|
<Divider variant="vertical" color="subtle" />
|
||||||
<Divider variant="vertical" color="subtle" />
|
</div>
|
||||||
</div>
|
<div className={styles.descriptionContainer}>
|
||||||
<div className={styles.descriptionContainer}>
|
<Body color="textHighContrast">
|
||||||
<Body color="textHighContrast">
|
{hotel.hotelContent.texts.descriptions.short}
|
||||||
{hotel.hotelContent.texts.descriptions.short}
|
</Body>
|
||||||
</Body>
|
<HotelDetailSidePeek />
|
||||||
<HotelDetailSidePeek />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function Payment({ hotel }: PaymentProps) {
|
|||||||
name="payment-method"
|
name="payment-method"
|
||||||
id="card"
|
id="card"
|
||||||
value="card"
|
value="card"
|
||||||
checked={selectedPaymentMethod === "card"}
|
defaultChecked={selectedPaymentMethod === "card"}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="card">card</label>
|
<label htmlFor="card">card</label>
|
||||||
</button>
|
</button>
|
||||||
@@ -145,7 +145,7 @@ export default function Payment({ hotel }: PaymentProps) {
|
|||||||
name="payment-method"
|
name="payment-method"
|
||||||
id={paymentOption}
|
id={paymentOption}
|
||||||
value={paymentOption}
|
value={paymentOption}
|
||||||
checked={selectedPaymentMethod === paymentOption}
|
defaultChecked={selectedPaymentMethod === paymentOption}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={paymentOption}>{paymentOption}</label>
|
<label htmlFor={paymentOption}>{paymentOption}</label>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: var(--Spacing-x1);
|
top: var(--Spacing-x1);
|
||||||
z-index: 10;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
|
|||||||
@@ -66,3 +66,8 @@
|
|||||||
.blue * {
|
.blue * {
|
||||||
fill: var(--UI-Input-Controls-Fill-Selected);
|
fill: var(--UI-Input-Controls-Fill-Selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.baseButtonTertiaryOnFillNormal,
|
||||||
|
.baseButtonTertiaryOnFillNormal * {
|
||||||
|
fill: var(--Base-Button-Tertiary-On-Fill-Normal);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import styles from "./icon.module.css"
|
|||||||
const config = {
|
const config = {
|
||||||
variants: {
|
variants: {
|
||||||
color: {
|
color: {
|
||||||
|
baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal,
|
||||||
baseIconLowContrast: styles.baseIconLowContrast,
|
baseIconLowContrast: styles.baseIconLowContrast,
|
||||||
black: styles.black,
|
black: styles.black,
|
||||||
|
blue: styles.blue,
|
||||||
burgundy: styles.burgundy,
|
burgundy: styles.burgundy,
|
||||||
|
green: styles.green,
|
||||||
grey80: styles.grey80,
|
grey80: styles.grey80,
|
||||||
pale: styles.pale,
|
pale: styles.pale,
|
||||||
peach80: styles.peach80,
|
peach80: styles.peach80,
|
||||||
primaryLightOnSurfaceAccent: styles.plosa,
|
primaryLightOnSurfaceAccent: styles.plosa,
|
||||||
red: styles.red,
|
red: styles.red,
|
||||||
green: styles.green,
|
|
||||||
white: styles.white,
|
white: styles.white,
|
||||||
uiTextHighContrast: styles.uiTextHighContrast,
|
uiTextHighContrast: styles.uiTextHighContrast,
|
||||||
uiTextMediumContrast: styles.uiTextMediumContrast,
|
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||||
blue: styles.blue,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ export default function FullView({
|
|||||||
className={styles.fullViewImage}
|
className={styles.fullViewImage}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={image.url}
|
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
layout="fill"
|
fill
|
||||||
objectFit="cover"
|
src={image.url}
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.fullViewFooter}>
|
<div className={styles.fullViewFooter}>
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
.desktopGallery {
|
.desktopGallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1.71875rem 1fr 7.8125rem;
|
grid-template-rows: 1.71875rem 1fr 7.8125rem;
|
||||||
|
row-gap: var(--Spacing-x-one-and-half);
|
||||||
background-color: var(--Base-Background-Primary-Normal);
|
background-color: var(--Base-Background-Primary-Normal);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ a.default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.btn.icon:is(.small, .medium, .large) {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Spacing-x-half);
|
gap: var(--Spacing-x1);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,8 @@ a.default {
|
|||||||
font-size: var(--typography-Caption-Bold-fontSize);
|
font-size: var(--typography-Caption-Bold-fontSize);
|
||||||
line-height: var(--typography-Caption-Bold-lineHeight);
|
line-height: var(--typography-Caption-Bold-lineHeight);
|
||||||
gap: var(--Spacing-x-quarter);
|
gap: var(--Spacing-x-quarter);
|
||||||
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2); /* Special case padding to adjust the missing border */
|
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2);
|
||||||
|
/* Special case padding to adjust the missing border */
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.small.secondary {
|
.btn.small.secondary {
|
||||||
@@ -85,7 +86,8 @@ a.default {
|
|||||||
|
|
||||||
.btn.medium {
|
.btn.medium {
|
||||||
gap: var(--Spacing-x-half);
|
gap: var(--Spacing-x-half);
|
||||||
padding: calc(var(--Spacing-x-one-and-half) + 2px) var(--Spacing-x2); /* Special case padding to adjust the missing border */
|
padding: calc(var(--Spacing-x-one-and-half) + 2px) var(--Spacing-x2);
|
||||||
|
/* Special case padding to adjust the missing border */
|
||||||
}
|
}
|
||||||
|
|
||||||
.medium.secondary {
|
.medium.secondary {
|
||||||
@@ -94,7 +96,8 @@ a.default {
|
|||||||
|
|
||||||
.btn.large {
|
.btn.large {
|
||||||
gap: var(--Spacing-x-half);
|
gap: var(--Spacing-x-half);
|
||||||
padding: calc(var(--Spacing-x2) + 2px) var(--Spacing-x3); /* Special case padding to adjust the missing border */
|
padding: calc(var(--Spacing-x2) + 2px) var(--Spacing-x3);
|
||||||
|
/* Special case padding to adjust the missing border */
|
||||||
}
|
}
|
||||||
|
|
||||||
.large.secondary {
|
.large.secondary {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
0.3vw + 15px,
|
0.3vw + 15px,
|
||||||
var(--typography-Subtitle-1-Desktop-fontSize)
|
var(--typography-Subtitle-1-Desktop-fontSize)
|
||||||
);
|
);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
letter-spacing: var(--typography-Subtitle-1-letterSpacing);
|
letter-spacing: var(--typography-Subtitle-1-letterSpacing);
|
||||||
line-height: var(--typography-Subtitle-1-lineHeight);
|
line-height: var(--typography-Subtitle-1-lineHeight);
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
0.3vw + 15px,
|
0.3vw + 15px,
|
||||||
var(--typography-Subtitle-2-Desktop-fontSize)
|
var(--typography-Subtitle-2-Desktop-fontSize)
|
||||||
);
|
);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
letter-spacing: var(--typography-Subtitle-2-letterSpacing);
|
letter-spacing: var(--typography-Subtitle-2-letterSpacing);
|
||||||
line-height: var(--typography-Subtitle-2-lineHeight);
|
line-height: var(--typography-Subtitle-2-lineHeight);
|
||||||
}
|
}
|
||||||
@@ -62,3 +62,7 @@
|
|||||||
.uiTextHighContrast {
|
.uiTextHighContrast {
|
||||||
color: var(--UI-Text-High-contrast);
|
color: var(--UI-Text-High-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uiTextMediumContrast {
|
||||||
|
color: var(--UI-Text-Medium-contrast);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const config = {
|
|||||||
burgundy: styles.burgundy,
|
burgundy: styles.burgundy,
|
||||||
pale: styles.pale,
|
pale: styles.pale,
|
||||||
uiTextHighContrast: styles.uiTextHighContrast,
|
uiTextHighContrast: styles.uiTextHighContrast,
|
||||||
|
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||||
},
|
},
|
||||||
textAlign: {
|
textAlign: {
|
||||||
center: styles.center,
|
center: styles.center,
|
||||||
|
|||||||
@@ -13,13 +13,12 @@
|
|||||||
"Already a friend?": "Allerede en ven?",
|
"Already a friend?": "Allerede en ven?",
|
||||||
"Amenities": "Faciliteter",
|
"Amenities": "Faciliteter",
|
||||||
"Amusement park": "Forlystelsespark",
|
"Amusement park": "Forlystelsespark",
|
||||||
"An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.",
|
|
||||||
"An error occurred trying to manage your preferences, please try again later.": "Der opstod en fejl under forsøget på at administrere dine præferencer. Prøv venligst igen senere.",
|
"An error occurred trying to manage your preferences, please try again later.": "Der opstod en fejl under forsøget på at administrere dine præferencer. Prøv venligst igen senere.",
|
||||||
|
"An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.",
|
||||||
"An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.",
|
"An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.",
|
||||||
"Any changes you've made will be lost.": "Alle ændringer, du har foretaget, går tabt.",
|
"Any changes you've made will be lost.": "Alle ændringer, du har foretaget, går tabt.",
|
||||||
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på, at du vil fjerne kortet, der slutter me {lastFourDigits} fra din medlemsprofil?",
|
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på, at du vil fjerne kortet, der slutter me {lastFourDigits} fra din medlemsprofil?",
|
||||||
"Arrival date": "Ankomstdato",
|
"Arrival date": "Ankomstdato",
|
||||||
"as of today": "pr. dags dato",
|
|
||||||
"As our": "Som vores {level}",
|
"As our": "Som vores {level}",
|
||||||
"As our Close Friend": "Som vores nære ven",
|
"As our Close Friend": "Som vores nære ven",
|
||||||
"At latest": "Senest",
|
"At latest": "Senest",
|
||||||
@@ -35,9 +34,9 @@
|
|||||||
"Breakfast buffet": "Morgenbuffet",
|
"Breakfast buffet": "Morgenbuffet",
|
||||||
"Breakfast excluded": "Morgenmad ikke inkluderet",
|
"Breakfast excluded": "Morgenmad ikke inkluderet",
|
||||||
"Breakfast included": "Morgenmad inkluderet",
|
"Breakfast included": "Morgenmad inkluderet",
|
||||||
|
"Breakfast restaurant": "Breakfast restaurant",
|
||||||
"Bus terminal": "Busstation",
|
"Bus terminal": "Busstation",
|
||||||
"Business": "Forretning",
|
"Business": "Forretning",
|
||||||
"Breakfast restaurant": "Breakfast restaurant",
|
|
||||||
"Cancel": "Afbestille",
|
"Cancel": "Afbestille",
|
||||||
"Check in": "Check ind",
|
"Check in": "Check ind",
|
||||||
"Check out": "Check ud",
|
"Check out": "Check ud",
|
||||||
@@ -83,9 +82,9 @@
|
|||||||
"Edit profile": "Rediger profil",
|
"Edit profile": "Rediger profil",
|
||||||
"Email": "E-mail",
|
"Email": "E-mail",
|
||||||
"Email address": "E-mailadresse",
|
"Email address": "E-mailadresse",
|
||||||
|
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
||||||
"Enter destination or hotel": "Indtast destination eller hotel",
|
"Enter destination or hotel": "Indtast destination eller hotel",
|
||||||
"Enter your details": "Indtast dine oplysninger",
|
"Enter your details": "Indtast dine oplysninger",
|
||||||
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
|
||||||
"Events that make an impression": "Events that make an impression",
|
"Events that make an impression": "Events that make an impression",
|
||||||
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
|
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
|
||||||
"Explore nearby": "Udforsk i nærheden",
|
"Explore nearby": "Udforsk i nærheden",
|
||||||
@@ -121,7 +120,6 @@
|
|||||||
"Join Scandic Friends": "Tilmeld dig Scandic Friends",
|
"Join Scandic Friends": "Tilmeld dig Scandic Friends",
|
||||||
"Join at no cost": "Tilmeld dig uden omkostninger",
|
"Join at no cost": "Tilmeld dig uden omkostninger",
|
||||||
"King bed": "Kingsize-seng",
|
"King bed": "Kingsize-seng",
|
||||||
"km to city center": "km til byens centrum",
|
|
||||||
"Language": "Sprog",
|
"Language": "Sprog",
|
||||||
"Lastname": "Efternavn",
|
"Lastname": "Efternavn",
|
||||||
"Latest searches": "Seneste søgninger",
|
"Latest searches": "Seneste søgninger",
|
||||||
@@ -209,11 +207,11 @@
|
|||||||
"Read more about the hotel": "Læs mere om hotellet",
|
"Read more about the hotel": "Læs mere om hotellet",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
|
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
|
||||||
|
"Request bedtype": "Anmod om sengetype",
|
||||||
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
||||||
"Restaurant & Bar": "Restaurant & Bar",
|
"Restaurant & Bar": "Restaurant & Bar",
|
||||||
"Restaurants & Bars": "Restaurants & Bars",
|
"Restaurants & Bars": "Restaurants & Bars",
|
||||||
"Retype new password": "Gentag den nye adgangskode",
|
"Retype new password": "Gentag den nye adgangskode",
|
||||||
"Request bedtype": "Anmod om sengetype",
|
|
||||||
"Room & Terms": "Værelse & Vilkår",
|
"Room & Terms": "Værelse & Vilkår",
|
||||||
"Room facilities": "Værelsesfaciliteter",
|
"Room facilities": "Værelsesfaciliteter",
|
||||||
"Rooms": "Værelser",
|
"Rooms": "Værelser",
|
||||||
@@ -299,11 +297,12 @@
|
|||||||
"Your details": "Dine oplysninger",
|
"Your details": "Dine oplysninger",
|
||||||
"Your level": "Dit niveau",
|
"Your level": "Dit niveau",
|
||||||
"Your points to spend": "Dine brugbare point",
|
"Your points to spend": "Dine brugbare point",
|
||||||
|
"Your room": "Dit værelse",
|
||||||
"Zip code": "Postnummer",
|
"Zip code": "Postnummer",
|
||||||
"Zoo": "Zoo",
|
"Zoo": "Zoo",
|
||||||
"Zoom in": "Zoom ind",
|
"Zoom in": "Zoom ind",
|
||||||
"Zoom out": "Zoom ud",
|
"Zoom out": "Zoom ud",
|
||||||
"as of today": "fra idag",
|
"as of today": "pr. dags dato",
|
||||||
"booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}",
|
"booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}",
|
||||||
"booking.nights": "{totalNights, plural, one {# nat} other {# nætter}}",
|
"booking.nights": "{totalNights, plural, one {# nat} other {# nætter}}",
|
||||||
"booking.rooms": "{totalRooms, plural, one {# værelse} other {# værelser}}",
|
"booking.rooms": "{totalRooms, plural, one {# værelse} other {# værelser}}",
|
||||||
|
|||||||
@@ -207,11 +207,11 @@
|
|||||||
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
|
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen",
|
"Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen",
|
||||||
|
"Request bedtype": "Bettentyp anfragen",
|
||||||
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
||||||
"Restaurant & Bar": "Restaurant & Bar",
|
"Restaurant & Bar": "Restaurant & Bar",
|
||||||
"Restaurants & Bars": "Restaurants & Bars",
|
"Restaurants & Bars": "Restaurants & Bars",
|
||||||
"Retype new password": "Neues Passwort erneut eingeben",
|
"Retype new password": "Neues Passwort erneut eingeben",
|
||||||
"Request bedtype": "Bettentyp anfragen",
|
|
||||||
"Room & Terms": "Zimmer & Bedingungen",
|
"Room & Terms": "Zimmer & Bedingungen",
|
||||||
"Room facilities": "Zimmerausstattung",
|
"Room facilities": "Zimmerausstattung",
|
||||||
"Rooms": "Räume",
|
"Rooms": "Räume",
|
||||||
@@ -297,6 +297,7 @@
|
|||||||
"Your details": "Ihre Angaben",
|
"Your details": "Ihre Angaben",
|
||||||
"Your level": "Dein level",
|
"Your level": "Dein level",
|
||||||
"Your points to spend": "Meine Punkte",
|
"Your points to spend": "Meine Punkte",
|
||||||
|
"Your room": "Ihr Zimmer",
|
||||||
"Zip code": "PLZ",
|
"Zip code": "PLZ",
|
||||||
"Zoo": "Zoo",
|
"Zoo": "Zoo",
|
||||||
"Zoom in": "Vergrößern",
|
"Zoom in": "Vergrößern",
|
||||||
|
|||||||
@@ -120,7 +120,6 @@
|
|||||||
"Join Scandic Friends": "Join Scandic Friends",
|
"Join Scandic Friends": "Join Scandic Friends",
|
||||||
"Join at no cost": "Join at no cost",
|
"Join at no cost": "Join at no cost",
|
||||||
"King bed": "King bed",
|
"King bed": "King bed",
|
||||||
"km to city center": "km to city center",
|
|
||||||
"Language": "Language",
|
"Language": "Language",
|
||||||
"Lastname": "Lastname",
|
"Lastname": "Lastname",
|
||||||
"Latest searches": "Latest searches",
|
"Latest searches": "Latest searches",
|
||||||
@@ -208,6 +207,7 @@
|
|||||||
"Read more about the hotel": "Read more about the hotel",
|
"Read more about the hotel": "Read more about the hotel",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Remove card from member profile": "Remove card from member profile",
|
"Remove card from member profile": "Remove card from member profile",
|
||||||
|
"Request bedtype": "Request bedtype",
|
||||||
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
||||||
"Restaurant & Bar": "Restaurant & Bar",
|
"Restaurant & Bar": "Restaurant & Bar",
|
||||||
"Restaurants & Bars": "Restaurants & Bars",
|
"Restaurants & Bars": "Restaurants & Bars",
|
||||||
@@ -226,10 +226,12 @@
|
|||||||
"See room details": "See room details",
|
"See room details": "See room details",
|
||||||
"See rooms": "See rooms",
|
"See rooms": "See rooms",
|
||||||
"Select a country": "Select a country",
|
"Select a country": "Select a country",
|
||||||
|
"Select breakfast options": "Select breakfast options",
|
||||||
"Select country of residence": "Select country of residence",
|
"Select country of residence": "Select country of residence",
|
||||||
"Select date of birth": "Select date of birth",
|
"Select date of birth": "Select date of birth",
|
||||||
"Select dates": "Select dates",
|
"Select dates": "Select dates",
|
||||||
"Select language": "Select language",
|
"Select language": "Select language",
|
||||||
|
"Select payment method": "Select payment method",
|
||||||
"Select your language": "Select your language",
|
"Select your language": "Select your language",
|
||||||
"Shopping": "Shopping",
|
"Shopping": "Shopping",
|
||||||
"Shopping & Dining": "Shopping & Dining",
|
"Shopping & Dining": "Shopping & Dining",
|
||||||
@@ -295,6 +297,7 @@
|
|||||||
"Your details": "Your details",
|
"Your details": "Your details",
|
||||||
"Your level": "Your level",
|
"Your level": "Your level",
|
||||||
"Your points to spend": "Your points to spend",
|
"Your points to spend": "Your points to spend",
|
||||||
|
"Your room": "Your room",
|
||||||
"Zip code": "Zip code",
|
"Zip code": "Zip code",
|
||||||
"Zoo": "Zoo",
|
"Zoo": "Zoo",
|
||||||
"Zoom in": "Zoom in",
|
"Zoom in": "Zoom in",
|
||||||
@@ -315,9 +318,6 @@
|
|||||||
"number": "number",
|
"number": "number",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"points": "Points",
|
"points": "Points",
|
||||||
"Request bedtype": "Request bedtype",
|
|
||||||
"Select breakfast options": "Select breakfast options",
|
|
||||||
"Select payment method": "Select payment method",
|
|
||||||
"special character": "special character",
|
"special character": "special character",
|
||||||
"spendable points expiring by": "{points} spendable points expiring by {date}",
|
"spendable points expiring by": "{points} spendable points expiring by {date}",
|
||||||
"to": "to",
|
"to": "to",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"Already a friend?": "Oletko jo ystävä?",
|
"Already a friend?": "Oletko jo ystävä?",
|
||||||
"Amenities": "Mukavuudet",
|
"Amenities": "Mukavuudet",
|
||||||
"Amusement park": "Huvipuisto",
|
"Amusement park": "Huvipuisto",
|
||||||
"An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.",
|
|
||||||
"An error occurred trying to manage your preferences, please try again later.": "Asetusten hallinnassa tapahtui virhe. Yritä myöhemmin uudelleen.",
|
"An error occurred trying to manage your preferences, please try again later.": "Asetusten hallinnassa tapahtui virhe. Yritä myöhemmin uudelleen.",
|
||||||
|
"An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.",
|
||||||
"An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.",
|
"An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.",
|
||||||
"Any changes you've made will be lost.": "Kaikki tekemäsi muutokset menetetään.",
|
"Any changes you've made will be lost.": "Kaikki tekemäsi muutokset menetetään.",
|
||||||
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Haluatko varmasti poistaa kortin, joka päättyy numeroon {lastFourDigits} jäsenprofiilistasi?",
|
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Haluatko varmasti poistaa kortin, joka päättyy numeroon {lastFourDigits} jäsenprofiilistasi?",
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
"Breakfast buffet": "Aamiaisbuffet",
|
"Breakfast buffet": "Aamiaisbuffet",
|
||||||
"Breakfast excluded": "Aamiainen ei sisälly",
|
"Breakfast excluded": "Aamiainen ei sisälly",
|
||||||
"Breakfast included": "Aamiainen sisältyy",
|
"Breakfast included": "Aamiainen sisältyy",
|
||||||
|
"Breakfast restaurant": "Breakfast restaurant",
|
||||||
"Bus terminal": "Bussiasema",
|
"Bus terminal": "Bussiasema",
|
||||||
"Business": "Business",
|
"Business": "Business",
|
||||||
"Breakfast restaurant": "Breakfast restaurant",
|
|
||||||
"Cancel": "Peruuttaa",
|
"Cancel": "Peruuttaa",
|
||||||
"Check in": "Sisäänkirjautuminen",
|
"Check in": "Sisäänkirjautuminen",
|
||||||
"Check out": "Uloskirjautuminen",
|
"Check out": "Uloskirjautuminen",
|
||||||
@@ -82,9 +82,9 @@
|
|||||||
"Edit profile": "Muokkaa profiilia",
|
"Edit profile": "Muokkaa profiilia",
|
||||||
"Email": "Sähköposti",
|
"Email": "Sähköposti",
|
||||||
"Email address": "Sähköpostiosoite",
|
"Email address": "Sähköpostiosoite",
|
||||||
|
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
||||||
"Enter destination or hotel": "Anna kohde tai hotelli",
|
"Enter destination or hotel": "Anna kohde tai hotelli",
|
||||||
"Enter your details": "Anna tietosi",
|
"Enter your details": "Anna tietosi",
|
||||||
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
|
||||||
"Events that make an impression": "Events that make an impression",
|
"Events that make an impression": "Events that make an impression",
|
||||||
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
|
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
|
||||||
"Explore nearby": "Tutustu lähialueeseen",
|
"Explore nearby": "Tutustu lähialueeseen",
|
||||||
@@ -120,7 +120,6 @@
|
|||||||
"Join Scandic Friends": "Liity jäseneksi",
|
"Join Scandic Friends": "Liity jäseneksi",
|
||||||
"Join at no cost": "Liity maksutta",
|
"Join at no cost": "Liity maksutta",
|
||||||
"King bed": "King-vuode",
|
"King bed": "King-vuode",
|
||||||
"km to city center": "km keskustaan",
|
|
||||||
"Language": "Kieli",
|
"Language": "Kieli",
|
||||||
"Lastname": "Sukunimi",
|
"Lastname": "Sukunimi",
|
||||||
"Latest searches": "Viimeisimmät haut",
|
"Latest searches": "Viimeisimmät haut",
|
||||||
@@ -208,6 +207,7 @@
|
|||||||
"Read more about the hotel": "Lue lisää hotellista",
|
"Read more about the hotel": "Lue lisää hotellista",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Remove card from member profile": "Poista kortti jäsenprofiilista",
|
"Remove card from member profile": "Poista kortti jäsenprofiilista",
|
||||||
|
"Request bedtype": "Pyydä sänkytyyppiä",
|
||||||
"Restaurant": "{count, plural, one {#Ravintola} other {#Restaurants}}",
|
"Restaurant": "{count, plural, one {#Ravintola} other {#Restaurants}}",
|
||||||
"Restaurant & Bar": "Ravintola & Baari",
|
"Restaurant & Bar": "Ravintola & Baari",
|
||||||
"Restaurants & Bars": "Restaurants & Bars",
|
"Restaurants & Bars": "Restaurants & Bars",
|
||||||
@@ -217,7 +217,6 @@
|
|||||||
"Rooms": "Huoneet",
|
"Rooms": "Huoneet",
|
||||||
"Rooms & Guests": "Huoneet & Vieraat",
|
"Rooms & Guests": "Huoneet & Vieraat",
|
||||||
"Rooms & Guestss": "Huoneet & Vieraat",
|
"Rooms & Guestss": "Huoneet & Vieraat",
|
||||||
"Request bedtype": "Pyydä sänkytyyppiä",
|
|
||||||
"Sauna and gym": "Sauna and gym",
|
"Sauna and gym": "Sauna and gym",
|
||||||
"Save": "Tallenna",
|
"Save": "Tallenna",
|
||||||
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
|
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
|
||||||
@@ -299,6 +298,7 @@
|
|||||||
"Your details": "Tietosi",
|
"Your details": "Tietosi",
|
||||||
"Your level": "Tasosi",
|
"Your level": "Tasosi",
|
||||||
"Your points to spend": "Käytettävissä olevat pisteesi",
|
"Your points to spend": "Käytettävissä olevat pisteesi",
|
||||||
|
"Your room": "Sinun huoneesi",
|
||||||
"Zip code": "Postinumero",
|
"Zip code": "Postinumero",
|
||||||
"Zoo": "Eläintarha",
|
"Zoo": "Eläintarha",
|
||||||
"Zoom in": "Lähennä",
|
"Zoom in": "Lähennä",
|
||||||
|
|||||||
@@ -13,13 +13,12 @@
|
|||||||
"Already a friend?": "Allerede Friend?",
|
"Already a friend?": "Allerede Friend?",
|
||||||
"Amenities": "Fasiliteter",
|
"Amenities": "Fasiliteter",
|
||||||
"Amusement park": "Tivoli",
|
"Amusement park": "Tivoli",
|
||||||
"An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.",
|
|
||||||
"An error occurred trying to manage your preferences, please try again later.": "Det oppstod en feil under forsøket på å administrere innstillingene dine. Prøv igjen senere.",
|
"An error occurred trying to manage your preferences, please try again later.": "Det oppstod en feil under forsøket på å administrere innstillingene dine. Prøv igjen senere.",
|
||||||
|
"An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.",
|
||||||
"An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.",
|
"An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.",
|
||||||
"Any changes you've made will be lost.": "Eventuelle endringer du har gjort, går tapt.",
|
"Any changes you've made will be lost.": "Eventuelle endringer du har gjort, går tapt.",
|
||||||
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på at du vil fjerne kortet som slutter på {lastFourDigits} fra medlemsprofilen din?",
|
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på at du vil fjerne kortet som slutter på {lastFourDigits} fra medlemsprofilen din?",
|
||||||
"Arrival date": "Ankomstdato",
|
"Arrival date": "Ankomstdato",
|
||||||
"as of today": "per i dag",
|
|
||||||
"As our": "Som vår {level}",
|
"As our": "Som vår {level}",
|
||||||
"As our Close Friend": "Som vår nære venn",
|
"As our Close Friend": "Som vår nære venn",
|
||||||
"At latest": "Senest",
|
"At latest": "Senest",
|
||||||
@@ -82,16 +81,16 @@
|
|||||||
"Edit profile": "Rediger profil",
|
"Edit profile": "Rediger profil",
|
||||||
"Email": "E-post",
|
"Email": "E-post",
|
||||||
"Email address": "E-postadresse",
|
"Email address": "E-postadresse",
|
||||||
|
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
||||||
"Enter destination or hotel": "Skriv inn destinasjon eller hotell",
|
"Enter destination or hotel": "Skriv inn destinasjon eller hotell",
|
||||||
"Enter your details": "Skriv inn detaljene dine",
|
"Enter your details": "Skriv inn detaljene dine",
|
||||||
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
|
||||||
"Events that make an impression": "Events that make an impression",
|
"Events that make an impression": "Events that make an impression",
|
||||||
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
|
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
|
||||||
"Explore nearby": "Utforsk i nærheten",
|
"Explore nearby": "Utforsk i nærheten",
|
||||||
"Extras to your booking": "Tilvalg til bestillingen din",
|
"Extras to your booking": "Tilvalg til bestillingen din",
|
||||||
|
"FAQ": "FAQ",
|
||||||
"Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.",
|
"Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.",
|
||||||
"Fair": "Messe",
|
"Fair": "Messe",
|
||||||
"FAQ": "FAQ",
|
|
||||||
"Find booking": "Finn booking",
|
"Find booking": "Finn booking",
|
||||||
"Find hotels": "Finn hotell",
|
"Find hotels": "Finn hotell",
|
||||||
"Firstname": "Fornavn",
|
"Firstname": "Fornavn",
|
||||||
@@ -120,7 +119,6 @@
|
|||||||
"Join Scandic Friends": "Bli med i Scandic Friends",
|
"Join Scandic Friends": "Bli med i Scandic Friends",
|
||||||
"Join at no cost": "Bli med uten kostnad",
|
"Join at no cost": "Bli med uten kostnad",
|
||||||
"King bed": "King-size-seng",
|
"King bed": "King-size-seng",
|
||||||
"km to city center": "km til sentrum",
|
|
||||||
"Language": "Språk",
|
"Language": "Språk",
|
||||||
"Lastname": "Etternavn",
|
"Lastname": "Etternavn",
|
||||||
"Latest searches": "Siste søk",
|
"Latest searches": "Siste søk",
|
||||||
@@ -208,11 +206,11 @@
|
|||||||
"Read more about the hotel": "Les mer om hotellet",
|
"Read more about the hotel": "Les mer om hotellet",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
|
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
|
||||||
|
"Request bedtype": "Be om sengetype",
|
||||||
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
"Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}",
|
||||||
"Restaurant & Bar": "Restaurant & Bar",
|
"Restaurant & Bar": "Restaurant & Bar",
|
||||||
"Restaurants & Bars": "Restaurants & Bars",
|
"Restaurants & Bars": "Restaurants & Bars",
|
||||||
"Retype new password": "Skriv inn nytt passord på nytt",
|
"Retype new password": "Skriv inn nytt passord på nytt",
|
||||||
"Request bedtype": "Be om sengetype",
|
|
||||||
"Room & Terms": "Rom & Vilkår",
|
"Room & Terms": "Rom & Vilkår",
|
||||||
"Room facilities": "Romfasiliteter",
|
"Room facilities": "Romfasiliteter",
|
||||||
"Rooms": "Rom",
|
"Rooms": "Rom",
|
||||||
@@ -298,11 +296,12 @@
|
|||||||
"Your details": "Dine detaljer",
|
"Your details": "Dine detaljer",
|
||||||
"Your level": "Ditt nivå",
|
"Your level": "Ditt nivå",
|
||||||
"Your points to spend": "Dine brukbare poeng",
|
"Your points to spend": "Dine brukbare poeng",
|
||||||
|
"Your room": "Rommet ditt",
|
||||||
"Zip code": "Post kode",
|
"Zip code": "Post kode",
|
||||||
"Zoo": "Dyrehage",
|
"Zoo": "Dyrehage",
|
||||||
"Zoom in": "Zoom inn",
|
"Zoom in": "Zoom inn",
|
||||||
"Zoom out": "Zoom ut",
|
"Zoom out": "Zoom ut",
|
||||||
"as of today": "per idag",
|
"as of today": "per i dag",
|
||||||
"booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}",
|
"booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}",
|
||||||
"booking.nights": "{totalNights, plural, one {# natt} other {# netter}}",
|
"booking.nights": "{totalNights, plural, one {# natt} other {# netter}}",
|
||||||
"booking.rooms": "{totalRooms, plural, one {# rom} other {# rom}}",
|
"booking.rooms": "{totalRooms, plural, one {# rom} other {# rom}}",
|
||||||
|
|||||||
@@ -13,13 +13,12 @@
|
|||||||
"Already a friend?": "Är du redan en vän?",
|
"Already a friend?": "Är du redan en vän?",
|
||||||
"Amenities": "Bekvämligheter",
|
"Amenities": "Bekvämligheter",
|
||||||
"Amusement park": "Nöjespark",
|
"Amusement park": "Nöjespark",
|
||||||
"An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.",
|
|
||||||
"An error occurred trying to manage your preferences, please try again later.": "Ett fel uppstod när du försökte hantera dina inställningar, försök igen senare.",
|
"An error occurred trying to manage your preferences, please try again later.": "Ett fel uppstod när du försökte hantera dina inställningar, försök igen senare.",
|
||||||
|
"An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.",
|
||||||
"An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.",
|
"An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.",
|
||||||
"Any changes you've made will be lost.": "Alla ändringar du har gjort kommer att gå förlorade.",
|
"Any changes you've made will be lost.": "Alla ändringar du har gjort kommer att gå förlorade.",
|
||||||
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Är du säker på att du vill ta bort kortet som slutar med {lastFourDigits} från din medlemsprofil?",
|
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Är du säker på att du vill ta bort kortet som slutar med {lastFourDigits} från din medlemsprofil?",
|
||||||
"Arrival date": "Ankomstdatum",
|
"Arrival date": "Ankomstdatum",
|
||||||
"as of today": "per idag",
|
|
||||||
"As our": "Som vår {level}",
|
"As our": "Som vår {level}",
|
||||||
"As our Close Friend": "Som vår nära vän",
|
"As our Close Friend": "Som vår nära vän",
|
||||||
"At latest": "Senast",
|
"At latest": "Senast",
|
||||||
@@ -35,9 +34,9 @@
|
|||||||
"Breakfast buffet": "Frukostbuffé",
|
"Breakfast buffet": "Frukostbuffé",
|
||||||
"Breakfast excluded": "Frukost ingår ej",
|
"Breakfast excluded": "Frukost ingår ej",
|
||||||
"Breakfast included": "Frukost ingår",
|
"Breakfast included": "Frukost ingår",
|
||||||
|
"Breakfast restaurant": "Breakfast restaurant",
|
||||||
"Bus terminal": "Bussterminal",
|
"Bus terminal": "Bussterminal",
|
||||||
"Business": "Business",
|
"Business": "Business",
|
||||||
"Breakfast restaurant": "Breakfast restaurant",
|
|
||||||
"Cancel": "Avbryt",
|
"Cancel": "Avbryt",
|
||||||
"Check in": "Checka in",
|
"Check in": "Checka in",
|
||||||
"Check out": "Checka ut",
|
"Check out": "Checka ut",
|
||||||
@@ -83,9 +82,9 @@
|
|||||||
"Edit profile": "Redigera profil",
|
"Edit profile": "Redigera profil",
|
||||||
"Email": "E-post",
|
"Email": "E-post",
|
||||||
"Email address": "E-postadress",
|
"Email address": "E-postadress",
|
||||||
|
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
||||||
"Enter destination or hotel": "Ange destination eller hotell",
|
"Enter destination or hotel": "Ange destination eller hotell",
|
||||||
"Enter your details": "Ange dina uppgifter",
|
"Enter your details": "Ange dina uppgifter",
|
||||||
"Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences",
|
|
||||||
"Events that make an impression": "Events that make an impression",
|
"Events that make an impression": "Events that make an impression",
|
||||||
"Explore all levels and benefits": "Utforska alla nivåer och fördelar",
|
"Explore all levels and benefits": "Utforska alla nivåer och fördelar",
|
||||||
"Explore nearby": "Utforska i närheten",
|
"Explore nearby": "Utforska i närheten",
|
||||||
@@ -118,11 +117,9 @@
|
|||||||
"How it works": "Hur det fungerar",
|
"How it works": "Hur det fungerar",
|
||||||
"Image gallery": "Bildgalleri",
|
"Image gallery": "Bildgalleri",
|
||||||
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det gick inte att hantera dina kommunikationsinställningar just nu, försök igen senare eller kontakta supporten om problemet kvarstår.",
|
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det gick inte att hantera dina kommunikationsinställningar just nu, försök igen senare eller kontakta supporten om problemet kvarstår.",
|
||||||
|
|
||||||
"Join Scandic Friends": "Gå med i Scandic Friends",
|
"Join Scandic Friends": "Gå med i Scandic Friends",
|
||||||
"Join at no cost": "Gå med utan kostnad",
|
"Join at no cost": "Gå med utan kostnad",
|
||||||
"King bed": "King size-säng",
|
"King bed": "King size-säng",
|
||||||
"km to city center": "km till stadens centrum",
|
|
||||||
"Language": "Språk",
|
"Language": "Språk",
|
||||||
"Lastname": "Efternamn",
|
"Lastname": "Efternamn",
|
||||||
"Latest searches": "Senaste sökningarna",
|
"Latest searches": "Senaste sökningarna",
|
||||||
@@ -210,11 +207,11 @@
|
|||||||
"Read more about the hotel": "Läs mer om hotellet",
|
"Read more about the hotel": "Läs mer om hotellet",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Remove card from member profile": "Ta bort kortet från medlemsprofilen",
|
"Remove card from member profile": "Ta bort kortet från medlemsprofilen",
|
||||||
|
"Request bedtype": "Request bedtype",
|
||||||
"Restaurant": "{count, plural, one {#Restaurang} other {#Restauranger}}",
|
"Restaurant": "{count, plural, one {#Restaurang} other {#Restauranger}}",
|
||||||
"Restaurant & Bar": "Restaurang & Bar",
|
"Restaurant & Bar": "Restaurang & Bar",
|
||||||
"Restaurants & Bars": "Restaurants & Bars",
|
"Restaurants & Bars": "Restaurants & Bars",
|
||||||
"Retype new password": "Upprepa nytt lösenord",
|
"Retype new password": "Upprepa nytt lösenord",
|
||||||
"Request bedtype": "Request bedtype",
|
|
||||||
"Room & Terms": "Rum & Villkor",
|
"Room & Terms": "Rum & Villkor",
|
||||||
"Room facilities": "Rumfaciliteter",
|
"Room facilities": "Rumfaciliteter",
|
||||||
"Rooms": "Rum",
|
"Rooms": "Rum",
|
||||||
@@ -266,7 +263,6 @@
|
|||||||
"Tripadvisor reviews": "{rating} ({count} recensioner på Tripadvisor)",
|
"Tripadvisor reviews": "{rating} ({count} recensioner på Tripadvisor)",
|
||||||
"Type of bed": "Sängtyp",
|
"Type of bed": "Sängtyp",
|
||||||
"Type of room": "Rumstyp",
|
"Type of room": "Rumstyp",
|
||||||
"uppercase letter": "stor bokstav",
|
|
||||||
"Use bonus cheque": "Använd bonuscheck",
|
"Use bonus cheque": "Använd bonuscheck",
|
||||||
"Use code/voucher": "Använd kod/voucher",
|
"Use code/voucher": "Använd kod/voucher",
|
||||||
"User information": "Användarinformation",
|
"User information": "Användarinformation",
|
||||||
@@ -301,6 +297,7 @@
|
|||||||
"Your details": "Dina uppgifter",
|
"Your details": "Dina uppgifter",
|
||||||
"Your level": "Din nivå",
|
"Your level": "Din nivå",
|
||||||
"Your points to spend": "Dina spenderbara poäng",
|
"Your points to spend": "Dina spenderbara poäng",
|
||||||
|
"Your room": "Ditt rum",
|
||||||
"Zip code": "Postnummer",
|
"Zip code": "Postnummer",
|
||||||
"Zoo": "Djurpark",
|
"Zoo": "Djurpark",
|
||||||
"Zoom in": "Zooma in",
|
"Zoom in": "Zooma in",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { metrics } from "@opentelemetry/api"
|
|||||||
|
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { getVerifiedUser } from "@/server/routers/user/query"
|
import { getVerifiedUser } from "@/server/routers/user/query"
|
||||||
import { bookingServiceProcedure, router } from "@/server/trpc"
|
import { router, serviceProcedure } from "@/server/trpc"
|
||||||
|
|
||||||
import { getMembership } from "@/utils/user"
|
import { getMembership } from "@/utils/user"
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ async function getMembershipNumber(
|
|||||||
|
|
||||||
export const bookingMutationRouter = router({
|
export const bookingMutationRouter = router({
|
||||||
booking: router({
|
booking: router({
|
||||||
create: bookingServiceProcedure
|
create: serviceProcedure
|
||||||
.input(createBookingInput)
|
.input(createBookingInput)
|
||||||
.mutation(async function ({ ctx, input }) {
|
.mutation(async function ({ ctx, input }) {
|
||||||
const { checkInDate, checkOutDate, hotelId } = input
|
const { checkInDate, checkOutDate, hotelId } = input
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { metrics } from "@opentelemetry/api"
|
|||||||
|
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
|
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
|
||||||
import { bookingServiceProcedure, router } from "@/server/trpc"
|
import { router, serviceProcedure } from "@/server/trpc"
|
||||||
|
|
||||||
import { getBookingStatusInput } from "./input"
|
import { getBookingStatusInput } from "./input"
|
||||||
import { createBookingSchema } from "./output"
|
import { createBookingSchema } from "./output"
|
||||||
@@ -17,69 +17,70 @@ const getBookingStatusFailCounter = meter.createCounter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const bookingQueryRouter = router({
|
export const bookingQueryRouter = router({
|
||||||
status: bookingServiceProcedure
|
status: serviceProcedure.input(getBookingStatusInput).query(async function ({
|
||||||
.input(getBookingStatusInput)
|
ctx,
|
||||||
.query(async function ({ ctx, input }) {
|
input,
|
||||||
const { confirmationNumber } = input
|
}) {
|
||||||
getBookingStatusCounter.add(1, { confirmationNumber })
|
const { confirmationNumber } = input
|
||||||
|
getBookingStatusCounter.add(1, { confirmationNumber })
|
||||||
|
|
||||||
const apiResponse = await api.get(
|
const apiResponse = await api.get(
|
||||||
`${api.endpoints.v1.booking}/${confirmationNumber}/status`,
|
`${api.endpoints.v1.booking}/${confirmationNumber}/status`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!apiResponse.ok) {
|
|
||||||
const responseMessage = await apiResponse.text()
|
|
||||||
getBookingStatusFailCounter.add(1, {
|
|
||||||
confirmationNumber,
|
|
||||||
error_type: "http_error",
|
|
||||||
error: responseMessage,
|
|
||||||
})
|
|
||||||
console.error(
|
|
||||||
"api.booking.status error",
|
|
||||||
JSON.stringify({
|
|
||||||
query: { confirmationNumber },
|
|
||||||
error: {
|
|
||||||
status: apiResponse.status,
|
|
||||||
statusText: apiResponse.statusText,
|
|
||||||
text: responseMessage,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
throw serverErrorByStatus(apiResponse.status, apiResponse)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const apiJson = await apiResponse.json()
|
if (!apiResponse.ok) {
|
||||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
const responseMessage = await apiResponse.text()
|
||||||
if (!verifiedData.success) {
|
getBookingStatusFailCounter.add(1, {
|
||||||
getBookingStatusFailCounter.add(1, {
|
confirmationNumber,
|
||||||
confirmationNumber,
|
error_type: "http_error",
|
||||||
error_type: "validation_error",
|
error: responseMessage,
|
||||||
error: JSON.stringify(verifiedData.error),
|
})
|
||||||
})
|
console.error(
|
||||||
console.error(
|
"api.booking.status error",
|
||||||
"api.booking.status validation error",
|
|
||||||
JSON.stringify({
|
|
||||||
query: { confirmationNumber },
|
|
||||||
error: verifiedData.error,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
throw badRequestError()
|
|
||||||
}
|
|
||||||
|
|
||||||
getBookingStatusSuccessCounter.add(1, { confirmationNumber })
|
|
||||||
console.info(
|
|
||||||
"api.booking.status success",
|
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
query: { confirmationNumber },
|
query: { confirmationNumber },
|
||||||
|
error: {
|
||||||
|
status: apiResponse.status,
|
||||||
|
statusText: apiResponse.statusText,
|
||||||
|
text: responseMessage,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return verifiedData.data
|
throw serverErrorByStatus(apiResponse.status, apiResponse)
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
const apiJson = await apiResponse.json()
|
||||||
|
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||||
|
if (!verifiedData.success) {
|
||||||
|
getBookingStatusFailCounter.add(1, {
|
||||||
|
confirmationNumber,
|
||||||
|
error_type: "validation_error",
|
||||||
|
error: JSON.stringify(verifiedData.error),
|
||||||
|
})
|
||||||
|
console.error(
|
||||||
|
"api.booking.status validation error",
|
||||||
|
JSON.stringify({
|
||||||
|
query: { confirmationNumber },
|
||||||
|
error: verifiedData.error,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
throw badRequestError()
|
||||||
|
}
|
||||||
|
|
||||||
|
getBookingStatusSuccessCounter.add(1, { confirmationNumber })
|
||||||
|
console.info(
|
||||||
|
"api.booking.status success",
|
||||||
|
JSON.stringify({
|
||||||
|
query: { confirmationNumber },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return verifiedData.data
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { metrics } from "@opentelemetry/api"
|
import { metrics } from "@opentelemetry/api"
|
||||||
|
import { unstable_cache } from "next/cache"
|
||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
|
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
|
||||||
import { request } from "@/lib/graphql/request"
|
import { request } from "@/lib/graphql/request"
|
||||||
import { Context } from "@/server/context"
|
|
||||||
import { notFound } from "@/server/errors/trpc"
|
import { notFound } from "@/server/errors/trpc"
|
||||||
import {
|
import {
|
||||||
contentStackBaseWithProfileServiceProcedure,
|
|
||||||
contentStackBaseWithProtectedProcedure,
|
contentStackBaseWithProtectedProcedure,
|
||||||
|
contentStackBaseWithServiceProcedure,
|
||||||
router,
|
router,
|
||||||
} from "@/server/trpc"
|
} from "@/server/trpc"
|
||||||
|
|
||||||
@@ -62,69 +62,71 @@ const getAllRewardFailCounter = meter.createCounter(
|
|||||||
"trpc.contentstack.reward.all-fail"
|
"trpc.contentstack.reward.all-fail"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ONE_HOUR = 60 * 60
|
||||||
|
|
||||||
function getUniqueRewardIds(rewardIds: string[]) {
|
function getUniqueRewardIds(rewardIds: string[]) {
|
||||||
const uniqueRewardIds = new Set(rewardIds)
|
const uniqueRewardIds = new Set(rewardIds)
|
||||||
return Array.from(uniqueRewardIds)
|
return Array.from(uniqueRewardIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllApiRewards(ctx: Context & { serviceToken: string }) {
|
const getAllCachedApiRewards = unstable_cache(
|
||||||
const apiResponse = await api.get(api.endpoints.v1.tierRewards, {
|
async function (token) {
|
||||||
cache: undefined, // override defaultOptions
|
const apiResponse = await api.get(api.endpoints.v1.tierRewards, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
// One hour. Since the service token is refreshed every hour, this is the longest cache we can have.
|
|
||||||
next: { revalidate: 60 * 60 },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!apiResponse.ok) {
|
|
||||||
const text = await apiResponse.text()
|
|
||||||
getCurrentRewardFailCounter.add(1, {
|
|
||||||
error_type: "http_error",
|
|
||||||
error: JSON.stringify({
|
|
||||||
status: apiResponse.status,
|
|
||||||
statusText: apiResponse.statusText,
|
|
||||||
text,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
console.error(
|
|
||||||
"api.rewards.tierRewards error ",
|
if (!apiResponse.ok) {
|
||||||
JSON.stringify({
|
const text = await apiResponse.text()
|
||||||
error: {
|
getCurrentRewardFailCounter.add(1, {
|
||||||
|
error_type: "http_error",
|
||||||
|
error: JSON.stringify({
|
||||||
status: apiResponse.status,
|
status: apiResponse.status,
|
||||||
statusText: apiResponse.statusText,
|
statusText: apiResponse.statusText,
|
||||||
text,
|
text,
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
)
|
console.error(
|
||||||
}
|
"api.rewards.tierRewards error ",
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
status: apiResponse.status,
|
||||||
|
statusText: apiResponse.statusText,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const data = await apiResponse.json()
|
const data = await apiResponse.json()
|
||||||
const validatedApiTierRewards = validateApiTierRewardsSchema.safeParse(data)
|
const validatedApiTierRewards = validateApiTierRewardsSchema.safeParse(data)
|
||||||
|
|
||||||
if (!validatedApiTierRewards.success) {
|
if (!validatedApiTierRewards.success) {
|
||||||
getAllRewardFailCounter.add(1, {
|
getAllRewardFailCounter.add(1, {
|
||||||
error_type: "validation_error",
|
error_type: "validation_error",
|
||||||
error: JSON.stringify(validatedApiTierRewards.error),
|
error: JSON.stringify(validatedApiTierRewards.error),
|
||||||
})
|
|
||||||
console.error(validatedApiTierRewards.error)
|
|
||||||
console.error(
|
|
||||||
"api.rewards validation error",
|
|
||||||
JSON.stringify({
|
|
||||||
error: validatedApiTierRewards.error,
|
|
||||||
})
|
})
|
||||||
)
|
console.error(validatedApiTierRewards.error)
|
||||||
return null
|
console.error(
|
||||||
}
|
"api.rewards validation error",
|
||||||
|
JSON.stringify({
|
||||||
|
error: validatedApiTierRewards.error,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return validatedApiTierRewards.data
|
return validatedApiTierRewards.data
|
||||||
}
|
},
|
||||||
|
["getAllApiRewards"],
|
||||||
|
{ revalidate: ONE_HOUR }
|
||||||
|
)
|
||||||
|
|
||||||
async function getCmsRewards(locale: Lang, rewardIds: string[]) {
|
async function getCmsRewards(locale: Lang, rewardIds: string[]) {
|
||||||
const tags = rewardIds.map((id) =>
|
const tags = rewardIds.map((id) =>
|
||||||
generateLoyaltyConfigTag(locale, "reward", id)
|
generateLoyaltyConfigTag(locale, "reward", id)
|
||||||
)
|
)
|
||||||
|
|
||||||
const cmsRewardsResponse = await request<CmsRewardsResponse>(
|
const cmsRewardsResponse = await request<CmsRewardsResponse>(
|
||||||
GetRewards,
|
GetRewards,
|
||||||
{
|
{
|
||||||
@@ -260,13 +262,15 @@ export const rewardQueryRouter = router({
|
|||||||
nextCursor,
|
nextCursor,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
byLevel: contentStackBaseWithProfileServiceProcedure
|
byLevel: contentStackBaseWithServiceProcedure
|
||||||
.input(rewardsByLevelInput)
|
.input(rewardsByLevelInput)
|
||||||
.query(async function ({ input, ctx }) {
|
.query(async function ({ input, ctx }) {
|
||||||
getByLevelRewardCounter.add(1)
|
getByLevelRewardCounter.add(1)
|
||||||
const { level_id } = input
|
const { level_id } = input
|
||||||
|
|
||||||
const allUpcomingApiRewards = await getAllApiRewards(ctx)
|
const allUpcomingApiRewards = await getAllCachedApiRewards(
|
||||||
|
ctx.serviceToken
|
||||||
|
)
|
||||||
|
|
||||||
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
|
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
|
||||||
getByLevelRewardFailCounter.add(1)
|
getByLevelRewardFailCounter.add(1)
|
||||||
@@ -310,11 +314,11 @@ export const rewardQueryRouter = router({
|
|||||||
getByLevelRewardSuccessCounter.add(1)
|
getByLevelRewardSuccessCounter.add(1)
|
||||||
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
|
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
|
||||||
}),
|
}),
|
||||||
all: contentStackBaseWithProfileServiceProcedure
|
all: contentStackBaseWithServiceProcedure
|
||||||
.input(rewardsAllInput)
|
.input(rewardsAllInput)
|
||||||
.query(async function ({ input, ctx }) {
|
.query(async function ({ input, ctx }) {
|
||||||
getAllRewardCounter.add(1)
|
getAllRewardCounter.add(1)
|
||||||
const allApiRewards = await getAllApiRewards(ctx)
|
const allApiRewards = await getAllCachedApiRewards(ctx.serviceToken)
|
||||||
|
|
||||||
if (!allApiRewards) {
|
if (!allApiRewards) {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { metrics } from "@opentelemetry/api"
|
import { metrics } from "@opentelemetry/api"
|
||||||
import { unstable_cache } from "next/cache"
|
|
||||||
|
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
|
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
|
||||||
@@ -11,10 +10,10 @@ import {
|
|||||||
} from "@/server/errors/trpc"
|
} from "@/server/errors/trpc"
|
||||||
import { extractHotelImages } from "@/server/routers/utils/hotels"
|
import { extractHotelImages } from "@/server/routers/utils/hotels"
|
||||||
import {
|
import {
|
||||||
contentStackUidWithHotelServiceProcedure,
|
contentStackUidWithServiceProcedure,
|
||||||
hotelServiceProcedure,
|
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
router,
|
router,
|
||||||
|
serviceProcedure,
|
||||||
} from "@/server/trpc"
|
} from "@/server/trpc"
|
||||||
import { toApiLang } from "@/server/utils"
|
import { toApiLang } from "@/server/utils"
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ import {
|
|||||||
getCitiesByCountry,
|
getCitiesByCountry,
|
||||||
getCountries,
|
getCountries,
|
||||||
getLocations,
|
getLocations,
|
||||||
locationsAffix,
|
|
||||||
TWENTYFOUR_HOURS,
|
TWENTYFOUR_HOURS,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
|
|
||||||
@@ -99,7 +97,7 @@ async function getContentstackData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const hotelQueryRouter = router({
|
export const hotelQueryRouter = router({
|
||||||
get: contentStackUidWithHotelServiceProcedure
|
get: contentStackUidWithServiceProcedure
|
||||||
.input(getHotelInputSchema)
|
.input(getHotelInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { lang, uid } = ctx
|
const { lang, uid } = ctx
|
||||||
@@ -264,7 +262,7 @@ export const hotelQueryRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
availability: router({
|
availability: router({
|
||||||
hotels: hotelServiceProcedure
|
hotels: serviceProcedure
|
||||||
.input(getHotelsAvailabilityInputSchema)
|
.input(getHotelsAvailabilityInputSchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const {
|
const {
|
||||||
@@ -388,7 +386,7 @@ export const hotelQueryRouter = router({
|
|||||||
.flatMap((hotels) => hotels.attributes),
|
.flatMap((hotels) => hotels.attributes),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
rooms: hotelServiceProcedure
|
rooms: serviceProcedure
|
||||||
.input(getRoomsAvailabilityInputSchema)
|
.input(getRoomsAvailabilityInputSchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const {
|
const {
|
||||||
@@ -543,7 +541,7 @@ export const hotelQueryRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
hotelData: router({
|
hotelData: router({
|
||||||
get: hotelServiceProcedure
|
get: serviceProcedure
|
||||||
.input(getlHotelDataInputSchema)
|
.input(getlHotelDataInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { hotelId, language, include } = input
|
const { hotelId, language, include } = input
|
||||||
@@ -641,7 +639,7 @@ export const hotelQueryRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
locations: router({
|
locations: router({
|
||||||
get: hotelServiceProcedure.query(async function ({ ctx }) {
|
get: serviceProcedure.query(async function ({ ctx }) {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
searchParams.set("language", toApiLang(ctx.lang))
|
searchParams.set("language", toApiLang(ctx.lang))
|
||||||
|
|
||||||
@@ -657,36 +655,19 @@ export const hotelQueryRouter = router({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCachedCountries = unstable_cache(
|
const countries = await getCountries(options, searchParams, ctx.lang)
|
||||||
getCountries,
|
|
||||||
[`${ctx.lang}:${locationsAffix}:countries`],
|
|
||||||
{ revalidate: TWENTYFOUR_HOURS }
|
|
||||||
)
|
|
||||||
|
|
||||||
const countries = await getCachedCountries(options, searchParams)
|
|
||||||
|
|
||||||
const getCachedCitiesByCountry = unstable_cache(
|
|
||||||
getCitiesByCountry,
|
|
||||||
[`${ctx.lang}:${locationsAffix}:cities-by-country`],
|
|
||||||
{ revalidate: TWENTYFOUR_HOURS }
|
|
||||||
)
|
|
||||||
|
|
||||||
let citiesByCountry = null
|
let citiesByCountry = null
|
||||||
if (countries) {
|
if (countries) {
|
||||||
citiesByCountry = await getCachedCitiesByCountry(
|
citiesByCountry = await getCitiesByCountry(
|
||||||
countries,
|
countries,
|
||||||
options,
|
options,
|
||||||
searchParams
|
searchParams,
|
||||||
|
ctx.lang
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCachedLocations = unstable_cache(
|
const locations = await getLocations(
|
||||||
getLocations,
|
|
||||||
[`${ctx.lang}:${locationsAffix}`],
|
|
||||||
{ revalidate: TWENTYFOUR_HOURS }
|
|
||||||
)
|
|
||||||
|
|
||||||
const locations = await getCachedLocations(
|
|
||||||
ctx.lang,
|
ctx.lang,
|
||||||
options,
|
options,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
PointOfInterestCategoryNameEnum,
|
PointOfInterestCategoryNameEnum,
|
||||||
PointOfInterestGroupEnum,
|
PointOfInterestGroupEnum,
|
||||||
} from "@/types/hotel"
|
} from "@/types/hotel"
|
||||||
|
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
|
||||||
import type { Lang } from "@/constants/languages"
|
import type { Lang } from "@/constants/languages"
|
||||||
import type { Endpoint } from "@/lib/api/endpoints"
|
import type { Endpoint } from "@/lib/api/endpoints"
|
||||||
|
|
||||||
@@ -54,89 +55,119 @@ export const locationsAffix = "locations"
|
|||||||
export const TWENTYFOUR_HOURS = 60 * 60 * 24
|
export const TWENTYFOUR_HOURS = 60 * 60 * 24
|
||||||
export async function getCity(
|
export async function getCity(
|
||||||
cityUrl: string,
|
cityUrl: string,
|
||||||
options: RequestOptionsWithOutBody
|
options: RequestOptionsWithOutBody,
|
||||||
|
lang: Lang,
|
||||||
|
relationshipCity: HotelLocation["relationships"]["city"]
|
||||||
) {
|
) {
|
||||||
const url = new URL(cityUrl)
|
return unstable_cache(
|
||||||
const cityResponse = await api.get(
|
async function (locationCityUrl: string) {
|
||||||
url.pathname as Endpoint,
|
const url = new URL(locationCityUrl)
|
||||||
options,
|
const cityResponse = await api.get(
|
||||||
url.searchParams
|
url.pathname as Endpoint,
|
||||||
)
|
options,
|
||||||
|
url.searchParams
|
||||||
|
)
|
||||||
|
|
||||||
if (!cityResponse.ok) {
|
if (!cityResponse.ok) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const cityJson = await cityResponse.json()
|
const cityJson = await cityResponse.json()
|
||||||
const city = apiCitySchema.safeParse(cityJson)
|
const city = apiCitySchema.safeParse(cityJson)
|
||||||
if (!city.success) {
|
if (!city.success) {
|
||||||
console.info(`Validation of city failed`)
|
console.info(`Validation of city failed`)
|
||||||
console.info(`cityUrl: ${cityUrl}`)
|
console.info(`cityUrl: ${locationCityUrl}`)
|
||||||
console.error(city.error)
|
console.error(city.error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return city.data
|
return city.data
|
||||||
|
},
|
||||||
|
[cityUrl, `${lang}:${relationshipCity}`],
|
||||||
|
{ revalidate: TWENTYFOUR_HOURS }
|
||||||
|
)(cityUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCountries(
|
export async function getCountries(
|
||||||
options: RequestOptionsWithOutBody,
|
options: RequestOptionsWithOutBody,
|
||||||
params: URLSearchParams
|
params: URLSearchParams,
|
||||||
|
lang: Lang
|
||||||
) {
|
) {
|
||||||
const countryResponse = await api.get(
|
return unstable_cache(
|
||||||
api.endpoints.v1.countries,
|
async function (searchParams) {
|
||||||
options,
|
|
||||||
params
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!countryResponse.ok) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const countriesJson = await countryResponse.json()
|
|
||||||
const countries = apiCountriesSchema.safeParse(countriesJson)
|
|
||||||
if (!countries.success) {
|
|
||||||
console.info(`Validation for countries failed`)
|
|
||||||
console.error(countries.error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return countries.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCitiesByCountry(
|
|
||||||
countries: Countries,
|
|
||||||
options: RequestOptionsWithOutBody,
|
|
||||||
params: URLSearchParams
|
|
||||||
) {
|
|
||||||
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
countries.data.map(async (country) => {
|
|
||||||
const countryResponse = await api.get(
|
const countryResponse = await api.get(
|
||||||
`${api.endpoints.v1.citiesCountry}/${country.name}`,
|
api.endpoints.v1.countries,
|
||||||
options,
|
options,
|
||||||
params
|
searchParams
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!countryResponse.ok) {
|
if (!countryResponse.ok) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const countryJson = await countryResponse.json()
|
const countriesJson = await countryResponse.json()
|
||||||
const citiesByCountry = apiCitiesByCountrySchema.safeParse(countryJson)
|
const countries = apiCountriesSchema.safeParse(countriesJson)
|
||||||
if (!citiesByCountry.success) {
|
if (!countries.success) {
|
||||||
console.info(`Failed to validate Cities by Country payload`)
|
console.info(`Validation for countries failed`)
|
||||||
console.error(citiesByCountry.error)
|
console.error(countries.error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
citiesGroupedByCountry[country.name] = citiesByCountry.data.data
|
return countries.data
|
||||||
return true
|
},
|
||||||
})
|
[`${lang}:${locationsAffix}:countries`, params.toString()],
|
||||||
)
|
{ revalidate: TWENTYFOUR_HOURS }
|
||||||
|
)(params)
|
||||||
|
}
|
||||||
|
|
||||||
return citiesGroupedByCountry
|
export async function getCitiesByCountry(
|
||||||
|
countries: Countries,
|
||||||
|
options: RequestOptionsWithOutBody,
|
||||||
|
params: URLSearchParams,
|
||||||
|
lang: Lang
|
||||||
|
) {
|
||||||
|
return unstable_cache(
|
||||||
|
async function (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
searchedCountries: Countries
|
||||||
|
) {
|
||||||
|
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
searchedCountries.data.map(async (country) => {
|
||||||
|
const countryResponse = await api.get(
|
||||||
|
`${api.endpoints.v1.citiesCountry}/${country.name}`,
|
||||||
|
options,
|
||||||
|
searchParams
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!countryResponse.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryJson = await countryResponse.json()
|
||||||
|
const citiesByCountry =
|
||||||
|
apiCitiesByCountrySchema.safeParse(countryJson)
|
||||||
|
if (!citiesByCountry.success) {
|
||||||
|
console.info(`Failed to validate Cities by Country payload`)
|
||||||
|
console.error(citiesByCountry.error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
citiesGroupedByCountry[country.name] = citiesByCountry.data.data
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return citiesGroupedByCountry
|
||||||
|
},
|
||||||
|
[
|
||||||
|
`${lang}:${locationsAffix}:cities-by-country`,
|
||||||
|
params.toString(),
|
||||||
|
JSON.stringify(countries),
|
||||||
|
],
|
||||||
|
{ revalidate: TWENTYFOUR_HOURS }
|
||||||
|
)(params, countries)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLocations(
|
export async function getLocations(
|
||||||
@@ -145,72 +176,89 @@ export async function getLocations(
|
|||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
citiesByCountry: CitiesGroupedByCountry | null
|
citiesByCountry: CitiesGroupedByCountry | null
|
||||||
) {
|
) {
|
||||||
const apiResponse = await api.get(api.endpoints.v1.locations, options, params)
|
return unstable_cache(
|
||||||
|
async function (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
groupedCitiesByCountry: CitiesGroupedByCountry | null
|
||||||
|
) {
|
||||||
|
const apiResponse = await api.get(
|
||||||
|
api.endpoints.v1.locations,
|
||||||
|
options,
|
||||||
|
searchParams
|
||||||
|
)
|
||||||
|
|
||||||
if (!apiResponse.ok) {
|
if (!apiResponse.ok) {
|
||||||
if (apiResponse.status === 401) {
|
if (apiResponse.status === 401) {
|
||||||
return { error: true, cause: "unauthorized" } as const
|
return { error: true, cause: "unauthorized" } as const
|
||||||
} else if (apiResponse.status === 403) {
|
} else if (apiResponse.status === 403) {
|
||||||
return { error: true, cause: "forbidden" } as const
|
return { error: true, cause: "forbidden" } as const
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiJson = await apiResponse.json()
|
|
||||||
const verifiedLocations = apiLocationsSchema.safeParse(apiJson)
|
|
||||||
if (!verifiedLocations.success) {
|
|
||||||
console.info(`Locations Verification Failed`)
|
|
||||||
console.error(verifiedLocations.error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return await Promise.all(
|
|
||||||
verifiedLocations.data.data.map(async (location) => {
|
|
||||||
if (location.type === "cities") {
|
|
||||||
if (citiesByCountry) {
|
|
||||||
const country = Object.keys(citiesByCountry).find((country) => {
|
|
||||||
if (
|
|
||||||
citiesByCountry[country].find((loc) => loc.name === location.name)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (country) {
|
|
||||||
return {
|
|
||||||
...location,
|
|
||||||
country,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.info(
|
|
||||||
`Location cannot be found in any of the countries cities`
|
|
||||||
)
|
|
||||||
console.info(location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (location.type === "hotels") {
|
|
||||||
if (location.relationships.city?.url) {
|
|
||||||
const getCachedCity = unstable_cache(
|
|
||||||
getCity,
|
|
||||||
[`${lang}:${location.relationships.city}`],
|
|
||||||
{ revalidate: TWENTYFOUR_HOURS }
|
|
||||||
)
|
|
||||||
|
|
||||||
const city = await getCachedCity(
|
|
||||||
location.relationships.city.url,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
if (city) {
|
|
||||||
return deepmerge(location, {
|
|
||||||
relationships: {
|
|
||||||
city,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return location
|
const apiJson = await apiResponse.json()
|
||||||
})
|
const verifiedLocations = apiLocationsSchema.safeParse(apiJson)
|
||||||
)
|
if (!verifiedLocations.success) {
|
||||||
|
console.info(`Locations Verification Failed`)
|
||||||
|
console.error(verifiedLocations.error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
verifiedLocations.data.data.map(async (location) => {
|
||||||
|
if (location.type === "cities") {
|
||||||
|
if (groupedCitiesByCountry) {
|
||||||
|
const country = Object.keys(groupedCitiesByCountry).find(
|
||||||
|
(country) => {
|
||||||
|
if (
|
||||||
|
groupedCitiesByCountry[country].find(
|
||||||
|
(loc) => loc.name === location.name
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (country) {
|
||||||
|
return {
|
||||||
|
...location,
|
||||||
|
country,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info(
|
||||||
|
`Location cannot be found in any of the countries cities`
|
||||||
|
)
|
||||||
|
console.info(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (location.type === "hotels") {
|
||||||
|
if (location.relationships.city?.url) {
|
||||||
|
const city = await getCity(
|
||||||
|
location.relationships.city.url,
|
||||||
|
options,
|
||||||
|
lang,
|
||||||
|
location.relationships.city
|
||||||
|
)
|
||||||
|
if (city) {
|
||||||
|
return deepmerge(location, {
|
||||||
|
relationships: {
|
||||||
|
city,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return location
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
`${lang}:${locationsAffix}`,
|
||||||
|
params.toString(),
|
||||||
|
JSON.stringify(citiesByCountry),
|
||||||
|
],
|
||||||
|
{ revalidate: TWENTYFOUR_HOURS }
|
||||||
|
)(params, citiesByCountry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { metrics } from "@opentelemetry/api"
|
import { metrics } from "@opentelemetry/api"
|
||||||
import { SafeParseSuccess } from "zod"
|
|
||||||
|
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,35 +1,85 @@
|
|||||||
|
import { metrics } from "@opentelemetry/api"
|
||||||
|
import { revalidateTag, unstable_cache } from "next/cache"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
import { generateServiceTokenTag } from "@/utils/generateTag"
|
||||||
|
|
||||||
import { ServiceTokenResponse } from "@/types/tokens"
|
import { ServiceTokenResponse } from "@/types/tokens"
|
||||||
|
|
||||||
const SERVICE_TOKEN_REVALIDATE_SECONDS = 3599 // 59 minutes and 59 seconds.
|
// OpenTelemetry metrics: Service token
|
||||||
|
const meter = metrics.getMeter("trpc.context.serviceToken")
|
||||||
|
const fetchServiceTokenCounter = meter.createCounter(
|
||||||
|
"trpc.context.serviceToken.fetch-new-token"
|
||||||
|
)
|
||||||
|
const fetchTempServiceTokenCounter = meter.createCounter(
|
||||||
|
"trpc.context.serviceToken.fetch-temporary"
|
||||||
|
)
|
||||||
|
const fetchServiceTokenFailCounter = meter.createCounter(
|
||||||
|
"trpc.context.serviceToken.fetch-fail"
|
||||||
|
)
|
||||||
|
|
||||||
export async function fetchServiceToken(
|
async function fetchServiceToken(scopes: string[]) {
|
||||||
scopes: string[]
|
fetchServiceTokenCounter.add(1)
|
||||||
): Promise<ServiceTokenResponse> {
|
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
|
||||||
try {
|
method: "POST",
|
||||||
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
headers: {
|
Accept: "application/json",
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
},
|
||||||
Accept: "application/json",
|
body: new URLSearchParams({
|
||||||
},
|
grant_type: "client_credentials",
|
||||||
body: new URLSearchParams({
|
client_id: env.CURITY_CLIENT_ID_SERVICE,
|
||||||
grant_type: "client_credentials",
|
client_secret: env.CURITY_CLIENT_SECRET_SERVICE,
|
||||||
client_id: env.CURITY_CLIENT_ID_SERVICE,
|
scope: scopes.join(" "),
|
||||||
client_secret: env.CURITY_CLIENT_SECRET_SERVICE,
|
}),
|
||||||
scope: scopes.join(" "),
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
fetchServiceTokenFailCounter.add(1, {
|
||||||
|
error_type: "http_error",
|
||||||
|
error: JSON.stringify({
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
}),
|
}),
|
||||||
next: {
|
|
||||||
revalidate: SERVICE_TOKEN_REVALIDATE_SECONDS,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
throw new Error("Failed to obtain service token")
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
return response.json()
|
||||||
throw new Error("Failed to obtain service token")
|
}
|
||||||
|
|
||||||
|
export async function getServiceToken(): Promise<ServiceTokenResponse> {
|
||||||
|
try {
|
||||||
|
const scopes = ["profile", "hotel", "booking"]
|
||||||
|
const tag = generateServiceTokenTag(scopes)
|
||||||
|
const getCachedJwt = unstable_cache(
|
||||||
|
async (scopes) => {
|
||||||
|
const jwt = await fetchServiceToken(scopes)
|
||||||
|
|
||||||
|
const expiresAt = Date.now() + jwt.expires_in * 1000
|
||||||
|
return { expiresAt, jwt }
|
||||||
|
},
|
||||||
|
[tag],
|
||||||
|
{ tags: [tag] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const cachedJwt = await getCachedJwt(scopes)
|
||||||
|
if (cachedJwt.expiresAt < Date.now()) {
|
||||||
|
console.log(
|
||||||
|
"trpc.context.serviceToken: Service token expired, revalidating tag"
|
||||||
|
)
|
||||||
|
revalidateTag(tag)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"trpc.context.serviceToken: Fetching new temporary service token."
|
||||||
|
)
|
||||||
|
fetchTempServiceTokenCounter.add(1)
|
||||||
|
const newToken = await fetchServiceToken(scopes)
|
||||||
|
return newToken
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return cachedJwt.jwt
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching service token:", error)
|
console.error("Error fetching service token:", error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
unauthorizedError,
|
unauthorizedError,
|
||||||
} from "./errors/trpc"
|
} from "./errors/trpc"
|
||||||
import { type Context, createContext } from "./context"
|
import { type Context, createContext } from "./context"
|
||||||
import { fetchServiceToken } from "./tokenManager"
|
import { getServiceToken } from "./tokenManager"
|
||||||
import { transformer } from "./transformer"
|
import { transformer } from "./transformer"
|
||||||
import { langInput } from "./utils"
|
import { langInput } from "./utils"
|
||||||
|
|
||||||
@@ -121,23 +121,17 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function createServiceProcedure(serviceName: string) {
|
export const serviceProcedure = t.procedure.use(async (opts) => {
|
||||||
return t.procedure.use(async (opts) => {
|
const { access_token } = await getServiceToken()
|
||||||
const { access_token } = await fetchServiceToken([serviceName])
|
if (!access_token) {
|
||||||
if (!access_token) {
|
throw internalServerError(`Failed to obtain service token`)
|
||||||
throw internalServerError(`Failed to obtain ${serviceName} service token`)
|
}
|
||||||
}
|
return opts.next({
|
||||||
return opts.next({
|
ctx: {
|
||||||
ctx: {
|
serviceToken: access_token,
|
||||||
serviceToken: access_token,
|
},
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
export const bookingServiceProcedure = createServiceProcedure("booking")
|
|
||||||
export const hotelServiceProcedure = createServiceProcedure("hotel")
|
|
||||||
export const profileServiceProcedure = createServiceProcedure("profile")
|
|
||||||
|
|
||||||
export const serverActionProcedure = t.procedure.experimental_caller(
|
export const serverActionProcedure = t.procedure.experimental_caller(
|
||||||
experimental_nextAppDirCaller({
|
experimental_nextAppDirCaller({
|
||||||
@@ -168,11 +162,11 @@ export const protectedServerActionProcedure = serverActionProcedure.use(
|
|||||||
|
|
||||||
// NOTE: This is actually save to use, just the implementation could change
|
// NOTE: This is actually save to use, just the implementation could change
|
||||||
// in minor version bumps. Please read: https://trpc.io/docs/faq#unstable
|
// in minor version bumps. Please read: https://trpc.io/docs/faq#unstable
|
||||||
export const contentStackUidWithHotelServiceProcedure =
|
export const contentStackUidWithServiceProcedure =
|
||||||
contentstackExtendedProcedureUID.unstable_concat(hotelServiceProcedure)
|
contentstackExtendedProcedureUID.unstable_concat(serviceProcedure)
|
||||||
|
|
||||||
export const contentStackBaseWithProfileServiceProcedure =
|
export const contentStackBaseWithServiceProcedure =
|
||||||
contentstackBaseProcedure.unstable_concat(profileServiceProcedure)
|
contentstackBaseProcedure.unstable_concat(serviceProcedure)
|
||||||
|
|
||||||
export const contentStackBaseWithProtectedProcedure =
|
export const contentStackBaseWithProtectedProcedure =
|
||||||
contentstackBaseProcedure.unstable_concat(protectedProcedure)
|
contentstackBaseProcedure.unstable_concat(protectedProcedure)
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ export interface LocationSchema extends z.output<typeof apiLocationsSchema> {}
|
|||||||
|
|
||||||
export type Locations = LocationSchema["data"]
|
export type Locations = LocationSchema["data"]
|
||||||
export type Location = Locations[number]
|
export type Location = Locations[number]
|
||||||
|
|
||||||
|
export type CityLocation = Location & { type: "cities" }
|
||||||
|
export type HotelLocation = Location & { type: "hotels" }
|
||||||
|
|||||||
@@ -99,3 +99,13 @@ export function generateLoyaltyConfigTag(
|
|||||||
) {
|
) {
|
||||||
return `${lang}:loyalty_config:${contentTypeUid}:${id}`
|
return `${lang}:loyalty_config:${contentTypeUid}:${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to generate tags for service tokens
|
||||||
|
*
|
||||||
|
* @param serviceTokenScope scope of service token
|
||||||
|
* @returns string
|
||||||
|
*/
|
||||||
|
export function generateServiceTokenTag(scopes: string[]) {
|
||||||
|
return `service_token:${scopes.join("-")}`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user