Merge branch 'develop' into feature/sw-561-design-fixes

This commit is contained in:
Linus Flood
2024-10-09 14:51:44 +02:00
72 changed files with 1215 additions and 298 deletions

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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,10 +71,7 @@ export default function StepPage({
} }
return ( return (
<main className={styles.page}> <section>
<HotelSelectionHeader hotel={hotel.data.attributes} />
<div className={styles.content}>
<section className={styles.section}>
<SectionAccordion <SectionAccordion
header="Select bed" header="Select bed"
isCompleted={true} isCompleted={true}
@@ -116,10 +109,5 @@ export default function StepPage({
<Payment hotel={hotel.data.attributes} /> <Payment hotel={hotel.data.attributes} />
</SectionAccordion> </SectionAccordion>
</section> </section>
<aside className={styles.summary}>
<Summary />
</aside>
</div>
</main>
) )
} }

View File

@@ -2,7 +2,6 @@ import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json" import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection" import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css" import styles from "./page.module.css"
@@ -19,18 +18,15 @@ export default async function SelectRatePage({
// TODO: Use real endpoint. // TODO: Use real endpoint.
const hotel = tempHotelData.data.attributes const hotel = tempHotelData.data.attributes
const rates = await serverClient().hotel.rates.get({ const roomConfigurations = await serverClient().hotel.availability.rooms({
// TODO: pass the correct hotel ID and all other parameters that should be included in the search hotelId: parseInt(searchParams.hotel, 10),
hotelId: searchParams.hotel, roomStayStartDate: "2024-11-02",
roomStayEndDate: "2024-11-03",
adults: 1,
}) })
if (!roomConfigurations) {
// const rates = await serverClient().hotel.availability.getForHotel({ return "No rooms found"
// hotelId: 811, }
// roomStayStartDate: "2024-11-02",
// roomStayEndDate: "2024-11-03",
// adults: 1,
// })
const intl = await getIntl()
return ( return (
<div> <div>
@@ -39,13 +35,7 @@ export default async function SelectRatePage({
<div className={styles.content}> <div className={styles.content}>
<div className={styles.main}> <div className={styles.main}>
<RoomSelection <RoomSelection roomConfigurations={roomConfigurations} />
rates={rates}
// TODO: Get real value
nrOfNights={1}
// TODO: Get real value
nrOfAdults={1}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,107 @@
"use client"
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useState } from "react"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import ScrollWrapper from "@/components/TempDesignSystem/ScrollWrapper"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Table from "@/components/TempDesignSystem/Table"
import styles from "./table.module.css"
import type { TableBlockProps } from "@/types/components/blocks/table"
export default function TableBlock({ data }: TableBlockProps) {
const { columns, rows, totalWidth, heading, preamble } = data
const initialPageSize = 5
const [pageSize, setPageSize] = useState(initialPageSize)
const showMoreVisible = rows.length > initialPageSize
const showLessVisible = pageSize >= rows.length
const columnDefs = columns.map((col) => ({
accessorKey: col.id,
header: col.header,
size: col.width,
}))
const table = useReactTable({
columns: columnDefs,
data: rows,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize,
},
},
})
function handleShowMore() {
setPageSize(showLessVisible ? initialPageSize : rows.length)
}
return (
<SectionContainer>
{heading ? (
<SectionHeader preamble={data.preamble} title={heading} />
) : null}
<div className={styles.tableWrapper}>
<ScrollWrapper>
<Table
width={`${totalWidth}%`}
variant="content"
intent="striped"
layout="fixed"
borderRadius="none"
>
<Table.THead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.TR key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.TH
key={header.id}
width={`${header.column.columnDef.size}%`}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.TH>
))}
</Table.TR>
))}
</Table.THead>
<Table.TBody>
{table.getRowModel().rows.map((row) => (
<Table.TR key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.TD key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.TD>
))}
</Table.TR>
))}
</Table.TBody>
</Table>
</ScrollWrapper>
{showMoreVisible ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={showLessVisible}
intent="table"
/>
) : null}
</div>
</SectionContainer>
)
}

View File

@@ -0,0 +1,6 @@
.tableWrapper {
display: grid;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Small);
overflow: hidden;
}

View File

@@ -5,6 +5,8 @@ import TextCols from "@/components/Blocks/TextCols"
import UspGrid from "@/components/Blocks/UspGrid" import UspGrid from "@/components/Blocks/UspGrid"
import JsonToHtml from "@/components/JsonToHtml" import JsonToHtml from "@/components/JsonToHtml"
import Table from "./Table"
import type { BlocksProps } from "@/types/components/blocks" import type { BlocksProps } from "@/types/components/blocks"
import { BlocksEnums } from "@/types/enums/blocks" import { BlocksEnums } from "@/types/enums/blocks"
@@ -47,6 +49,8 @@ export default function Blocks({ blocks }: BlocksProps) {
title={block.shortcuts.title} title={block.shortcuts.title}
/> />
) )
case BlocksEnums.block.Table:
return <Table data={block.table} />
case BlocksEnums.block.TextCols: case BlocksEnums.block.TextCols:
return <TextCols text_cols={block.text_cols} /> return <TextCols text_cols={block.text_cols} />
case BlocksEnums.block.TextContent: case BlocksEnums.block.TextContent:

View File

@@ -42,7 +42,7 @@
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05); box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10000; z-index: 9;
background-color: var(--Base-Surface-Primary-light-Normal); background-color: var(--Base-Surface-Primary-light-Normal);
} }

View File

@@ -40,6 +40,8 @@
} }
.mainContent { .mainContent {
display: grid;
gap: var(--Spacing-x4);
width: 100%; width: 100%;
} }

View File

@@ -5,7 +5,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { KingBedIcon } from "@/components/Icons" import { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import { bedTypeSchema } from "./schema" import { bedTypeSchema } from "./schema"
@@ -24,9 +24,7 @@ export default function BedType() {
reValidateMode: "onChange", reValidateMode: "onChange",
}) })
// @ts-expect-error - Types mismatch docs as this is const text = intl.formatMessage<React.ReactNode>(
// a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage
const text = intl.formatMessage(
{ id: "<b>Included</b> (based on availability)" }, { id: "<b>Included</b> (based on availability)" },
{ b: (str) => <b>{str}</b> } { b: (str) => <b>{str}</b> }
) )

View File

@@ -5,7 +5,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons" import { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import { breakfastSchema } from "./schema" import { breakfastSchema } from "./schema"
@@ -31,9 +31,7 @@ export default function Breakfast() {
Icon={BreakfastIcon} Icon={BreakfastIcon}
id={breakfastEnum.BREAKFAST} id={breakfastEnum.BREAKFAST}
name="breakfast" name="breakfast"
// @ts-expect-error - Types mismatch docs as this is subtitle={intl.formatMessage<React.ReactNode>(
// a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage
subtitle={intl.formatMessage(
{ id: "<b>{amount} {currency}</b>/night per adult" }, { id: "<b>{amount} {currency}</b>/night per adult" },
{ {
amount: "150", amount: "150",

View File

@@ -4,7 +4,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import CheckboxCard from "@/components/TempDesignSystem/Form/Card/Checkbox" import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
import CountrySelect from "@/components/TempDesignSystem/Form/Country" import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input" import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone" import Phone from "@/components/TempDesignSystem/Form/Phone"
@@ -88,8 +88,9 @@ export default function Details({ user }: DetailsProps) {
<footer className={styles.footer}> <footer className={styles.footer}>
{user ? null : ( {user ? null : (
<CheckboxCard <CheckboxCard
highlightSubtitle
list={list} list={list}
saving name="join"
subtitle={intl.formatMessage( subtitle={intl.formatMessage(
{ {
id: "{difference}{amount} {currency}", id: "{difference}{amount} {currency}",

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -19,6 +19,7 @@ export default function HotelSelectionHeader({
return ( return (
<header className={styles.hotelSelectionHeader}> <header className={styles.hotelSelectionHeader}>
<div className={styles.hotelSelectionHeaderWrapper}>
<div className={styles.titleContainer}> <div className={styles.titleContainer}>
<Title as="h3" level="h1"> <Title as="h3" level="h1">
{hotel.name} {hotel.name}
@@ -32,9 +33,7 @@ export default function HotelSelectionHeader({
</div> </div>
<Caption color="textMediumContrast"> <Caption color="textMediumContrast">
{intl.formatMessage( {intl.formatMessage(
{ { id: "Distance to city centre" },
id: "Distance to city centre",
},
{ number: hotel.location.distanceToCentre } { number: hotel.location.distanceToCentre }
)} )}
</Caption> </Caption>
@@ -49,6 +48,7 @@ export default function HotelSelectionHeader({
</Body> </Body>
<HotelDetailSidePeek /> <HotelDetailSidePeek />
</div> </div>
</div>
</header> </header>
) )
} }

View File

@@ -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>

View File

@@ -9,17 +9,31 @@ import styles from "./flexibilityOption.module.css"
import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption" import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function FlexibilityOption({ export default function FlexibilityOption({
currency, product,
standardPrice,
memberPrice,
name, name,
value,
paymentTerm, paymentTerm,
}: FlexibilityOptionProps) { }: FlexibilityOptionProps) {
const intl = useIntl() const intl = useIntl()
if (!product) {
// TODO: Implement empty state when this rate can't be booked
return <div>TBI: Rate not available</div>
}
const { productType } = product
const { public: publicPrice, member: memberPrice } = productType
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
publicPrice
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
memberPrice
return ( return (
<label> <label>
<input type="radio" name="flexibility" value={value} /> <input
type="radio"
name="rateCode"
value={product.productType.public.rateCode}
/>
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}> <div className={styles.header}>
<Body>{name}</Body> <Body>{name}</Body>
@@ -29,15 +43,27 @@ export default function FlexibilityOption({
<div> <div>
<dt>{intl.formatMessage({ id: "Standard price" })}</dt> <dt>{intl.formatMessage({ id: "Standard price" })}</dt>
<dd> <dd>
{standardPrice} {currency} {publicLocalPrice.pricePerNight} {publicLocalPrice.currency}/
{intl.formatMessage({ id: "night" })}
</dd> </dd>
</div> </div>
<div> <div>
<dt>{intl.formatMessage({ id: "Member price" })}</dt> <dt>{intl.formatMessage({ id: "Member price" })}</dt>
<dd> <dd>
{memberPrice} {currency} {memberLocalPrice.pricePerNight} {memberLocalPrice.currency}/
{intl.formatMessage({ id: "night" })}
</dd> </dd>
</div> </div>
{publicRequestedPrice && memberRequestedPrice && (
<div>
<dt>{intl.formatMessage({ id: "Approx." })}</dt>
<dd>
{publicRequestedPrice.pricePerNight}/
{memberRequestedPrice.pricePerNight}{" "}
{publicRequestedPrice.currency}
</dd>
</div>
)}
</dl> </dl>
</div> </div>
</label> </label>

View File

@@ -11,20 +11,54 @@ import styles from "./roomCard.module.css"
import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard" import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
export default function RoomCard({ export default function RoomCard({
room, rateDefinitions,
nrOfAdults, roomConfiguration,
nrOfNights,
breakfastIncluded,
}: RoomCardProps) { }: RoomCardProps) {
const intl = useIntl() const intl = useIntl()
const saveRate = rateDefinitions.find(
// TODO: Update string when API has decided
(rate) => rate.cancellationRule === "NonCancellable"
)
const changeRate = rateDefinitions.find(
// TODO: Update string when API has decided
(rate) => rate.cancellationRule === "Modifiable"
)
const flexRate = rateDefinitions.find(
// TODO: Update string when API has decided
(rate) => rate.cancellationRule === "CancellableBefore6PM"
)
const saveProduct = saveRate
? roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === saveRate.rateCode ||
product.productType.member.rateCode === saveRate.rateCode
)
: undefined
const changeProduct = changeRate
? roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === changeRate.rateCode ||
product.productType.member.rateCode === changeRate.rateCode
)
: undefined
const flexProduct = flexRate
? roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === flexRate.rateCode ||
product.productType.member.rateCode === flexRate.rateCode
)
: undefined
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardBody}> <div className={styles.cardBody}>
<div className={styles.specification}> <div className={styles.specification}>
<Subtitle className={styles.name} type="two"> <Subtitle className={styles.name} type="two">
{room.name} {roomConfiguration.roomType}
</Subtitle> </Subtitle>
<Caption>{room.size}</Caption> <Caption>Room size TBI</Caption>
<Button intent="text" type="button" size="small" theme="base"> <Button intent="text" type="button" size="small" theme="base">
{intl.formatMessage({ id: "See room details" })} {intl.formatMessage({ id: "See room details" })}
</Button> </Button>
@@ -32,19 +66,14 @@ export default function RoomCard({
{/*TODO: Handle pluralisation*/} {/*TODO: Handle pluralisation*/}
{intl.formatMessage( {intl.formatMessage(
{ {
id: "Nr night, nr adult", id: "Max {nrOfGuests} guests",
defaultMessage: defaultMessage: "Max {nrOfGuests} guests",
"{nights, number} night, {adults, number} adult",
}, },
{ nights: nrOfNights, adults: nrOfAdults } // TODO: Correct number
{ nrOfGuests: 2 }
)} )}
{" | "} {intl.formatMessage({
{breakfastIncluded
? intl.formatMessage({
id: "Breakfast included", id: "Breakfast included",
})
: intl.formatMessage({
id: "Breakfast excluded",
})} })}
</Caption> </Caption>
</div> </div>
@@ -53,25 +82,19 @@ export default function RoomCard({
name={intl.formatMessage({ id: "Non-refundable" })} name={intl.formatMessage({ id: "Non-refundable" })}
value="non-refundable" value="non-refundable"
paymentTerm={intl.formatMessage({ id: "Pay now" })} paymentTerm={intl.formatMessage({ id: "Pay now" })}
standardPrice={room.prices.nonRefundable.standard} product={saveProduct}
memberPrice={room.prices.nonRefundable.member}
currency={room.prices.currency}
/> />
<FlexibilityOption <FlexibilityOption
name={intl.formatMessage({ id: "Free rebooking" })} name={intl.formatMessage({ id: "Free rebooking" })}
value="free-rebooking" value="free-rebooking"
paymentTerm={intl.formatMessage({ id: "Pay now" })} paymentTerm={intl.formatMessage({ id: "Pay now" })}
standardPrice={room.prices.freeRebooking.standard} product={changeProduct}
memberPrice={room.prices.freeRebooking.member}
currency={room.prices.currency}
/> />
<FlexibilityOption <FlexibilityOption
name={intl.formatMessage({ id: "Free cancellation" })} name={intl.formatMessage({ id: "Free cancellation" })}
value="free-cancellation" value="free-cancellation"
paymentTerm={intl.formatMessage({ id: "Pay later" })} paymentTerm={intl.formatMessage({ id: "Pay later" })}
standardPrice={room.prices.freeCancellation.standard} product={flexProduct}
memberPrice={room.prices.freeCancellation.member}
currency={room.prices.currency}
/> />
<Button <Button
@@ -87,7 +110,8 @@ export default function RoomCard({
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
alt={intl.formatMessage({ id: "A photo of the room" })} alt={intl.formatMessage({ id: "A photo of the room" })}
src={room.imageSrc} // TODO: Correct image URL
src="https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg"
/> />
</div> </div>
) )

View File

@@ -1,5 +1,8 @@
"use client" "use client"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import RoomCard from "./RoomCard" import RoomCard from "./RoomCard"
@@ -8,12 +11,11 @@ import styles from "./roomSelection.module.css"
import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection" import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
export default function RoomSelection({ export default function RoomSelection({
rates, roomConfigurations,
nrOfNights,
nrOfAdults,
}: RoomSelectionProps) { }: RoomSelectionProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const intl = useIntl()
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
@@ -25,31 +27,28 @@ export default function RoomSelection({
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<ul className={styles.roomList}>
{rates.map((room) => (
<li key={room.id}>
<form <form
method="GET" method="GET"
action={`select-bed?${searchParams}`} action={`select-bed?${searchParams}`}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<input <ul className={styles.roomList}>
type="hidden" {roomConfigurations.roomConfigurations.map((roomConfiguration) => (
name="roomClass" <li key={roomConfiguration.roomType}>
value={room.id}
id={`room-${room.id}`}
/>
<RoomCard <RoomCard
room={room} rateDefinitions={roomConfigurations.rateDefinitions}
nrOfAdults={nrOfAdults} roomConfiguration={roomConfiguration}
nrOfNights={nrOfNights}
breakfastIncluded={room.breakfastIncluded}
/> />
</form>
</li> </li>
))} ))}
</ul> </ul>
<div className={styles.summary}>This is summary</div> <div className={styles.summary}>
This is summary
<Button type="submit" size="small" theme="primaryDark">
{intl.formatMessage({ id: "Choose room" })}
</Button>
</div>
</form>
</div> </div>
) )
} }

View File

@@ -27,4 +27,6 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background-color: white;
padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5);
} }

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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: {

View File

@@ -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}>

View File

@@ -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 {

View File

@@ -1,6 +1,6 @@
import Card from "." import Card from "./_Card"
import type { CheckboxProps } from "./card" import type { CheckboxProps } from "./_Card/card"
export default function CheckboxCard(props: CheckboxProps) { export default function CheckboxCard(props: CheckboxProps) {
return <Card {...props} type="checkbox" /> return <Card {...props} type="checkbox" />

View File

@@ -1,6 +1,6 @@
import Card from "." import Card from "./_Card"
import type { RadioProps } from "./card" import type { RadioProps } from "./_Card/card"
export default function RadioCard(props: RadioProps) { export default function RadioCard(props: RadioProps) {
return <Card {...props} type="radio" /> return <Card {...props} type="radio" />

View File

@@ -1,14 +1,15 @@
import type { IconProps } from "@/types/components/icon" import type { IconProps } from "@/types/components/icon"
interface BaseCardProps extends React.LabelHTMLAttributes<HTMLLabelElement> { interface BaseCardProps
extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
Icon?: (props: IconProps) => JSX.Element Icon?: (props: IconProps) => JSX.Element
declined?: boolean declined?: boolean
highlightSubtitle?: boolean
iconHeight?: number iconHeight?: number
iconWidth?: number iconWidth?: number
name?: string name: string
saving?: boolean subtitle?: React.ReactNode
subtitle?: string title: React.ReactNode
title: string
type: "checkbox" | "radio" type: "checkbox" | "radio"
value?: string value?: string
} }
@@ -22,7 +23,7 @@ interface ListCardProps extends BaseCardProps {
interface TextCardProps extends BaseCardProps { interface TextCardProps extends BaseCardProps {
list?: never list?: never
text: string text: React.ReactNode
} }
export type CardProps = ListCardProps | TextCardProps export type CardProps = ListCardProps | TextCardProps

View File

@@ -13,10 +13,10 @@ export default function Card({
iconHeight = 32, iconHeight = 32,
iconWidth = 32, iconWidth = 32,
declined = false, declined = false,
highlightSubtitle = false,
id, id,
list, list,
name = "join", name,
saving = false,
subtitle, subtitle,
text, text,
title, title,
@@ -31,7 +31,7 @@ export default function Card({
{subtitle ? ( {subtitle ? (
<Caption <Caption
className={styles.subtitle} className={styles.subtitle}
color={saving ? "baseTextAccent" : "uiTextHighContrast"} color={highlightSubtitle ? "baseTextAccent" : "uiTextHighContrast"}
textTransform="bold" textTransform="bold"
> >
{subtitle} {subtitle}

View File

@@ -0,0 +1,36 @@
"use client"
import { useMemo } from "react"
import useScrollShadows from "@/hooks/useScrollShadows"
import styles from "./scrollWrapper.module.css"
import type { ScrollWrapperProps } from "./scrollWrapper"
export default function ScrollWrapper({
className,
children,
}: ScrollWrapperProps) {
const { containerRef, showLeftShadow, showRightShadow } =
useScrollShadows<HTMLDivElement>()
const classNames = useMemo(() => {
const cls = [styles.scrollWrapper, className]
if (showLeftShadow) {
cls.push(styles.leftShadow)
}
if (showRightShadow) {
cls.push(styles.rightShadow)
}
return cls.join(" ")
}, [showLeftShadow, showRightShadow, className])
return (
<div className={classNames}>
<div className={styles.content} ref={containerRef}>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
.scrollWrapper {
position: relative;
overflow: hidden;
}
.scrollWrapper::before,
.scrollWrapper::after {
content: "";
position: absolute;
top: 0;
height: 100%;
pointer-events: none;
z-index: 1;
transition: opacity 0.2s ease;
opacity: 0;
width: 50px;
}
.scrollWrapper.leftShadow::before {
left: 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0.3), transparent);
opacity: 1;
}
.scrollWrapper.rightShadow::after {
right: 0;
background: linear-gradient(to left, rgba(0, 0, 0, 0.3), transparent);
opacity: 1;
}
.content {
overflow-x: auto;
}

View File

@@ -0,0 +1,2 @@
export interface ScrollWrapperProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>> {}

View File

@@ -1,4 +0,0 @@
.container {
display: flex;
justify-content: center;
}

View File

@@ -5,18 +5,29 @@ import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons" import { ChevronDownIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import styles from "./button.module.css" import { showMoreButtonVariants } from "./variants"
import type { ShowMoreButtonParams } from "@/types/components/myPages/stays/button" import styles from "./showMoreButton.module.css"
import type { ShowMoreButtonProps } from "./showMoreButton"
export default function ShowMoreButton({ export default function ShowMoreButton({
className,
intent,
disabled, disabled,
showLess,
loadMoreData, loadMoreData,
}: ShowMoreButtonParams) { }: ShowMoreButtonProps) {
const { formatMessage } = useIntl() const intl = useIntl()
const classNames = showMoreButtonVariants({
className,
intent,
})
return ( return (
<div className={styles.container}> <div className={`${classNames} ${showLess ? styles.showLess : ""}`}>
<Button <Button
className={styles.button}
disabled={disabled} disabled={disabled}
onClick={loadMoreData} onClick={loadMoreData}
variant="icon" variant="icon"
@@ -24,8 +35,8 @@ export default function ShowMoreButton({
theme="base" theme="base"
intent="text" intent="text"
> >
<ChevronDownIcon /> <ChevronDownIcon className={styles.icon} />
{formatMessage({ id: "Show more" })} {intl.formatMessage({ id: showLess ? "Show less" : "Show more" })}
</Button> </Button>
</div> </div>
) )

View File

@@ -0,0 +1,23 @@
.container {
display: flex;
justify-content: center;
}
.table {
display: grid;
justify-content: stretch;
border-top: 1px solid var(--Base-Border-Subtle);
background-color: var(--Base-Surface-Primary-light-Normal);
}
.table .button {
border-radius: 0;
}
.icon {
transition: transform 0.3s;
}
.showLess .icon {
transform: rotate(180deg);
}

View File

@@ -0,0 +1,11 @@
import { showMoreButtonVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
export interface ShowMoreButtonProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>,
VariantProps<typeof showMoreButtonVariants> {
disabled?: boolean
showLess?: boolean
loadMoreData: () => void
}

View File

@@ -0,0 +1,11 @@
import { cva } from "class-variance-authority"
import styles from "./showMoreButton.module.css"
export const showMoreButtonVariants = cva(styles.container, {
variants: {
intent: {
table: styles.table,
},
},
})

View File

@@ -1,7 +1,13 @@
import styles from "./table.module.css" import styles from "./table.module.css"
function TH({ children }: React.PropsWithChildren) { import type { THeadProps } from "./table"
return <th className={styles.th}>{children}</th>
function TH({ children, width = "auto", ...props }: THeadProps) {
return (
<th className={styles.th} style={{ width }} {...props}>
{children}
</th>
)
} }
export default TH export default TH

View File

@@ -3,11 +3,33 @@ import TD from "./TD"
import TH from "./TH" import TH from "./TH"
import THead from "./THead" import THead from "./THead"
import TR from "./TR" import TR from "./TR"
import { tableVariants } from "./variants"
import styles from "./table.module.css" import type { TableProps } from "./table"
function Table({ children }: React.PropsWithChildren) { function Table({
return <table className={styles.table}>{children}</table> className,
intent,
borderRadius,
variant,
layout,
width = "100%",
children,
...props
}: TableProps) {
const classNames = tableVariants({
className,
borderRadius,
intent,
layout,
variant,
})
return (
<table className={classNames} style={{ width }} {...props}>
{children}
</table>
)
} }
Table.THead = THead Table.THead = THead

View File

@@ -1,20 +1,20 @@
.table { .table {
border-radius: var(--Corner-radius-Medium);
border-collapse: collapse; border-collapse: collapse;
overflow: hidden; overflow: hidden;
width: 100%; min-width: 100%;
} }
.thead { .thead {
background-color: var(--Base-Background-Secondary-Normal, #f7e1d5); color: var(--Base-Text-High-contrast);
background-color: var(--Base-Surface-Primary-dark-Normal);
} }
.tbody { .tbody {
background-color: var(--Base-Surface-Primary-light-Normal, #fff); background-color: var(--Base-Surface-Primary-light-Normal);
} }
.tr:not(:last-of-type) { .tr:not(:last-of-type) {
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider, #f0c1b6); border-bottom: 1px solid var(--Base-Border-Subtle);
} }
.th { .th {
@@ -28,6 +28,35 @@
padding: var(--Spacing-x2); padding: var(--Spacing-x2);
} }
.fixed {
table-layout: fixed;
}
.smallRadius {
border-radius: var(--Corner-radius-Small);
}
.mediumRadius {
border-radius: var(--Corner-radius-Medium);
}
.largeRadius {
border-radius: var(--Corner-radius-Large);
}
.content .thead {
background-color: var(--Base-Surface-Subtle-Hover);
}
.content .tbody {
background-color: var(--Base-Background-Primary-Normal);
}
.content.striped .tbody .tr:nth-child(odd) {
background-color: var(--Base-Surface-Subtle-Normal);
}
.content.striped .tbody .tr:nth-child(even) {
background-color: var(--Base-Background-Primary-Normal);
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.th { .th {
padding: var(--Spacing-x2) var(--Spacing-x3); padding: var(--Spacing-x2) var(--Spacing-x3);

View File

@@ -0,0 +1,14 @@
import { tableVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
export interface TableProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLTableElement>>,
VariantProps<typeof tableVariants> {
width?: string
}
export interface THeadProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLTableCellElement>> {
width?: string
}

View File

@@ -0,0 +1,27 @@
import { cva } from "class-variance-authority"
import styles from "./table.module.css"
export const tableVariants = cva(styles.table, {
variants: {
intent: {
light: styles.light,
striped: styles.striped,
},
variant: {
content: styles.content,
},
borderRadius: {
none: "",
small: styles.smallRadius,
medium: styles.mediumRadius,
large: styles.largeRadius,
},
layout: {
fixed: styles.fixed,
},
},
defaultVariants: {
borderRadius: "medium",
},
})

View File

@@ -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);
}

View File

@@ -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,

34
hooks/useScrollShadows.ts Normal file
View File

@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from "react"
export default function useScrollShadows<T extends HTMLElement>() {
const containerRef = useRef<T>(null)
const [showLeftShadow, setShowLeftShadow] = useState<boolean>(false)
const [showRightShadow, setShowRightShadow] = useState<boolean>(false)
useEffect(() => {
const handleScroll = () => {
const container = containerRef.current
if (!container) return
setShowLeftShadow(container.scrollLeft > 0)
setShowRightShadow(
container.scrollLeft < container.scrollWidth - container.clientWidth
)
}
const container = containerRef.current
if (container) {
container.addEventListener("scroll", handleScroll)
}
handleScroll()
return () => {
if (container) {
container.removeEventListener("scroll", handleScroll)
}
}
}, [])
return { containerRef, showLeftShadow, showRightShadow }
}

View File

@@ -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}}",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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ä",

View File

@@ -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}}",

View File

@@ -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",

View File

@@ -7,6 +7,7 @@ export namespace endpoints {
} }
export const enum v1 { export const enum v1 {
hotelsAvailability = "availability/v1/availabilities/city", hotelsAvailability = "availability/v1/availabilities/city",
roomsAvailability = "availability/v1/availabilities/hotel",
profile = "profile/v1/Profile", profile = "profile/v1/Profile",
booking = "booking/v1/Bookings", booking = "booking/v1/Bookings",
creditCards = `${profile}/creditCards`, creditCards = `${profile}/creditCards`,

View File

@@ -0,0 +1,9 @@
fragment Table_ContentPage on ContentPageBlocksTable {
__typename
table {
heading
preamble
column_widths
table
}
}

View File

@@ -4,6 +4,7 @@
#import "../../Fragments/Blocks/Content.graphql" #import "../../Fragments/Blocks/Content.graphql"
#import "../../Fragments/Blocks/DynamicContent.graphql" #import "../../Fragments/Blocks/DynamicContent.graphql"
#import "../../Fragments/Blocks/Shortcuts.graphql" #import "../../Fragments/Blocks/Shortcuts.graphql"
#import "../../Fragments/Blocks/Table.graphql"
#import "../../Fragments/Blocks/TextCols.graphql" #import "../../Fragments/Blocks/TextCols.graphql"
#import "../../Fragments/Blocks/UspGrid.graphql" #import "../../Fragments/Blocks/UspGrid.graphql"
@@ -28,6 +29,7 @@ query GetContentPage($locale: String!, $uid: String!) {
...Content_ContentPage ...Content_ContentPage
...DynamicContent_ContentPage ...DynamicContent_ContentPage
...Shortcuts_ContentPage ...Shortcuts_ContentPage
...Table_ContentPage
...TextCols_ContentPage ...TextCols_ContentPage
...UspGrid_ContentPage ...UspGrid_ContentPage
} }

32
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9", "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9",
"@t3-oss/env-nextjs": "^0.9.2", "@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
"@tanstack/react-table": "^8.20.5",
"@trpc/client": "^11.0.0-rc.467", "@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467", "@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467", "@trpc/server": "^11.0.0-rc.467",
@@ -5971,6 +5972,37 @@
"react": "^18.0.0" "react": "^18.0.0"
} }
}, },
"node_modules/@tanstack/react-table": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz",
"integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz",

View File

@@ -39,6 +39,7 @@
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9", "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9",
"@t3-oss/env-nextjs": "^0.9.2", "@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
"@tanstack/react-table": "^8.20.5",
"@trpc/client": "^11.0.0-rc.467", "@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467", "@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467", "@trpc/server": "^11.0.0-rc.467",

View File

@@ -18,6 +18,7 @@ import {
shortcutsRefsSchema, shortcutsRefsSchema,
shortcutsSchema, shortcutsSchema,
} from "../schemas/blocks/shortcuts" } from "../schemas/blocks/shortcuts"
import { tableSchema } from "../schemas/blocks/table"
import { textColsRefsSchema, textColsSchema } from "../schemas/blocks/textCols" import { textColsRefsSchema, textColsSchema } from "../schemas/blocks/textCols"
import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid" import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid"
import { tempImageVaultAssetSchema } from "../schemas/imageVault" import { tempImageVaultAssetSchema } from "../schemas/imageVault"
@@ -75,11 +76,18 @@ export const contentPageUspGrid = z
}) })
.merge(uspGridSchema) .merge(uspGridSchema)
export const contentPageTable = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Table),
})
.merge(tableSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [ export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageCards, contentPageCards,
contentPageContent, contentPageContent,
contentPageDynamicContent, contentPageDynamicContent,
contentPageShortcuts, contentPageShortcuts,
contentPageTable,
contentPageTextCols, contentPageTextCols,
contentPageUspGrid, contentPageUspGrid,
]) ])

View File

@@ -0,0 +1,58 @@
import { z } from "zod"
import { BlocksEnums } from "@/types/enums/blocks"
export const tableSchema = z.object({
typename: z
.literal(BlocksEnums.block.Table)
.optional()
.default(BlocksEnums.block.Table),
table: z
.object({
heading: z.string().optional(),
preamble: z.string().optional().default(""),
column_widths: z.array(z.number()),
table: z.object({
tableState: z.object({
columns: z.array(
z.object({
id: z.string(),
label: z.string().default(""),
accessor: z.string(),
dataType: z.string(),
})
),
data: z.array(z.object({}).catchall(z.string())),
skipReset: z.boolean(),
tableActionEnabled: z.boolean(),
headerRowAdded: z.boolean(),
}),
}),
})
.transform((data) => {
const totalWidth = data.column_widths.reduce(
(acc, width) => acc + width,
0
)
const columns = data.table.tableState.columns.map((col, idx) => ({
id: col.id,
header: col.label || "",
width: data.column_widths[idx] || 0,
}))
const rows = data.table.tableState.data.map((rowData) =>
columns.reduce<Record<string, string>>((transformedRow, column) => {
transformedRow[column.id] = rowData[column.id] || ""
return transformedRow
}, {})
)
return {
heading: data.heading,
preamble: data.preamble,
columns,
rows,
totalWidth,
}
}),
})

View File

@@ -17,6 +17,17 @@ export const getHotelsAvailabilityInputSchema = z.object({
attachedProfileId: z.string().optional().default(""), attachedProfileId: z.string().optional().default(""),
}) })
export const getRoomsAvailabilityInputSchema = z.object({
hotelId: z.number(),
roomStayStartDate: z.string(),
roomStayEndDate: z.string(),
adults: z.number(),
children: z.number().optional().default(0),
promotionCode: z.string().optional(),
reservationProfileType: z.string().optional().default(""),
attachedProfileId: z.string().optional().default(""),
})
export const getRatesInputSchema = z.object({ export const getRatesInputSchema = z.object({
hotelId: z.string(), hotelId: z.string(),
}) })

View File

@@ -572,6 +572,86 @@ export type HotelsAvailability = z.infer<typeof hotelsAvailabilitySchema>
export type HotelsAvailabilityPrices = export type HotelsAvailabilityPrices =
HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"] HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"]
const productSchema = z.object({
productType: z.object({
public: z.object({
rateCode: z.string(),
rateType: z.string().optional(),
localPrice: z.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
}),
requestedPrice: z
.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
})
.optional(),
}),
member: z.object({
rateCode: z.string(),
rateType: z.string().optional(),
localPrice: z.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
}),
requestedPrice: z
.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
})
.optional(),
}),
}),
})
const roomConfigurationSchema = z.object({
status: z.string(),
bedType: z.string(),
roomType: z.string(),
roomsLeft: z.number(),
features: z.array(z.object({ inventory: z.number(), code: z.string() })),
products: z.array(productSchema),
})
const rateDefinitionSchema = z.object({
title: z.string(),
breakfastIncluded: z.boolean(),
rateType: z.string().optional(),
rateCode: z.string(),
generalTerms: z.array(z.string()),
cancellationRule: z.string(),
cancellationText: z.string(),
mustBeGuaranteed: z.boolean(),
})
const roomsAvailabilitySchema = z
.object({
data: z.object({
attributes: z.object({
checkInDate: z.string(),
checkOutDate: z.string(),
occupancy: occupancySchema.optional(),
hotelId: z.number(),
roomConfigurations: z.array(roomConfigurationSchema),
rateDefinitions: z.array(rateDefinitionSchema),
}),
relationships: linksSchema.optional(),
type: z.string().optional(),
}),
})
.transform((o) => o.data.attributes)
export const getRoomsAvailabilitySchema = roomsAvailabilitySchema
export type RoomsAvailability = z.infer<typeof roomsAvailabilitySchema>
export type RoomConfiguration = z.infer<typeof roomConfigurationSchema>
export type Product = z.infer<typeof productSchema>
export type RateDefinition = z.infer<typeof rateDefinitionSchema>
const flexibilityPrice = z.object({ const flexibilityPrice = z.object({
standard: z.number(), standard: z.number(),
member: z.number(), member: z.number(),

View File

@@ -24,11 +24,13 @@ import {
getHotelsAvailabilityInputSchema, getHotelsAvailabilityInputSchema,
getlHotelDataInputSchema, getlHotelDataInputSchema,
getRatesInputSchema, getRatesInputSchema,
getRoomsAvailabilityInputSchema,
} from "./input" } from "./input"
import { import {
getHotelDataSchema, getHotelDataSchema,
getHotelsAvailabilitySchema, getHotelsAvailabilitySchema,
getRatesSchema, getRatesSchema,
getRoomsAvailabilitySchema,
roomSchema, roomSchema,
} from "./output" } from "./output"
import tempRatesData from "./tempRatesData.json" import tempRatesData from "./tempRatesData.json"
@@ -61,6 +63,16 @@ const hotelsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-fail" "trpc.hotel.availability.hotels-fail"
) )
const roomsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.rooms"
)
const roomsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.rooms-success"
)
const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail"
)
async function getContentstackData( async function getContentstackData(
locale: string, locale: string,
uid: string | null | undefined uid: string | null | undefined
@@ -376,6 +388,123 @@ export const hotelQueryRouter = router({
.flatMap((hotels) => hotels.attributes), .flatMap((hotels) => hotels.attributes),
} }
}), }),
rooms: hotelServiceProcedure
.input(getRoomsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
attachedProfileId,
} = input
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
attachedProfileId,
}
roomsAvailabilityCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
})
console.info(
"api.hotels.roomsAvailability start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponse = await api.get(
`${api.endpoints.v1.roomsAvailability}/${hotelId}`,
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
roomsAvailabilityFailCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.roomsAvailability error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
getRoomsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
roomsAvailabilityFailCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.roomsAvailability validation error",
JSON.stringify({
query: { hotelId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
roomsAvailabilitySuccessCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
})
console.info(
"api.hotels.roomsAvailability success",
JSON.stringify({
query: { hotelId, params: params },
})
)
return validateAvailabilityData.data
}),
}), }),
rates: router({ rates: router({
get: publicProcedure get: publicProcedure

View File

@@ -111,6 +111,7 @@ export const getStaysSchema = z.object({
limit: z.number(), limit: z.number(),
totalCount: z.number(), totalCount: z.number(),
}) })
.optional()
.nullable(), .nullable(),
}) })

View File

@@ -0,0 +1,5 @@
import type { TableData } from "@/types/trpc/routers/contentstack/blocks"
export interface TableBlockProps {
data: TableData
}

View File

@@ -1,8 +1,8 @@
import { Product, RateDefinition } from "@/server/routers/hotels/output"
export type FlexibilityOptionProps = { export type FlexibilityOptionProps = {
product: Product | undefined
name: string name: string
value: string value: string
paymentTerm: string paymentTerm: string
standardPrice: number
memberPrice: number
currency: string
} }

View File

@@ -1,8 +1,9 @@
import { Rate } from "@/server/routers/hotels/output" import {
RateDefinition,
RoomConfiguration,
} from "@/server/routers/hotels/output"
export type RoomCardProps = { export type RoomCardProps = {
room: Rate roomConfiguration: RoomConfiguration
nrOfNights: number rateDefinitions: RateDefinition[]
nrOfAdults: number
breakfastIncluded: boolean
} }

View File

@@ -1,7 +1,5 @@
import { Rate } from "@/server/routers/hotels/output" import { RoomsAvailability } from "@/server/routers/hotels/output"
export interface RoomSelectionProps { export interface RoomSelectionProps {
rates: Rate[] roomConfigurations: RoomsAvailability
nrOfAdults: number
nrOfNights: number
} }

View File

@@ -4,6 +4,7 @@ export namespace BlocksEnums {
Content = "Content", Content = "Content",
DynamicContent = "DynamicContent", DynamicContent = "DynamicContent",
Shortcuts = "Shortcuts", Shortcuts = "Shortcuts",
Table = "Table",
TextCols = "TextCols", TextCols = "TextCols",
TextContent = "TextContent", TextContent = "TextContent",
UspGrid = "UspGrid", UspGrid = "UspGrid",

View File

@@ -7,6 +7,7 @@ export namespace ContentPageEnum {
Shortcuts = "ContentPageBlocksShortcuts", Shortcuts = "ContentPageBlocksShortcuts",
TextCols = "ContentPageBlocksTextCols", TextCols = "ContentPageBlocksTextCols",
UspGrid = "ContentPageBlocksUspGrid", UspGrid = "ContentPageBlocksUspGrid",
Table = "ContentPageBlocksTable",
} }
export const enum sidebar { export const enum sidebar {

View File

@@ -4,6 +4,7 @@ import { cardsGridSchema } from "@/server/routers/contentstack/schemas/blocks/ca
import { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content" import { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content"
import { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent" import { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent"
import { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts" import { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts"
import { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table"
import { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols" import { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols"
import { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid" import { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid"
@@ -12,5 +13,7 @@ export interface Content extends z.output<typeof contentSchema> {}
export interface DynamicContent extends z.output<typeof dynamicContentSchema> {} export interface DynamicContent extends z.output<typeof dynamicContentSchema> {}
export interface Shortcuts extends z.output<typeof shortcutsSchema> {} export interface Shortcuts extends z.output<typeof shortcutsSchema> {}
export type Shortcut = Shortcuts["shortcuts"] export type Shortcut = Shortcuts["shortcuts"]
export interface TableBlock extends z.output<typeof tableSchema> {}
export type TableData = TableBlock["table"]
export interface TextCols extends z.output<typeof textColsSchema> {} export interface TextCols extends z.output<typeof textColsSchema> {}
export interface UspGrid extends z.output<typeof uspGridSchema> {} export interface UspGrid extends z.output<typeof uspGridSchema> {}