Compare commits

...

17 Commits

Author SHA1 Message Date
Rasmus Langvad
089bbe7c4f Remove old component and update usage 2026-02-04 14:34:49 +01:00
Rasmus Langvad
c62999879f Move LinkChips and add unit + a11y tests for chips components 2026-02-04 14:28:21 +01:00
Matilda Landström
989b18527e Merged in fix/STAY-138-center-text (pull request #3538)
fix(STAY-138): center text

* fix(STAY-138): center text


Approved-by: Emma Zettervall
2026-02-04 13:09:12 +00:00
Erik Tiekstra
0cda37808e Merged in fix/BOOK-755-alert-content (pull request #3523)
fix(BOOK-755, BOOK-787): Fixed issue for phone number and sidepeeks not showing

* fix(BOOK-755): Fixed issue for phone number and sidepeeks not showing

* fix(BOOK-755): fix issue phonenumber alert

* fix(BOOK-755): fix issue phonenumber


Approved-by: Matilda Landström
2026-02-03 15:28:23 +00:00
Erik Tiekstra
b3c4761ae5 Merged in chore/BOOK-773-replace-old-typography-variables (pull request #3515)
Chore/BOOK-773 replace old typography variables

* chore(BOOK-773): Replaced body typography

* chore(BOOK-773): Replaced caption typography

* chore(BOOK-773): Replaced footnote typography

* chore(BOOK-773): Replaced subtitle typography


Approved-by: Bianca Widstam
2026-02-03 15:07:18 +00:00
Linus Flood
dd65467573 Merged in fix/close-map-text (pull request #3536)
feat(map): fixed close map text alignment

* feat(map): fixed close map text alignment
2026-02-03 14:07:40 +00:00
Joakim Jäderberg
eb45e6b294 Merged in fix/LOY-606 (pull request #3535)
fix(LOY-606): breakfast price now considers number of nights in MyStay/ChangeDates

* fix(LOY-606): breakfast price now considers number of nights in MyStay/ChangeDates


Approved-by: Linus Flood
2026-02-03 13:51:45 +00:00
Emma Zettervall
6553fcf685 Merged in fix/use-old-loacalize-keys (pull request #3534)
fix(LOY-391): changed back localize keys to the old ones to make less work for content

* fix(LOY-391): changed back localize keys to the old ones to make less work for content


Approved-by: Anton Gunnarsson
2026-02-03 13:42:37 +00:00
Anton Gunnarsson
c2cf6b03a7 Merged in feat/loy-291-new-claim-points-flow (pull request #3508)
feat(LOY-291): New claim points flow for logged in users

* wip new flow

* More wip

* More wip

* Wip styling

* wip with a mutation

* Actually fetch booking data

* More styling wip

* Fix toast duration

* fix loading a11y maybe

* More stuff

* Add feature flag

* Add invalid state

* Clean up

* Add fields for missing user info

* Restructure files

* Add todos

* Disable warning

* Fix icon and border radius


Approved-by: Emma Zettervall
Approved-by: Matilda Landström
2026-02-03 13:27:24 +00:00
Linus Flood
310ad7bc7f Merged in fix/book-785-hotelfilters-cache (pull request #3533)
fix(BOOK-785): fix incorrect cache keys for contact config and hotel filters

* fix(BOOK-785): fix incorrect cache keys for contact config and hotel filters
2026-02-03 13:18:23 +00:00
Joakim Jäderberg
fbdbd35813 Merged in fix/mainmenu-button-css-cascade (pull request #3532)
Fix/mainmenu button css cascade

* fix(MainMenuButton): add @layer to make css cascading work

* reset
2026-02-03 12:36:27 +00:00
Matilda Landström
d6b94376b0 Merged in fix/STAY-23-room-price (pull request #3529)
fix(STAY-23): don't strikethrough price if only one room is cancelled (multiroom)

* fix(STAY-23): don't strikethrough price if only room is cancelled (multiroom)


Approved-by: Anton Gunnarsson
2026-02-03 07:58:52 +00:00
Linus Flood
7a604f1250 Merged in chore/nextjs-version (pull request #3530)
Chore/nextjs version

* chore(next/react): update to latest versions

* Correct swc version

* Readme

* No turbopack

* test

* test

* test
2026-02-03 07:52:04 +00:00
Joakim Jäderberg
13fd8f81c9 Merged in revert-nextjs-upgrade (pull request #3528)
revert nextjs upgrade

* revert nextjs upgrade

* Fix revert


Approved-by: Anton Gunnarsson
2026-02-02 15:15:42 +00:00
Joakim Jäderberg
16cc26632e Merged in chore/refactor-trpc-booking-routes (pull request #3510)
feat(BOOK-750): refactor booking endpoints

* WIP

* wip

* wip

* parse dates in UTC

* wip

* no more errors

* Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/refactor-trpc-booking-routes

* .

* cleanup

* import named z from zod

* fix(BOOK-750): updateBooking api endpoint expects dateOnly, we passed ISO date


Approved-by: Anton Gunnarsson
2026-02-02 14:28:14 +00:00
Linus Flood
8ac2c4ba22 Merged in chore/nextjs-version (pull request #3527)
chore(next/react): update to latest versions

* chore(next/react): update to latest versions

* Correct swc version


Approved-by: Anton Gunnarsson
2026-02-02 14:21:39 +00:00
Emma Zettervall
65e5d90fee Merged in fix/point-transaction-link (pull request #3526)
Fix/point transaction link

* fix: only make stay transactions links

* fix: only make transaction of type stay and reward nights with a bookingUrl as links


Approved-by: Matilda Landström
2026-02-02 12:44:08 +00:00
148 changed files with 3796 additions and 1876 deletions

View File

@@ -1,3 +1,4 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
}

View File

@@ -22,13 +22,13 @@
},
"dependencies": {
"@formatjs/intl": "^3.1.6",
"@netlify/plugin-nextjs": "^5.15.1",
"@netlify/plugin-nextjs": "^5.15.7",
"@scandic-hotels/booking-flow": "workspace:*",
"@scandic-hotels/design-system": "workspace:*",
"@scandic-hotels/tracking": "workspace:*",
"@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.33.0",
"@swc/plugin-formatjs": "^3.2.2",
"@swc/plugin-formatjs": "^8.1.0",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
@@ -36,11 +36,11 @@
"@trpc/server": "^11.1.2",
"class-variance-authority": "^0.7.1",
"iron-session": "^8.0.4",
"next": "16.0.10",
"next": "16.1.6",
"next-auth": "5.0.0-beta.29",
"react": "19.2.1",
"react": "19.2.4",
"react-aria-components": "1.8.0",
"react-dom": "19.2.1",
"react-dom": "19.2.4",
"react-intl": "^7.1.11",
"server-only": "^0.0.1",
"usehooks-ts": "3.1.1",

View File

@@ -52,3 +52,4 @@ DTMC_ENTRA_ID_CLIENT=""
DTMC_ENTRA_ID_ISSUER=""
DTMC_ENTRA_ID_SECRET=""
NEXT_PUBLIC_NEW_POINTCLAIMS="true"

View File

@@ -16,6 +16,18 @@ yarn workspace @scandic-hotels/design-system build
yarn dev
```
To run only scandic web
```bash
yarn dev:web
```
To run only partner sas
```bash
yarn dev:sas
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Caching

View File

@@ -4,7 +4,8 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
grid-template-rows: auto 1fr;
min-height: 100dvh;
max-width: var(--max-width-page);

View File

@@ -1,6 +1,7 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
grid-template-rows: auto 1fr;
position: relative;
}

View File

@@ -1,3 +1,4 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
}

View File

@@ -1,9 +1,8 @@
import { redirect } from "next/navigation"
import { z } from "zod"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Image from "@scandic-hotels/design-system/Image"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
@@ -94,7 +93,8 @@ export default async function SASxScandicLoginPage(
{intentDescriptions[parsedParams.intent]}
</p>
</Typography>
<Footnote textAlign="center">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p style={{ textAlign: "center" }}>
{intl.formatMessage(
{
id: "linkEuroBonusAccount.manualRedirectLinkMessage",
@@ -103,18 +103,14 @@ export default async function SASxScandicLoginPage(
},
{
loginLink: (str) => (
<Link
href={loginLink}
color="red"
size="tiny"
textDecoration="underline"
>
<TextLink typography="Link/sm" href={loginLink}>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</p>
</Typography>
</SASModal>
)
}

View File

@@ -42,7 +42,8 @@
width: 34px;
height: 0px;
padding: var(--Space-x3) 0;
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-Radius-md);
text-align: center;

View File

@@ -1,5 +1,6 @@
.layout {
background-color: var(--Background-Primary);
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
min-height: 100dvh;
}

View File

@@ -1,3 +1,4 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
}

View File

@@ -1,13 +1,29 @@
import { Alert } from "@scandic-hotels/design-system/Alert"
import { getAlertPhoneContactData } from "@scandic-hotels/trpc/routers/contentstack/base/utils"
import { serverClient } from "@/lib/trpc/server"
import type { AlertBlock } from "@scandic-hotels/trpc/types/blocks"
interface AlertBlockProps extends Pick<AlertBlock, "alert"> {}
export function AlertBlock({ alert }: AlertBlockProps) {
export async function AlertBlock({ alert }: AlertBlockProps) {
const caller = await serverClient()
const contactConfig = await caller.contentstack.base.contact()
if (!alert) {
return null
}
return <Alert {...alert} />
const phoneContact =
alert.phoneContact && contactConfig
? getAlertPhoneContactData(alert, contactConfig)
: null
return (
<Alert
{...alert}
phoneContact={phoneContact}
sidepeekCtaText={alert.sidepeekButton?.cta_text}
/>
)
}

View File

@@ -16,18 +16,14 @@
.iconTh {
padding: var(--Space-x5) var(--Space-x2) var(--Space-x2);
font-weight: var(--typography-Caption-Regular-fontWeight);
vertical-align: bottom;
}
.summaryTh {
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
padding: 0 var(--Space-x2) var(--Space-x2);
vertical-align: top;
}
.select {
font-weight: var(--typography-Caption-Regular-fontWeight);
padding: 0 var(--Space-x2) var(--Space-x2);
}

View File

@@ -1,3 +1,5 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import MembershipLevelIcon from "@/components/Levels/Icon"
import LevelSummary from "../../LevelSummary"
@@ -37,12 +39,14 @@ export default function DesktopHeader({
<th />
{levels.map((level, idx) => {
return (
<th
key={"summary" + level.level_id + idx}
className={styles.summaryTh}
<Typography
variant="Body/Supporting text (caption)/smRegular"
key={"name" + level.level_id + idx}
>
<th className={styles.summaryTh}>
<LevelSummary level={level} />
</th>
</Typography>
)
})}
</tr>

View File

@@ -82,10 +82,12 @@ function RewardTableHeader({ name, description }: RewardTableHeaderProps) {
</span>
</hgroup>
</summary>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p
className={styles.rewardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
</Typography>
</details>
)
}

View File

@@ -15,14 +15,11 @@
}
.td {
font-size: var(--typography-Footnote-Regular-fontSize);
text-align: center;
}
.rewardTh {
padding: var(--Space-x3) var(--Space-x2);
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
}
.details[open] .chevron {

View File

@@ -1,5 +1,7 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./levelSummary.module.css"
import type { LevelSummaryProps } from "@/types/components/overviewTable"
@@ -32,7 +34,9 @@ export default function LevelSummary({
return (
<div className={styles.levelSummary}>
<Typography variant="Label/xsRegular">
<span className={styles.levelRequirements}>{pointsMsg}</span>
</Typography>
{showDescription && (
<p className={styles.levelSummaryText}>{level.description}</p>
)}

View File

@@ -8,16 +8,14 @@
.levelRequirements {
border-radius: var(--Corner-Radius-md);
background-color: var(--Scandic-Brand-Pale-Peach);
color: var(--Scandic-Peach-80);
background-color: var(--Surface-Brand-Primary-1-Default);
color: var(--Text-Interactive-Secondary);
padding: var(--Space-x05) var(--Space-x1);
text-align: center;
width: 100%;
}
.levelSummaryText {
font-size: var(--typography-Caption-Regular-fontSize);
line-height: var(--typography-Body-Regular-lineHeight);
margin: 0;
}
@@ -26,12 +24,3 @@
padding: var(--Space-x05) var(--Space-x1);
}
}
@media screen and (min-width: 1367px) {
.levelRequirements {
font-size: var(--typography-Footnote-Regular-fontSize);
}
.levelSummaryText {
font-size: var(--typography-Caption-Regular-fontSize);
}
}

View File

@@ -27,10 +27,12 @@ export default function RewardCard({
</span>
</hgroup>
</summary>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p
className={styles.rewardCardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
</Typography>
</details>
</div>
<div className={styles.rewardComparison}>

View File

@@ -12,8 +12,6 @@
}
.rewardCardDescription {
font-size: var(--typography-Caption-Regular-fontSize);
line-height: 150%;
padding-right: var(--Space-x4);
}

View File

@@ -1,6 +1,7 @@
import { Minus } from "react-feather"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./rewardValue.module.css"
@@ -21,8 +22,8 @@ export default function RewardValue({ reward }: RewardValueProps) {
)
}
return (
<div className={styles.rewardValueContainer}>
<span className={styles.rewardValue}>{reward.value}</span>
</div>
<Typography variant="Body/Paragraph/mdBold">
<div className={styles.rewardValueContainer}>{reward.value}</div>
</Typography>
)
}

View File

@@ -7,17 +7,6 @@
text-wrap: balance;
}
.rewardValue {
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
}
.rewardValueDetails {
font-size: var(--typography-Footnote-Regular-fontSize);
text-align: center;
color: var(--UI-Grey-80);
}
.checkIcon {
display: inline-flex;
}

View File

@@ -0,0 +1,430 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
/* TODO remove disable and add i18n */
/* TODO add analytics */
import { zodResolver } from "@hookform/resolvers/zod"
import { cx } from "class-variance-authority"
import { useState } from "react"
import { FormProvider, useForm, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import z from "zod"
import { dt } from "@scandic-hotels/common/dt"
import { Button } from "@scandic-hotels/design-system/Button"
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "@/hooks/useLang"
import styles from "./claimPoints.module.css"
type PointClaimBookingInfo = {
from: string
to: string
city: string
hotel: string
}
export function ClaimPointsWizard({
onSuccess,
onClose,
}: {
onSuccess: () => void
onClose: () => void
}) {
const [state, setState] = useState<
"initial" | "loading" | "invalid" | "form"
>("initial")
const [bookingDetails, setBookingDetails] =
useState<PointClaimBookingInfo | null>(null)
const { data, isLoading } = trpc.user.getSafely.useQuery()
if (state === "invalid") {
return <InvalidBooking onClose={onClose} />
}
if (state === "form") {
if (isLoading) {
return null
}
return (
<ClaimPointsForm
onSuccess={onSuccess}
initialData={{
...bookingDetails,
firstName: data?.firstName ?? "",
lastName: data?.lastName ?? "",
email: data?.email ?? "",
phone: data?.phoneNumber ?? "",
}}
/>
)
}
const handleBookingNumberEvent = (event: BookingNumberEvent) => {
switch (event.type) {
case "submit":
setState("loading")
break
case "error":
setState("initial")
break
case "invalid":
setState("invalid")
break
case "success":
setBookingDetails(event.data)
setState("form")
break
}
}
return (
<div className={styles.introWrapper}>
{state === "loading" && (
<div
className={styles.spinner}
aria-live="polite"
aria-label="Loading booking details, please wait.."
>
<LoadingSpinner />
</div>
)}
<div
className={cx(styles.options, { [styles.hidden]: state === "loading" })}
>
<section className={styles.sectionCard}>
<div className={styles.sectionInfo}>
<Typography variant="Body/Paragraph/mdBold">
<h4>Claim points with booking number</h4>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
Enter a valid booking number to load booking details
automatically.
</p>
</Typography>
</div>
<BookingNumberInput onEvent={handleBookingNumberEvent} />
</section>
<Divider />
<section className={styles.sectionCard}>
<div className={styles.sectionInfo}>
<Typography variant="Body/Paragraph/mdBold">
<h4>Claim points without booking number</h4>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>You need to add booking details in a form.</p>
</Typography>
</div>
<Button variant="Secondary" onPress={() => setState("form")}>
Fill form to claim points
</Button>
</section>
</div>
<MessageBanner
type="info"
text="Points can be claimed up to 6 months back if you were a member at the time of your stay."
/>
</div>
)
}
type BookingNumberFormData = {
bookingNumber: string
}
type BookingNumberEvent =
| { type: "submit" }
| { type: "success"; data: PointClaimBookingInfo }
| { type: "error" }
| { type: "invalid" }
function BookingNumberInput({
onEvent,
}: {
onEvent: (event: BookingNumberEvent) => void
}) {
const lang = useLang()
const form = useForm<BookingNumberFormData>({
resolver: zodResolver(
z.object({
bookingNumber: z
.string()
// TODO Check UX for validation as different environments have different lengths
.min(9, { message: "Booking number must be 10 digits" })
.max(10, { message: "Booking number must be 10 digits" }),
})
),
defaultValues: {
bookingNumber: "",
},
})
const confirmationNumber = useWatch({
name: "bookingNumber",
control: form.control,
})
const { refetch, isFetching } =
trpc.booking.findBookingForCurrentUser.useQuery(
{
confirmationNumber,
lang,
},
{ enabled: false }
)
const handleSubmit = async () => {
onEvent({ type: "submit" })
const result = await refetch()
if (!result.data) {
onEvent({ type: "error" })
form.setError("bookingNumber", {
type: "manual",
message:
"We could not find a booking with this number registered in your name.",
})
return
}
const data = result.data
// TODO validate if this should be check out or check in date
const checkOutDate = dt(data.booking.checkOutDate)
const sixMonthsAgo = dt().subtract(6, "months")
if (checkOutDate.isBefore(sixMonthsAgo, "day")) {
onEvent({ type: "invalid" })
return
}
onEvent({
type: "success",
data: {
from: data.booking.checkInDate,
to: data.booking.checkOutDate,
city: data.hotel.cityName,
hotel: data.hotel.name,
},
})
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FormInput
name="bookingNumber"
label="Booking number"
leftIcon={<MaterialIcon icon="edit_document" />}
description="Enter your 10-digit booking number"
maxLength={10}
showClearContentIcon
disabled={isFetching}
autoFocus
autoComplete="off"
onChange={(e) => {
const value = e.target.value
if (value.length !== 10) return
form.handleSubmit(handleSubmit)()
}}
/>
</form>
</FormProvider>
)
}
function InvalidBooking({ onClose }: { onClose: () => void }) {
return (
<div className={styles.invalidWrapper}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
We cant add these points to your account as it has been longer than 6
months since your stay.
</p>
</Typography>
<Button variant="Primary" fullWidth onPress={onClose}>
Close
</Button>
</div>
)
}
type PointClaimUserInfo = {
firstName: string
lastName: string
email: string
phone: string
}
function ClaimPointsForm({
onSuccess,
initialData,
}: {
onSuccess: () => void
initialData: Partial<PointClaimBookingInfo & PointClaimUserInfo> | null
}) {
const form = useForm({
resolver: zodResolver(
z.object({
from: z.string().min(1, { message: "Arrival date is required" }),
to: z.string().min(1, { message: "Departure date is required" }),
city: z.string().min(1, { message: "City is required" }),
hotel: z.string().min(1, { message: "Hotel is required" }),
firstName: z.string().min(1, { message: "First name is required" }),
lastName: z.string().min(1, { message: "Last name is required" }),
email: z
.string()
.email("Enter a valid email")
.min(1, { message: "Email is required" }),
phone: z.string().min(1, { message: "Phone is required" }),
})
),
defaultValues: {
from: initialData?.from || "",
to: initialData?.to || "",
city: initialData?.city || "",
hotel: initialData?.hotel || "",
firstName: initialData?.firstName || "",
lastName: initialData?.lastName || "",
email: initialData?.email || "",
phone: initialData?.phone || "",
},
mode: "all",
})
const { mutate, isPending } = trpc.user.claimPoints.useMutation({
onSuccess,
})
const autoFocusField = getAutoFocus(initialData)
return (
<FormProvider {...form}>
<form
className={styles.form}
onSubmit={form.handleSubmit((data) => mutate(data))}
>
<div className={styles.formInputs}>
{!initialData?.firstName && (
<FormInput
name="firstName"
label="First name"
autoFocus={autoFocusField === "firstName"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
{!initialData?.lastName && (
<FormInput
name="lastName"
label="Last name"
autoFocus={autoFocusField === "lastName"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
{!initialData?.email && (
<FormInput
name="email"
label="Email"
type="email"
autoFocus={autoFocusField === "email"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
{!initialData?.phone && (
<FormInput
name="phone"
label="Phone"
type="tel"
autoFocus={autoFocusField === "phone"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
<FormInput
name="from"
label="Arrival (YYYY-MM-DD)"
leftIcon={<MaterialIcon icon="calendar_today" />}
autoFocus={autoFocusField === "from"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
<FormInput
name="to"
label="Departure (YYYY-MM-DD)"
leftIcon={<MaterialIcon icon="calendar_today" />}
readOnly={isPending}
registerOptions={{ required: true }}
/>
<FormInput
name="city"
label="City"
readOnly={isPending}
registerOptions={{ required: true }}
/>
<FormInput
name="hotel"
label="Hotel"
readOnly={isPending}
registerOptions={{ required: true }}
/>
</div>
<MessageBanner
type="info"
text="Points can be claimed up to 6 months back if you were a member at the time of your stay."
/>
<Button
type="submit"
variant="Primary"
fullWidth
isDisabled={!form.formState.isValid}
isPending={isPending}
className={styles.formSubmit}
>
Send points claim
</Button>
</form>
</FormProvider>
)
}
function getAutoFocus(userInfo: Partial<PointClaimUserInfo> | null) {
if (!userInfo?.firstName) {
return "firstName"
}
if (!userInfo?.lastName) {
return "lastName"
}
if (!userInfo?.email) {
return "email"
}
if (!userInfo?.phone) {
return "phone"
}
return "from"
}
function Divider() {
const intl = useIntl()
return (
<div className={styles.divider}>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
id: "common.or",
defaultMessage: "or",
})}
</span>
</Typography>
</div>
)
}

View File

@@ -6,3 +6,100 @@
gap: var(--Space-x2);
white-space: nowrap;
}
.dialog {
max-width: 560px;
}
.introWrapper {
position: relative;
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.options {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.sectionCard {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
padding: var(--Space-x2);
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-Radius-md);
}
.sectionInfo {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.spinner {
background-color: var(--Base-Surface-Primary-light-Normal);
position: absolute;
inset: 0;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
}
.bookingInputDescription {
display: flex;
align-items: center;
gap: var(--Space-x05);
}
.form {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.formInputs {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.formSubmit {
margin-top: auto;
}
.divider {
width: 100%;
position: relative;
display: flex;
justify-content: center;
& > span {
position: relative;
padding: 0 var(--Space-x2);
background-color: white;
}
&::before {
position: absolute;
bottom: calc(50% - 1px);
content: "";
display: block;
height: 1px;
width: 100%;
background-color: var(--Border-Default);
}
}
.hidden {
visibility: hidden;
}
.invalidWrapper {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}

View File

@@ -1,18 +1,110 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
/* TODO remove disable and add i18n */
"use client"
import { useEffect, useState } from "react"
import { Dialog } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { missingPoints } from "@/constants/missingPointsHrefs"
import { env } from "@/env/client"
import useLang from "@/hooks/useLang"
import { ClaimPointsWizard } from "./ClaimPointsWizard"
import styles from "./claimPoints.module.css"
export default function ClaimPoints() {
const intl = useIntl()
const [openModal, setOpenModal] = useLinkableModalState("claim-points")
const useNewFlow = env.NEXT_PUBLIC_NEW_POINTCLAIMS
if (!useNewFlow) {
return <OldClaimPointsLink />
}
return (
<>
<div className={styles.claim}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "points.claimPoints.missingPreviousStay",
defaultMessage: "Missing a previous stay?",
})}
</p>
</Typography>
<Button variant="Text" size="sm" onPress={() => setOpenModal(true)}>
{intl.formatMessage({
id: "points.claimPoints.cta",
defaultMessage: "Claim points",
})}
</Button>
</div>
<Modal
title="Add missing points"
isOpen={openModal}
onToggle={(open) => setOpenModal(open)}
>
<Dialog aria-label="TODO" className={styles.dialog}>
{({ close }) => (
<ClaimPointsWizard
onSuccess={() => {
toast.info(
<>
<Typography variant="Body/Paragraph/mdBold">
<p>We&apos;re on it!</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
If your points have not been added to your account
within 2 weeks, please contact us.
</p>
</Typography>
</>,
{
duration: Infinity,
}
)
close()
}}
onClose={close}
/>
)}
</Dialog>
</Modal>
</>
)
}
function useLinkableModalState(target: string) {
const [openModal, setOpenModal] = useState(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const claimPoints = params.get("target") === target
if (claimPoints) {
params.delete("target")
const newUrl = `${window.location.pathname}?${params.toString()}`
window.history.replaceState({}, "", newUrl)
// eslint-disable-next-line react-hooks/set-state-in-effect
setOpenModal(true)
}
}, [target])
return [openModal, setOpenModal] as const
}
function OldClaimPointsLink() {
const intl = useIntl()
const lang = useLang()

View File

@@ -41,7 +41,12 @@ export function PointTransactionRow({
? formattedPoints
: `${awardPoints > 0 ? "+" : "-"} ${formattedPoints}`
const canLinkBookingUrl = !balfwd && !nonTransactional
const canLinkBookingUrl =
!balfwd &&
!nonTransactional &&
!!transaction.attributes.bookingUrl &&
(transaction.type === Transactions.rewardType.stay ||
transaction.type === Transactions.rewardType.rewardNight)
const description = getDescription(transaction, intl)
@@ -99,7 +104,7 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
if (isNonTransactional && transaction.attributes.nights === 0) {
return intl.formatMessage({
id: "myPoints.pointTransactions.pointsActivity",
id: "earnAndBurn.journeyTable.pointsActivity",
defaultMessage: "Point activity",
})
}
@@ -108,7 +113,7 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
if (hotelInformation?.name) {
return intl.formatMessage(
{
id: "myPoints.pointTransactions.stayAt",
id: "earnAndBurn.journeyTable.stayAt",
defaultMessage: "Stay at {hotelName}",
},
{ hotelName: hotelInformation.name }
@@ -119,53 +124,53 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
case Transactions.rewardType.stayAdj:
if (transaction.attributes.hotelOperaId === "ORS") {
return intl.formatMessage({
id: "myPoints.pointTransactions.formerScandicHotel",
id: "earnAndBurn.journeyTable.formerScandicHotel",
defaultMessage: "Former Scandic Hotel",
})
}
if (isBalfwd) {
return intl.formatMessage({
id: "myPoints.pointTransactions.pointsEarnedPriorMay2021",
id: "earnAndBurn.journeyTable.pointsEarnedPriorMay2021",
defaultMessage: "Points earned prior to May 1, 2021",
})
}
case Transactions.rewardType.redgift:
return intl.formatMessage({
id: "myPoints.pointTransactions.redGift",
id: "earnAndBurn.journeyTable.redGift",
defaultMessage: "Reward Gift",
})
case Transactions.rewardType.rewardNight:
return intl.formatMessage({
id: "myPoints.pointTransactions.rewardNight",
id: "earnAndBurn.journeyTable.rewardNight",
defaultMessage: "Reward Night",
})
case Transactions.rewardType.ancillary:
return intl.formatMessage({
id: "myPoints.pointTransactions.extrasToBooking",
id: "earnAndBurn.journeyTable.extrasToBooking",
defaultMessage: "Extras to your booking",
})
case Transactions.rewardType.enrollment:
return intl.formatMessage({
id: "myPoints.pointTransactions.signUpBonus",
id: "earnAndBurn.journeyTable.signUpBonus",
defaultMessage: "Sign up bonus",
})
case Transactions.rewardType.mastercard_points:
return intl.formatMessage({
id: "myPoints.pointTransactions.scandicFriendsMastercard",
id: "earnAndBurn.journeyTable.scandicFriendsMastercard",
defaultMessage: "Scandic Friends Mastercard",
})
case Transactions.rewardType.tui_points:
return intl.formatMessage({
id: "myPoints.pointTransactions.tuiPoints",
id: "earnAndBurn.journeyTable.tuiPoints",
defaultMessage: "TUI Points",
})
case Transactions.rewardType.pointShop:
return intl.formatMessage({
id: "myPoints.pointTransactions.pointShop",
id: "earnAndBurn.journeyTable.pointShop",
defaultMessage: "Scandic Friends Point Shop",
})
default:

View File

@@ -1,13 +1,13 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getCampaignOverviewPage } from "@/lib/trpc/memoizedRequests"
import { TopCampaign } from "@/components/ContentType/CampaignOverviewPage/TopCampaign"
import LinkChips from "@/components/TempDesignSystem/LinkChips"
import Blocks from "./Blocks"
import CampaignOverviewPageSkeleton from "./CampaignOverviewPageSkeleton"

View File

@@ -7,6 +7,7 @@ import {
} from "@scandic-hotels/design-system/Breadcrumbs"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import Image from "@scandic-hotels/design-system/Image"
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
@@ -17,7 +18,6 @@ import { Breadcrumbs } from "@/components/Breadcrumbs"
import HeaderDynamicContent from "@/components/Headers/DynamicContent"
import { HeroVideo } from "@/components/HeroVideo"
import MeetingPackageWidget from "@/components/MeetingPackageWidget"
import LinkChips from "@/components/TempDesignSystem/LinkChips"
import styles from "./collectionPage.module.css"

View File

@@ -3,6 +3,7 @@ import { Suspense } from "react"
import { BreadcrumbsSkeleton } from "@scandic-hotels/design-system/Breadcrumbs"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
@@ -16,7 +17,6 @@ import { HeroVideo } from "@/components/HeroVideo"
import Sidebar from "@/components/Sidebar"
import SidebarSkeleton from "@/components/Sidebar/SidebarSkeleton"
import StickyMeetingPackageWidget from "@/components/StickyMeetingPackageWidget"
import LinkChips from "@/components/TempDesignSystem/LinkChips"
import styles from "./contentPage.module.css"

View File

@@ -10,6 +10,7 @@ import { useMediaQuery } from "usehooks-ts"
import { useMarkerHover } from "@scandic-hotels/common/hooks/map/useMarkerHover"
import { InfoWindow } from "@scandic-hotels/design-system/Map/InfoWindow"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
@@ -79,7 +80,10 @@ export default function CityClusterMarker({
})}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
>
<span className={styles.count}>{sizeAsText}</span>
<Typography variant="Title/Subtitle/md">
<span>{sizeAsText}</span>
</Typography>
{isDesktop && isHovered ? (
<InfoWindow
position={position}

View File

@@ -20,9 +20,3 @@
height: 46px !important;
}
}
.count {
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Subtitle-2-fontSize);
font-weight: var(--typography-Subtitle-2-fontWeight);
}

View File

@@ -19,12 +19,13 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -90,14 +91,16 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family:
var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
.footer {

View File

@@ -89,12 +89,13 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -156,14 +157,16 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family:
var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
@media screen and (min-width: 1367px) {

View File

@@ -1,3 +1,4 @@
@layer component {
.menuButton {
display: flex;
align-items: center;
@@ -14,3 +15,4 @@
cursor: progress;
}
}
}

View File

@@ -1,4 +1,5 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
@@ -25,7 +26,7 @@ import ModifyContact from "../ModifyContact"
import styles from "./guestDetails.module.css"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import {
type ModifyContactSchema,
@@ -34,9 +35,9 @@ import {
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import type { SafeUser } from "@/types/user"
interface GuestDetailsProps {
type GuestDetailsProps = {
refId: string
guest: Guest
guest: BookingConfirmation["booking"]["guest"]
isCancelled: boolean
user: SafeUser
}
@@ -76,6 +77,7 @@ export default function GuestDetails({
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
const isMemberBooking =
!!user?.membership?.membershipNumber &&
guest.membershipNumber === user?.membership?.membershipNumber
const updateGuest = trpc.booking.update.useMutation({
@@ -196,7 +198,7 @@ export default function GuestDetails({
{guest.firstName} {guest.lastName}
</p>
</Typography>
{isMemberBooking && user.membership && (
{isMemberBooking && user?.membership && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.memberNumber} data-hj-suppress>
{intl.formatMessage(

View File

@@ -23,6 +23,7 @@
text-decoration-skip-ink: none;
text-decoration-thickness: auto;
text-underline-offset: auto;
text-align: center;
text-underline-position: from-font;
}

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -66,9 +67,14 @@ export default function Steps({ closeModal }: ChangeDatesStepsProps) {
setDates({ fromDate, toDate })
const numberOfNights = dt(toDate).diff(dt(fromDate), "days")
const pkgsSum = sumPackages(packages)
const extraPrice =
pkgsSum.price + ((breakfast && breakfast.localPrice.totalPrice) || 0)
const breakfastPrice = !!breakfast
? breakfast.localPrice.price * numberOfNights
: 0
const extraPrice = pkgsSum.price + breakfastPrice
if (isLoggedIn && "member" in data.product && data.product.member) {
const { currency, pricePerStay } = data.product.member.localPrice
setNewPrice(formatPrice(intl, pricePerStay + extraPrice, currency))

View File

@@ -4,16 +4,18 @@ import { useMyStayStore } from "@/stores/my-stay"
import Price from "../PriceType/Price"
import type { PriceType as _PriceType } from "@/types/components/hotelReservation/myStay/myStay"
export default function TotalPrice() {
const { bookedRoom, totalPrice } = useMyStayStore((state) => ({
const { bookedRoom, totalPrice, allRoomsAreCancelled } = useMyStayStore(
(state) => ({
bookedRoom: state.bookedRoom,
totalPrice: state.totalPrice,
}))
allRoomsAreCancelled: state.allRoomsAreCancelled,
})
)
return (
<Price
isCancelled={bookedRoom.isCancelled}
isCancelled={allRoomsAreCancelled}
isMember={bookedRoom.rateDefinition.isMemberRate}
price={totalPrice}
/>

View File

@@ -8,7 +8,7 @@ import accessBooking, {
} from "./accessBooking"
import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { SafeUser } from "@/types/user"
@@ -201,7 +201,7 @@ const badAuthenticatedUser: SafeUser = {
profilingConsentUpdateDate: undefined,
}
const loggedOutGuest: Guest = {
const loggedOutGuest: BookingConfirmation["booking"]["guest"] = {
email: "logged+out@scandichotels.com",
firstName: "Anonymous",
lastName: "Booking",
@@ -210,7 +210,7 @@ const loggedOutGuest: Guest = {
countryCode: "SE",
}
const loggedInGuest: Guest = {
const loggedInGuest: BookingConfirmation["booking"]["guest"] = {
email: "logged+in@scandichotels.com",
firstName: "Authenticated",
lastName: "Booking",

View File

@@ -1,5 +1,5 @@
import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { SafeUser } from "@/types/user"
@@ -15,7 +15,7 @@ export {
* Whether a request can access a confirmed booking or not.
*/
function accessBooking(
guest: Guest,
guest: BookingConfirmation["booking"]["guest"],
lastName: string,
user: SafeUser | null,
cookie: string = ""

View File

@@ -4,13 +4,6 @@
padding: var(--Space-x3) var(--Space-x2);
}
.subtitle {
font-family: var(--typography-Subtitle-2-fontFamily);
font-size: var(--typography-Subtitle-2-Mobile-fontSize);
font-weight: var(--typography-Subtitle-2-fontWeight);
color: var(--Base-Text-High-contrast);
}
.list {
list-style: none;
}

View File

@@ -16,8 +16,8 @@
cursor: pointer;
height: 32px;
width: 32px;
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
padding: 0;
display: flex;
align-items: center;

View File

@@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
gap: var(--Space-x05);
align-items: flex-start;
}
.link {

View File

@@ -1,15 +1,13 @@
import Footnote from "@scandic-hotels/design-system/Footnote"
import {
MaterialIcon,
type MaterialIconSetIconProps,
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getValueFromContactConfig } from "@scandic-hotels/trpc/utils/contactConfig"
import { serverClient } from "@/lib/trpc/server"
// import { getValueFromContactConfig } from "@/utils/contactConfig"
import styles from "./contactRow.module.css"
import type { ContactRowProps } from "@/types/components/sidebar/joinLoyaltyContact"
@@ -46,22 +44,27 @@ export default async function ContactRow({ contact }: ContactRowProps) {
return (
<div className={styles.wrapper}>
{contact.display_text ? (
<Typography
variant="Body/Paragraph/mdBold"
className={styles.displayText}
>
<p>{contact.display_text}</p>
</Typography>
<Link
) : null}
<TextLink
typography="Link/sm"
className={styles.link}
href={openableLink}
textDecoration="underline"
size="small"
>
{Icon ? <Icon size={20} color="Icon/Interactive/Default" /> : null}
{val}
</Link>
{footnote && <Footnote color="burgundy">{footnote}</Footnote>}
</TextLink>
{footnote && (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{footnote}</p>
</Typography>
)}
</div>
)
}

View File

@@ -13,18 +13,9 @@
gap: var(--Space-x15);
}
.contact > div {
display: flex;
justify-content: center;
}
@media screen and (min-width: 1367px) {
.contactContainer {
align-items: start;
padding-top: var(--Space-x2);
}
.contact > div {
justify-content: start;
}
}

View File

@@ -1,29 +0,0 @@
"use client"
import { ChipLink } from "@scandic-hotels/design-system/ChipLink"
import { Chips } from "@scandic-hotels/design-system/Chips"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
interface LinkChipsProps {
chips: {
url: string
title: string
}[]
}
export default function LinkChips({ chips }: LinkChipsProps) {
if (!chips.length) {
return null
}
return (
<Chips>
{chips.map(({ title, url }) => (
<ChipLink key={`${title}-${url}`} href={url}>
{title}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</ChipLink>
))}
</Chips>
)
}

View File

@@ -16,6 +16,11 @@ export const env = createEnv({
.transform((s) =>
getSemver("scandic-web", s, process.env.BRANCH || "development")
),
NEXT_PUBLIC_NEW_POINTCLAIMS: z
.string()
.optional()
.default("false")
.transform((s) => s === "true"),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -26,5 +31,6 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE,
NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
NEXT_PUBLIC_NEW_POINTCLAIMS: process.env.NEXT_PUBLIC_NEW_POINTCLAIMS,
},
})

View File

@@ -30,7 +30,7 @@
"@internationalized/date": "^3.8.0",
"@netlify/blobs": "^8.1.0",
"@netlify/functions": "^3.0.0",
"@netlify/plugin-nextjs": "^5.15.1",
"@netlify/plugin-nextjs": "^5.15.7",
"@radix-ui/react-slot": "^1.2.2",
"@scandic-hotels/booking-flow": "workspace:*",
"@scandic-hotels/common": "workspace:*",
@@ -38,7 +38,7 @@
"@scandic-hotels/tracking": "workspace:*",
"@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.33.0",
"@swc/plugin-formatjs": "^3.2.2",
"@swc/plugin-formatjs": "^8.1.0",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
@@ -66,12 +66,12 @@
"jsonwebtoken": "^9.0.2",
"md5": "^2.3.0",
"motion": "^12.10.0",
"next": "16.0.10",
"next": "16.1.6",
"next-auth": "5.0.0-beta.29",
"react": "19.2.1",
"react": "19.2.4",
"react-aria-components": "1.8.0",
"react-day-picker": "^9.6.7",
"react-dom": "19.2.1",
"react-dom": "19.2.4",
"react-feather": "^2.0.10",
"react-focus-lock": "^2.13.6",
"react-hook-form": "^7.56.2",

View File

@@ -13,22 +13,20 @@ import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkele
import { MyStayContext } from "@/contexts/MyStay"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
BookingConfirmation,
BookingConfirmationSchema,
} from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { RoomCategories } from "@scandic-hotels/trpc/types/hotel"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { Packages } from "@/types/components/myPages/myStay/ancillaries"
import type { MyStayStore } from "@/types/contexts/my-stay"
import type { getLinkedReservations } from "@/lib/trpc/memoizedRequests"
interface MyStayProviderProps {
bookingConfirmation: BookingConfirmation
breakfastPackages: Packages | null
isLoggedIn?: boolean
lang: Lang
linkedReservationsPromise: Promise<BookingConfirmationSchema[]>
linkedReservationsPromise: ReturnType<typeof getLinkedReservations>
refId: string
roomCategories: RoomCategories
savedCreditCards: CreditCard[] | null

View File

@@ -1,13 +1,11 @@
import Footnote from "@scandic-hotels/design-system/Footnote"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./list.module.css"
export default function Label({ children }: React.PropsWithChildren) {
return (
<li className={styles.label}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
{children}
</Footnote>
</li>
<Typography variant="Title/Overline/sm">
<li className={styles.label}>{children}</li>
</Typography>
)
}

View File

@@ -5,5 +5,6 @@
}
.label {
padding: 0 var(--Space-x1);
padding: 0 var(--Space-x1) var(--Space-x05);
color: var(--Text-Tertiary);
}

View File

@@ -6,7 +6,6 @@ import { useIntl } from "react-intl"
import { useDebounceValue } from "usehooks-ts"
import { Divider } from "@scandic-hotels/design-system/Divider"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -192,16 +191,14 @@ export default function SearchList({
{typeFilteredSearchHistory && typeFilteredSearchHistory.length > 0 && (
<>
<Divider className={styles.noResultsDivider} />
<Footnote
className={styles.text}
color="uiTextPlaceholder"
textTransform="uppercase"
>
<Typography variant="Title/Overline/sm">
<p className={styles.text}>
{intl.formatMessage({
id: "bookingWidget.searchList.latestSearches",
defaultMessage: "Latest searches",
})}
</Footnote>
</p>
</Typography>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
@@ -226,12 +223,14 @@ export default function SearchList({
if (displaySearchHistory) {
return (
<Dialog getMenuProps={getMenuProps}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
<Typography variant="Title/Overline/sm">
<p className={styles.text}>
{intl.formatMessage({
id: "bookingWidget.searchList.latestSearches",
defaultMessage: "Latest searches",
})}
</Footnote>
</p>
</Typography>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}

View File

@@ -33,6 +33,7 @@
.text {
padding: 0 var(--Space-x1);
color: var(--Text-Tertiary);
white-space: normal;
}
.textPlaceholderColor {

View File

@@ -65,7 +65,7 @@ export default function DatePickerRangeDesktop({
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
weekday: styles.weekDay,
nav: `${classNames.nav} ${styles.nav}`,
button_next: `${classNames.button_next} ${styles.button_next}`,
button_previous: `${classNames.button_previous} ${styles.button_previous}`,

View File

@@ -98,7 +98,7 @@ export default function DatePickerRangeMobile({
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.root}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
weekday: styles.weekDay,
}}
disabled={[
{ from: lastDayOfPreviousMonth, to: yesterday },

View File

@@ -20,12 +20,12 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -92,14 +92,15 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family: var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
.footer {

View File

@@ -97,12 +97,12 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -165,15 +165,15 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--Base-Text-Medium-contrast);
opacity: 1;
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family: var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
@media screen and (min-width: 1367px) {

View File

@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
@@ -83,8 +82,8 @@ export default function JoinScandicFriendsCard({
</Typography>
</Checkbox>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -93,19 +92,18 @@ export default function JoinScandicFriendsCard({
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}

View File

@@ -28,6 +28,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {

View File

@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
@@ -97,8 +96,8 @@ export function PartnerSASJoinScandicFriendsCard({
/>
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -107,19 +106,18 @@ export function PartnerSASJoinScandicFriendsCard({
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}

View File

@@ -31,6 +31,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {

View File

@@ -4,10 +4,9 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { LoginButton } from "@scandic-hotels/design-system/LoginButton"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackEvent } from "@scandic-hotels/tracking/base"
import { trackLoginClick } from "@scandic-hotels/tracking/navigation"
@@ -101,8 +100,8 @@ export function JoinScandicFriendsCard({ name = "join" }: Props) {
})}
</LoginButton>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -111,19 +110,18 @@ export function JoinScandicFriendsCard({ name = "join" }: Props) {
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}

View File

@@ -34,6 +34,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {

View File

@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -94,8 +93,8 @@ export function PartnerSASJoinScandicFriendsCard({
/>
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -104,19 +103,18 @@ export function PartnerSASJoinScandicFriendsCard({
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}

View File

@@ -31,6 +31,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {

View File

@@ -52,7 +52,7 @@ import { getPaymentHeadingConfig } from "./utils"
import styles from "./payment.module.css"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/input"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { PriceChangeData } from "../PriceChangeData"
@@ -128,7 +128,11 @@ export default function PaymentClient({
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => {
if (result) {
if (!result) {
handlePaymentError("No confirmation number")
return
}
if ("error" in result) {
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.set("errorCode", result.cause)
@@ -165,9 +169,6 @@ export default function PaymentClient({
} else {
setIsPollingForBookingStatus(true)
}
} else {
handlePaymentError("No confirmation number")
}
},
onError: (error) => {
logger.error("Booking error", error)
@@ -419,6 +420,7 @@ export default function PaymentClient({
}
),
}
initiateBooking.mutate(payload)
},
[

View File

@@ -6,7 +6,6 @@ import { useIntl } from "react-intl"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { Button } from "@scandic-hotels/design-system/Button"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
@@ -47,20 +46,14 @@ export default function SelectedRoom() {
<div className={styles.wrapper} data-available={room.isAvailable}>
<div className={styles.main}>
<div className={styles.headerContainer}>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color="uiTextHighContrast"
>
<h2>
<Typography variant="Title/Overline/sm">
<h2 className={styles.title}>
{intl.formatMessage({
id: "common.room",
defaultMessage: "Room",
})}
</h2>
</Footnote>
</Typography>
<Typography
variant="Title/Subtitle/md"
className={styles.description}

View File

@@ -9,7 +9,7 @@
}
.facilities {
font-family: var(--typography-Body-Bold-fontFamily);
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
padding-bottom: var(--Space-x3);
}

View File

@@ -177,12 +177,12 @@ export function SelectHotelMapContent({
>
<MaterialIcon icon="close" size={20} color="CurrentColor" />
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
<span>
{intl.formatMessage({
id: "selectHotel.closeMap",
defaultMessage: "Close the map",
})}
</p>
</span>
</Typography>
</Link>
</Button>

View File

@@ -29,7 +29,7 @@
.link {
display: flex;
gap: var(--Space-x05);
align-items: baseline;
align-items: center;
}
.bookingCodeFilter {

View File

@@ -3,7 +3,6 @@
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
@@ -37,9 +36,11 @@ export default function SignupPromoDesktop({
data-testid="signup-promo-desktop"
>
{badgeContent && <span className={styles.badge}>{badgeContent}</span>}
<Footnote color="burgundy">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
<Message price={price} isEnterDetailsPage={isEnterDetailsPage} />
</Footnote>
</p>
</Typography>
</div>
) : null
}

View File

@@ -2,7 +2,7 @@
import { useIntl } from "react-intl"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
@@ -14,9 +14,11 @@ export default function SignupPromoMobile() {
data-footer-spacing-signup
className={styles.memberDiscountBannerMobile}
>
<Footnote color="burgundy">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
<Message />
</Footnote>
</p>
</Typography>
</div>
)
}

View File

@@ -5,6 +5,7 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--Text-Default);
}
.memberDiscountBannerDesktop {
@@ -16,10 +17,11 @@
padding: var(--Space-x15) var(--Space-x2);
gap: var(--Space-x2);
position: relative;
color: var(--Text-Default);
}
.red {
color: var(--Text-Accent-Primary);
color: var(--Scandic-Brand-Scandic-Red);
}
.badge {

View File

@@ -9,7 +9,7 @@ import {
import { logger } from "@scandic-hotels/common/logger"
import { getServiceToken } from "@scandic-hotels/common/tokenManager"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import { getBooking } from "@scandic-hotels/trpc/routers/booking/utils"
import { getBooking } from "@scandic-hotels/trpc/services/booking/getBooking"
import { encrypt } from "@scandic-hotels/trpc/utils/encryption"
import { BookingFlowConfig } from "../bookingFlowConfig/bookingFlowConfig"
@@ -18,7 +18,7 @@ import { HandleSuccessCallback } from "../components/EnterDetails/Payment/Paymen
import { serverClient } from "../trpc"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { CreateBookingSchema } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { BookingStatus } from "@scandic-hotels/trpc/services/booking/getBookingStatus"
import type { NextSearchParams } from "../types"
@@ -99,7 +99,7 @@ export async function PaymentCallbackPage({
notFound()
}
const booking = await getBooking(confirmationNumber, lang, token)
const booking = await getBooking({ confirmationNumber, lang }, token)
const refId = booking?.refId
const caller = await serverClient()
@@ -156,7 +156,7 @@ function HandleBookingStatusError({
config,
status,
}: {
booking: CreateBookingSchema | null
booking: BookingStatus | null
confirmationNumber?: string
returnUrl: string
config: BookingFlowConfig

View File

@@ -0,0 +1,144 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { describe, expect, it, vi, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ChipButton } from "./ChipButton"
afterEach(() => {
cleanup()
})
describe("ChipButton accessibility", () => {
describe("semantic HTML", () => {
it("uses proper button element", () => {
render(<ChipButton>Button</ChipButton>)
const button = screen.getByRole("button")
expect(button.tagName).toBe("BUTTON")
})
it("has accessible button text", () => {
render(<ChipButton>Filter by price</ChipButton>)
expect(
screen.getByRole("button", { name: "Filter by price" })
).toBeTruthy()
})
it("button type defaults to button (not submit)", () => {
render(<ChipButton>Not Submit</ChipButton>)
const button = screen.getByRole("button")
// React Aria Components Button defaults to type="button"
expect(button.getAttribute("type")).toBe("button")
})
})
describe("disabled state", () => {
it("disabled button has disabled attribute", () => {
render(<ChipButton isDisabled>Disabled</ChipButton>)
const button = screen.getByRole("button", { name: "Disabled" })
expect(button).toHaveProperty("disabled", true)
})
it("disabled button is not focusable", async () => {
const user = userEvent.setup()
render(
<div>
<ChipButton isDisabled>Disabled</ChipButton>
<ChipButton>Enabled</ChipButton>
</div>
)
await user.tab()
// Focus should skip disabled button and go to enabled one
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Enabled" })
)
})
})
describe("keyboard navigation", () => {
it("button is keyboard accessible", async () => {
const user = userEvent.setup()
render(<ChipButton>Accessible Button</ChipButton>)
const button = screen.getByRole("button")
await user.tab()
expect(document.activeElement).toBe(button)
})
it("multiple buttons maintain logical focus order", async () => {
const user = userEvent.setup()
render(
<div>
<ChipButton>First</ChipButton>
<ChipButton>Second</ChipButton>
<ChipButton>Third</ChipButton>
</div>
)
const firstButton = screen.getByRole("button", { name: "First" })
const secondButton = screen.getByRole("button", { name: "Second" })
const thirdButton = screen.getByRole("button", { name: "Third" })
await user.tab()
expect(document.activeElement).toBe(firstButton)
await user.tab()
expect(document.activeElement).toBe(secondButton)
await user.tab()
expect(document.activeElement).toBe(thirdButton)
})
it("Enter key activates button", async () => {
const onPress = vi.fn()
const user = userEvent.setup()
render(<ChipButton onPress={onPress}>Activate</ChipButton>)
await user.tab()
await user.keyboard("{Enter}")
expect(onPress).toHaveBeenCalledTimes(1)
})
it("Space key activates button", async () => {
const onPress = vi.fn()
const user = userEvent.setup()
render(<ChipButton onPress={onPress}>Activate</ChipButton>)
await user.tab()
await user.keyboard(" ")
expect(onPress).toHaveBeenCalledTimes(1)
})
})
describe("screen reader support", () => {
it("button has accessible name from content", () => {
render(<ChipButton>Descriptive Button Text</ChipButton>)
const button = screen.getByRole("button")
expect(button.textContent?.trim().length).toBeGreaterThan(0)
})
it("button with icon and text has accessible name", () => {
render(
<ChipButton>
<span aria-hidden="true"></span>
Selected Filter
</ChipButton>
)
const button = screen.getByRole("button")
expect(button.textContent).toContain("Selected Filter")
})
})
describe("selected state accessibility", () => {
it("selected state is indicated via aria-pressed", () => {
render(
<ChipButton selected aria-pressed={true}>
Selected
</ChipButton>
)
const button = screen.getByRole("button", { name: "Selected" })
expect(button.getAttribute("aria-pressed")).toBe("true")
})
})
})

View File

@@ -0,0 +1,148 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { describe, expect, it, vi, afterEach } from "vitest"
import { render, screen, cleanup, fireEvent } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ChipButton } from "./ChipButton"
afterEach(() => {
cleanup()
})
describe("ChipButton", () => {
describe("rendering", () => {
it("renders as a button element", () => {
render(<ChipButton>Click me</ChipButton>)
const button = screen.getByRole("button", { name: "Click me" })
expect(button).toBeTruthy()
expect(button.tagName).toBe("BUTTON")
})
it("renders children content", () => {
render(<ChipButton>Button Content</ChipButton>)
expect(screen.getByText("Button Content")).toBeTruthy()
})
it("renders with multiple children", () => {
render(
<ChipButton>
<span>Icon</span>
Label
</ChipButton>
)
expect(screen.getByText("Icon")).toBeTruthy()
expect(screen.getByText("Label")).toBeTruthy()
})
})
describe("variants", () => {
it("renders Default variant", () => {
render(<ChipButton variant="Default">Default</ChipButton>)
expect(screen.getByRole("button", { name: "Default" })).toBeTruthy()
})
it("renders Outlined variant", () => {
render(<ChipButton variant="Outlined">Outlined</ChipButton>)
expect(screen.getByRole("button", { name: "Outlined" })).toBeTruthy()
})
it("renders FilterRounded variant", () => {
render(<ChipButton variant="FilterRounded">Filter</ChipButton>)
expect(screen.getByRole("button", { name: "Filter" })).toBeTruthy()
})
})
describe("selected state", () => {
it("renders with selected=false by default", () => {
render(<ChipButton>Not Selected</ChipButton>)
expect(screen.getByRole("button", { name: "Not Selected" })).toBeTruthy()
})
it("renders with selected=true", () => {
render(<ChipButton selected>Selected</ChipButton>)
expect(screen.getByRole("button", { name: "Selected" })).toBeTruthy()
})
})
describe("sizes", () => {
it("renders Medium size", () => {
render(<ChipButton size="Medium">Medium</ChipButton>)
expect(screen.getByRole("button", { name: "Medium" })).toBeTruthy()
})
it("renders Large size (default)", () => {
render(<ChipButton size="Large">Large</ChipButton>)
expect(screen.getByRole("button", { name: "Large" })).toBeTruthy()
})
})
describe("props", () => {
it("applies custom className", () => {
render(<ChipButton className="custom-class">Button</ChipButton>)
const button = screen.getByRole("button", { name: "Button" })
expect(button.className).toContain("custom-class")
})
it("can be disabled", () => {
render(<ChipButton isDisabled>Disabled</ChipButton>)
const button = screen.getByRole("button", { name: "Disabled" })
expect(button).toHaveProperty("disabled", true)
})
})
describe("interactions", () => {
it("calls onPress when clicked", () => {
const onPress = vi.fn()
render(<ChipButton onPress={onPress}>Click me</ChipButton>)
// React Aria Components Button uses onPress which listens to click events
fireEvent.click(screen.getByRole("button", { name: "Click me" }))
expect(onPress).toHaveBeenCalledTimes(1)
})
it("does not call onPress when disabled", () => {
const onPress = vi.fn()
render(
<ChipButton isDisabled onPress={onPress}>
Disabled
</ChipButton>
)
fireEvent.click(screen.getByRole("button", { name: "Disabled" }))
expect(onPress).not.toHaveBeenCalled()
})
})
describe("keyboard navigation", () => {
it("is focusable via keyboard", async () => {
const user = userEvent.setup()
render(<ChipButton>Focusable</ChipButton>)
const button = screen.getByRole("button", { name: "Focusable" })
await user.tab()
expect(document.activeElement).toBe(button)
})
it("can be activated with Enter key", async () => {
const onPress = vi.fn()
const user = userEvent.setup()
render(<ChipButton onPress={onPress}>Press Enter</ChipButton>)
screen.getByRole("button", { name: "Press Enter" })
await user.tab()
await user.keyboard("{Enter}")
expect(onPress).toHaveBeenCalledTimes(1)
})
it("can be activated with Space key", async () => {
const onPress = vi.fn()
const user = userEvent.setup()
render(<ChipButton onPress={onPress}>Press Space</ChipButton>)
screen.getByRole("button", { name: "Press Space" })
await user.tab()
await user.keyboard(" ")
expect(onPress).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,85 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { describe, expect, it, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ChipLink } from "./ChipLink"
afterEach(() => {
cleanup()
})
describe("ChipLink accessibility", () => {
describe("semantic HTML", () => {
it("uses proper link element for navigation", () => {
render(<ChipLink href="/test">Test Link</ChipLink>)
const link = screen.getByRole("link")
expect(link.tagName).toBe("A")
})
it("has accessible link text", () => {
render(<ChipLink href="/hotels">View Hotels</ChipLink>)
expect(screen.getByRole("link", { name: "View Hotels" })).toBeTruthy()
})
it("link has descriptive href attribute", () => {
render(<ChipLink href="/hotels/stockholm">Stockholm Hotels</ChipLink>)
const link = screen.getByRole("link", { name: "Stockholm Hotels" })
expect(link.getAttribute("href")).toBe("/hotels/stockholm")
})
})
describe("keyboard navigation", () => {
it("link is keyboard accessible", async () => {
const user = userEvent.setup()
render(<ChipLink href="/test">Accessible Link</ChipLink>)
const link = screen.getByRole("link")
await user.tab()
expect(document.activeElement).toBe(link)
})
it("multiple links maintain logical focus order", async () => {
const user = userEvent.setup()
render(
<div>
<ChipLink href="/first">First Link</ChipLink>
<ChipLink href="/second">Second Link</ChipLink>
<ChipLink href="/third">Third Link</ChipLink>
</div>
)
const firstLink = screen.getByRole("link", { name: "First Link" })
const secondLink = screen.getByRole("link", { name: "Second Link" })
const thirdLink = screen.getByRole("link", { name: "Third Link" })
await user.tab()
expect(document.activeElement).toBe(firstLink)
await user.tab()
expect(document.activeElement).toBe(secondLink)
await user.tab()
expect(document.activeElement).toBe(thirdLink)
})
})
describe("screen reader support", () => {
it("link has accessible name from content", () => {
render(<ChipLink href="/test">Descriptive Link Text</ChipLink>)
const link = screen.getByRole("link")
expect(link.textContent?.trim().length).toBeGreaterThan(0)
})
it("link with icon and text has accessible name", () => {
render(
<ChipLink href="/test">
<span aria-hidden="true"></span>
Next Page
</ChipLink>
)
const link = screen.getByRole("link")
expect(link.textContent).toContain("Next Page")
})
})
})

View File

@@ -0,0 +1,86 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { describe, expect, it, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ChipLink } from "./ChipLink"
afterEach(() => {
cleanup()
})
describe("ChipLink", () => {
describe("rendering", () => {
it("renders as a link element", () => {
render(<ChipLink href="/test">Test Link</ChipLink>)
const link = screen.getByRole("link", { name: "Test Link" })
expect(link).toBeTruthy()
expect(link.tagName).toBe("A")
})
it("renders children content", () => {
render(<ChipLink href="/test">Link Content</ChipLink>)
expect(screen.getByText("Link Content")).toBeTruthy()
})
it("applies correct href attribute", () => {
render(<ChipLink href="/destination">Go somewhere</ChipLink>)
const link = screen.getByRole("link", { name: "Go somewhere" })
expect(link.getAttribute("href")).toBe("/destination")
})
it("renders with multiple children", () => {
render(
<ChipLink href="/test">
<span>Icon</span>
Text
</ChipLink>
)
expect(screen.getByText("Icon")).toBeTruthy()
expect(screen.getByText("Text")).toBeTruthy()
})
})
describe("props", () => {
it("applies custom className", () => {
render(
<ChipLink href="/test" className="custom-class">
Link
</ChipLink>
)
const link = screen.getByRole("link", { name: "Link" })
expect(link.className).toContain("custom-class")
})
it("supports target attribute", () => {
render(
<ChipLink href="/external" target="_blank">
External Link
</ChipLink>
)
const link = screen.getByRole("link", { name: "External Link" })
expect(link.getAttribute("target")).toBe("_blank")
})
it("supports rel attribute", () => {
render(
<ChipLink href="/external" rel="noopener noreferrer">
External Link
</ChipLink>
)
const link = screen.getByRole("link", { name: "External Link" })
expect(link.getAttribute("rel")).toBe("noopener noreferrer")
})
})
describe("keyboard navigation", () => {
it("is focusable via keyboard", async () => {
const user = userEvent.setup()
render(<ChipLink href="/test">Focusable Link</ChipLink>)
const link = screen.getByRole("link", { name: "Focusable Link" })
await user.tab()
expect(document.activeElement).toBe(link)
})
})
})

View File

@@ -0,0 +1,116 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { describe, expect, it, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ChipStatic } from "./ChipStatic"
afterEach(() => {
cleanup()
})
describe("ChipStatic accessibility", () => {
describe("semantic HTML", () => {
it("uses span element (non-interactive)", () => {
render(<ChipStatic>Static Label</ChipStatic>)
const chip = screen.getByText("Static Label")
expect(chip.tagName).toBe("SPAN")
})
it("is not a button or link", () => {
render(<ChipStatic>Not Interactive</ChipStatic>)
expect(screen.queryByRole("button")).toBeNull()
expect(screen.queryByRole("link")).toBeNull()
})
it("content is visible and readable", () => {
render(<ChipStatic>Readable Content</ChipStatic>)
expect(screen.getByText("Readable Content")).toBeTruthy()
})
})
describe("non-interactive behavior", () => {
it("is not focusable by default", async () => {
const user = userEvent.setup()
render(
<div>
<ChipStatic>Static Chip</ChipStatic>
<button>Focusable Button</button>
</div>
)
await user.tab()
// Focus should skip the static chip and go directly to the button
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Focusable Button" })
)
})
it("does not receive focus when tabbing through page", async () => {
const user = userEvent.setup()
render(
<div>
<button>First</button>
<ChipStatic>Static</ChipStatic>
<button>Second</button>
</div>
)
const firstButton = screen.getByRole("button", { name: "First" })
const secondButton = screen.getByRole("button", { name: "Second" })
await user.tab()
expect(document.activeElement).toBe(firstButton)
await user.tab()
expect(document.activeElement).toBe(secondButton)
})
})
describe("screen reader support", () => {
it("has visible text content", () => {
render(<ChipStatic>Screen Reader Text</ChipStatic>)
const chip = screen.getByText("Screen Reader Text")
expect(chip.textContent?.trim().length).toBeGreaterThan(0)
})
it("text content is accessible in the DOM", () => {
render(<ChipStatic>Status: Active</ChipStatic>)
expect(screen.getByText("Status: Active")).toBeTruthy()
})
})
describe("color contrast considerations", () => {
it("Neutral color variant renders", () => {
render(<ChipStatic color="Neutral">Neutral</ChipStatic>)
expect(screen.getByText("Neutral")).toBeTruthy()
})
it("Subtle color variant renders", () => {
render(<ChipStatic color="Subtle">Subtle</ChipStatic>)
expect(screen.getByText("Subtle")).toBeTruthy()
})
it("Disabled color variant renders", () => {
render(<ChipStatic color="Disabled">Disabled</ChipStatic>)
expect(screen.getByText("Disabled")).toBeTruthy()
})
})
describe("text sizing", () => {
it("xs size renders readable text", () => {
render(<ChipStatic size="xs">Extra Small</ChipStatic>)
expect(screen.getByText("Extra Small")).toBeTruthy()
})
it("sm size renders readable text", () => {
render(<ChipStatic size="sm">Small</ChipStatic>)
expect(screen.getByText("Small")).toBeTruthy()
})
it("lg size renders readable text", () => {
render(<ChipStatic size="lg">Large</ChipStatic>)
expect(screen.getByText("Large")).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,104 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { describe, expect, it, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import { ChipStatic } from "./ChipStatic"
afterEach(() => {
cleanup()
})
describe("ChipStatic", () => {
describe("rendering", () => {
it("renders as a span element", () => {
render(<ChipStatic>Static Chip</ChipStatic>)
const chip = screen.getByText("Static Chip")
expect(chip).toBeTruthy()
expect(chip.tagName).toBe("SPAN")
})
it("renders children content", () => {
render(<ChipStatic>Chip Content</ChipStatic>)
expect(screen.getByText("Chip Content")).toBeTruthy()
})
it("renders with multiple children", () => {
render(
<ChipStatic>
<span>Icon</span>
Label
</ChipStatic>
)
expect(screen.getByText("Icon")).toBeTruthy()
expect(screen.getByText("Label")).toBeTruthy()
})
})
describe("color variants", () => {
it("renders Neutral color (default)", () => {
render(<ChipStatic color="Neutral">Neutral</ChipStatic>)
expect(screen.getByText("Neutral")).toBeTruthy()
})
it("renders Subtle color", () => {
render(<ChipStatic color="Subtle">Subtle</ChipStatic>)
expect(screen.getByText("Subtle")).toBeTruthy()
})
it("renders Disabled color", () => {
render(<ChipStatic color="Disabled">Disabled</ChipStatic>)
expect(screen.getByText("Disabled")).toBeTruthy()
})
})
describe("sizes", () => {
it("renders xs size", () => {
render(<ChipStatic size="xs">Extra Small</ChipStatic>)
expect(screen.getByText("Extra Small")).toBeTruthy()
})
it("renders sm size (default)", () => {
render(<ChipStatic size="sm">Small</ChipStatic>)
expect(screen.getByText("Small")).toBeTruthy()
})
it("renders lg size", () => {
render(<ChipStatic size="lg">Large</ChipStatic>)
expect(screen.getByText("Large")).toBeTruthy()
})
})
describe("typography", () => {
it("uses uppercase typography by default", () => {
render(<ChipStatic>Default Case</ChipStatic>)
expect(screen.getByText("Default Case")).toBeTruthy()
})
it("uses lowercase typography when lowerCase is true", () => {
render(<ChipStatic lowerCase>Lower Case</ChipStatic>)
expect(screen.getByText("Lower Case")).toBeTruthy()
})
})
describe("props", () => {
it("applies custom className", () => {
render(<ChipStatic className="custom-class">Styled</ChipStatic>)
const chip = screen.getByText("Styled")
expect(chip.className).toContain("custom-class")
})
})
describe("edge cases", () => {
it("handles empty string children", () => {
const emptyString = ""
const { container } = render(<ChipStatic>{emptyString}</ChipStatic>)
const span = container.querySelector("span")
expect(span).toBeTruthy()
})
it("handles numeric children", () => {
render(<ChipStatic>{42}</ChipStatic>)
expect(screen.getByText("42")).toBeTruthy()
})
})
})

View File

@@ -1,82 +0,0 @@
.footnote {
margin: 0;
padding: 0;
}
.footnoteFontOnly {
font-style: normal;
}
.bold {
font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: var(--typography-Footnote-Bold-fontWeight);
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight);
text-decoration: var(--typography-Footnote-Bold-textDecoration);
}
.regular {
font-family: var(--typography-Footnote-Regular-fontFamily);
font-size: var(--typography-Footnote-Regular-fontSize);
font-weight: var(--typography-Footnote-Regular-fontWeight);
letter-spacing: var(--typography-Footnote-Regular-letterSpacing);
line-height: var(--typography-Footnote-Regular-lineHeight);
text-decoration: var(--typography-Footnote-Regular-textDecoration);
}
.labels {
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
}
.uppercase {
text-transform: uppercase;
}
.center {
text-align: center;
}
.left {
text-align: left;
}
.black {
color: var(--Main-Grey-100);
}
.burgundy {
color: var(--Scandic-Brand-Burgundy);
}
.pale {
color: var(--Scandic-Brand-Pale-Peach);
}
.peach50 {
color: var(--Scandic-Peach-50);
}
.uiTextMediumContrast {
color: var(--UI-Text-Medium-contrast);
}
.uiTextHighContrast {
color: var(--UI-Text-High-contrast);
}
.uiTextPlaceholder {
color: var(--UI-Text-Placeholder);
}
.white {
color: var(--Main-Grey-White);
}
.baseTextDisabled {
color: var(--Base-Text-Disabled);
}

View File

@@ -1,44 +0,0 @@
import { Slot } from "@radix-ui/react-slot"
import { footnoteFontOnlyVariants, footnoteVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
interface FootnoteProps
extends
Omit<React.HTMLAttributes<HTMLParagraphElement>, "color">,
VariantProps<typeof footnoteVariants> {
asChild?: boolean
fontOnly?: boolean
}
/**
* @deprecated Use `@scandic-hotels/design-system/Typography` instead.
*/
export default function Footnote({
asChild = false,
className = "",
color,
fontOnly = false,
textAlign,
textTransform,
type,
...props
}: FootnoteProps) {
const Comp = asChild ? Slot : "p"
const classNames = fontOnly
? footnoteFontOnlyVariants({
className,
textAlign,
textTransform,
type,
})
: footnoteVariants({
className,
color,
textAlign,
textTransform,
type,
})
return <Comp className={classNames} {...props} />
}

View File

@@ -1,61 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./footnote.module.css"
const config = {
variants: {
type: {
regular: styles.regular,
bold: styles.bold,
label: styles.labels,
},
color: {
black: styles.black,
burgundy: styles.burgundy,
pale: styles.pale,
peach50: styles.peach50,
uiTextMediumContrast: styles.uiTextMediumContrast,
uiTextHighContrast: styles.uiTextHighContrast,
uiTextPlaceholder: styles.uiTextPlaceholder,
white: styles.white,
baseTextDisabled: styles.baseTextDisabled,
},
textAlign: {
center: styles.center,
left: styles.left,
},
textTransform: {
uppercase: styles.uppercase,
},
},
defaultVariants: {
type: "regular",
},
} as const
export const footnoteVariants = cva(styles.footnote, config)
const fontOnlyConfig = {
variants: {
type: {
regular: styles.regular,
bold: styles.bold,
label: styles.labels,
},
textAlign: {
center: styles.center,
left: styles.left,
},
textTransform: {
uppercase: styles.uppercase,
},
},
defaultVariants: {
type: "regular",
},
} as const
export const footnoteFontOnlyVariants = cva(
styles.footnoteFontOnly,
fontOnlyConfig
)

View File

@@ -85,7 +85,10 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
ref={mergeRefs(field.ref, ref)}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
onChange={(event) => {
field.onChange(event)
props.onChange?.(event)
}}
value={field.value ?? ""}
autoComplete={autoComplete}
id={id ?? field.name}

View File

@@ -35,8 +35,3 @@
justify-content: start;
align-items: baseline;
}
.perNight {
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);
}

View File

@@ -1,8 +1,8 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { Divider } from "../../Divider"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { Divider } from "../../Divider"
import { Typography } from "../../Typography"
import styles from "./hotelPriceCard.module.css"
@@ -117,14 +117,16 @@ export function HotelPriceCard({
>
<p>
{productTypePrices.localPrice.currency}
<Typography variant="Body/Supporting text (caption)/smRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span className={styles.perNight}>
<span>
/
{intl.formatMessage({
id: "common.night",
defaultMessage: "night",
})}
</span>
</Typography>
</p>
</Typography>
</div>

View File

@@ -160,6 +160,8 @@ import EditOutlined from "./generated/EditOutlined"
import EditFilled from "./generated/EditFilled"
import EditCalendarOutlined from "./generated/EditCalendarOutlined"
import EditCalendarFilled from "./generated/EditCalendarFilled"
import EditDocumentOutlined from "./generated/EditDocumentOutlined"
import EditDocumentFilled from "./generated/EditDocumentFilled"
import EditSquareOutlined from "./generated/EditSquareOutlined"
import EditSquareFilled from "./generated/EditSquareFilled"
import ElectricBikeOutlined from "./generated/ElectricBikeOutlined"
@@ -642,6 +644,9 @@ const _materialIcons = {
edit_calendar: {
rounded: { outlined: EditCalendarOutlined, filled: EditCalendarFilled },
},
edit_document: {
rounded: { outlined: EditDocumentOutlined, filled: EditDocumentFilled },
},
edit_square: {
rounded: { outlined: EditSquareOutlined, filled: EditSquareFilled },
},

View File

@@ -0,0 +1,10 @@
/* AUTO-GENERATED — DO NOT EDIT */
import type { SVGProps } from "react"
const EditDocumentFilled = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox="0 -960 960 960" {...props}>
<path d="M220-80q-24 0-42-18t-18-42v-680q0-24 18-42t42-18h315q12 0 23.5 5t19.5 13l204 204q8 8 13 19.5t5 23.5v99q0 8-5 14.5t-13 8.5q-12 5-23 11.5T738-465L518-246q-8 8-13 19.5t-5 23.5v93q0 13-8.5 21.5T470-80zm340-30v-81q0-6 2-11t7-10l212-211q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22-4.5 22.5T902-300L692-89q-5 5-10 7t-11 2h-81q-13 0-21.5-8.5T560-110m263-194 37-39-37-37-38 38zM550-600h190L520-820l220 220-220-220v190q0 13 8.5 21.5T550-600" />
</svg>
)
export default EditDocumentFilled

View File

@@ -0,0 +1,10 @@
/* AUTO-GENERATED — DO NOT EDIT */
import type { SVGProps } from "react"
const EditDocumentOutlined = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox="0 -960 960 960" {...props}>
<path d="M560-110v-81q0-5.57 2-10.78 2-5.22 7-10.22l211.61-210.77q9.11-9.12 20.25-13.18Q812-440 823-440q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22-4.5 22.5-13.58 20.62L692-89q-5 5-10.22 7-5.21 2-10.78 2h-81q-12.75 0-21.37-8.63Q560-97.25 560-110m300-233-37-37zM620-140h38l121-122-37-37-122 121zM220-80q-24 0-42-18t-18-42v-680q0-24 18-42t42-18h315q12.44 0 23.72 5T578-862l204 204q8 8 13 19.28t5 23.72v71q0 12.75-8.68 21.37-8.67 8.63-21.5 8.63-12.82 0-21.32-8.63-8.5-8.62-8.5-21.37v-56H550q-12.75 0-21.37-8.63Q520-617.25 520-630v-190H220v680h250q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32Q482.75-80 470-80zm0-60v-680zm541-141-19-18 37 37z" />
</svg>
)
export default EditDocumentOutlined

View File

@@ -4,9 +4,7 @@ import { nodesToHtml } from "./utils"
import styles from "./jsontohtml.module.css"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
import { AlertSidepeekContent } from "../../types/sidepeekContent"
import { ContentBlockType } from "./types/rte/enums"
import type { RTENode } from "./types/rte/node"
import type { RenderOptions } from "./types/rte/option"
@@ -17,7 +15,7 @@ export type Node<T> = {
export type Embeds =
| {
__typename: Exclude<ContentBlockType, "ImageContainer" | "Alert">
__typename: Exclude<ContentBlockType, "ImageContainer">
system?: { uid: string } | null
url?: string | null
permanent_url?: string | null
@@ -31,25 +29,6 @@ export type Embeds =
image_left?: ImageVaultAsset
image_right?: ImageVaultAsset
}
| {
__typename: "Alert"
system?: { uid: string } | null
type: AlertTypeEnum
heading: string | null
text: string
phoneContact?: {
displayText: string
phoneNumber: string
footnote?: string | null
} | null
sidepeekContent?: AlertSidepeekContent | null
sidepeekCtaText?: string | null
link?: {
url: string
title: string
keepSearchParams?: boolean
} | null
}
export type EmbedByUid = Record<string, Node<Embeds>>

View File

@@ -20,7 +20,6 @@ import {
mapImageVaultAssetResponseToImageVaultAsset,
mapInsertResponseToImageVaultAsset,
} from "@scandic-hotels/common/utils/imageVault"
import { Alert } from "../Alert"
import { TextLink } from "../TextLink"
import type { EmbedByUid } from "./JsonToHtml"
import type { Attributes } from "./types/rte/attrs"
@@ -459,8 +458,6 @@ export const renderOptions: RenderOptions = {
)
}
return null
} else if (entry?.node.__typename === "Alert") {
return <Alert key={node.uid} {...entry.node} />
} else if (
entry?.node.__typename === "AccountPage" ||
entry?.node.__typename === "CampaignOverviewPage" ||

View File

@@ -0,0 +1,105 @@
import { describe, expect, it, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { LinkChips } from "./LinkChips"
import type { LinkChipsProps } from "./types"
afterEach(() => {
cleanup()
})
const defaultChips: LinkChipsProps["chips"] = [
{ title: "Hotels in Stockholm", url: "/hotels/stockholm" },
{ title: "Hotels in Copenhagen", url: "/hotels/copenhagen" },
{ title: "Hotels in Oslo", url: "/hotels/oslo" },
]
describe("LinkChips accessibility", () => {
describe("semantic HTML", () => {
it("uses proper link elements for navigation", () => {
render(<LinkChips chips={defaultChips} />)
const links = screen.getAllByRole("link")
expect(links.length).toBe(3)
links.forEach((link) => {
expect(link.tagName).toBe("A")
})
})
it("has accessible link text", () => {
render(<LinkChips chips={defaultChips} />)
expect(
screen.getByRole("link", { name: "Hotels in Stockholm" })
).toBeTruthy()
expect(
screen.getByRole("link", { name: "Hotels in Copenhagen" })
).toBeTruthy()
expect(screen.getByRole("link", { name: "Hotels in Oslo" })).toBeTruthy()
})
it("links have descriptive href attributes", () => {
render(<LinkChips chips={defaultChips} />)
const stockholmLink = screen.getByRole("link", {
name: "Hotels in Stockholm",
})
expect(stockholmLink.getAttribute("href")).toBe("/hotels/stockholm")
})
})
describe("keyboard navigation", () => {
it("all links are keyboard accessible", async () => {
const user = userEvent.setup()
render(<LinkChips chips={defaultChips} />)
const links = screen.getAllByRole("link")
expect(links.length).toBeGreaterThan(0)
// Tab through all links
for (const link of links) {
await user.tab()
expect(document.activeElement).toBe(link)
}
})
it("maintains logical focus order", async () => {
const user = userEvent.setup()
render(<LinkChips chips={defaultChips} />)
const firstLink = screen.getByRole("link", {
name: "Hotels in Stockholm",
})
const secondLink = screen.getByRole("link", {
name: "Hotels in Copenhagen",
})
const thirdLink = screen.getByRole("link", { name: "Hotels in Oslo" })
await user.tab()
expect(document.activeElement).toBe(firstLink)
await user.tab()
expect(document.activeElement).toBe(secondLink)
await user.tab()
expect(document.activeElement).toBe(thirdLink)
})
})
describe("screen reader support", () => {
it("links have accessible names", () => {
render(<LinkChips chips={defaultChips} />)
const links = screen.getAllByRole("link")
links.forEach((link) => {
// Check that link has text content (accessible name)
expect(link.textContent?.trim().length).toBeGreaterThan(0)
})
})
})
describe("empty state", () => {
it("does not render anything when chips array is empty", () => {
const { container } = render(<LinkChips chips={[]} />)
expect(container.firstChild).toBeNull()
expect(screen.queryAllByRole("link")).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { LinkChips } from "./LinkChips"
const meta: Meta<typeof LinkChips> = {
title: "Product Components/LinkChips",
component: LinkChips,
}
export default meta
type Story = StoryObj<typeof LinkChips>
export const Default: Story = {
args: {
chips: [
{ title: "Hotels in Stockholm", url: "/hotels/stockholm" },
{ title: "Hotels in Copenhagen", url: "/hotels/copenhagen" },
{ title: "Hotels in Oslo", url: "/hotels/oslo" },
],
},
}
export const SingleChip: Story = {
args: {
chips: [{ title: "View all hotels", url: "/hotels" }],
},
}
export const ManyChips: Story = {
args: {
chips: [
{ title: "Stockholm", url: "/hotels/stockholm" },
{ title: "Copenhagen", url: "/hotels/copenhagen" },
{ title: "Oslo", url: "/hotels/oslo" },
{ title: "Helsinki", url: "/hotels/helsinki" },
{ title: "Gothenburg", url: "/hotels/gothenburg" },
{ title: "Malmö", url: "/hotels/malmo" },
{ title: "Bergen", url: "/hotels/bergen" },
{ title: "Tampere", url: "/hotels/tampere" },
],
},
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, it, afterEach } from "vitest"
import { render, screen, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { LinkChips } from "./LinkChips"
import type { LinkChipsProps } from "./types"
afterEach(() => {
cleanup()
})
const defaultChips: LinkChipsProps["chips"] = [
{ title: "Hotels in Stockholm", url: "/hotels/stockholm" },
{ title: "Hotels in Copenhagen", url: "/hotels/copenhagen" },
{ title: "Hotels in Oslo", url: "/hotels/oslo" },
]
describe("LinkChips", () => {
describe("rendering", () => {
it("renders all chip links", () => {
render(<LinkChips chips={defaultChips} />)
expect(
screen.getByRole("link", { name: "Hotels in Stockholm" })
).toBeTruthy()
expect(
screen.getByRole("link", { name: "Hotels in Copenhagen" })
).toBeTruthy()
expect(screen.getByRole("link", { name: "Hotels in Oslo" })).toBeTruthy()
})
it("renders chip links with correct href attributes", () => {
render(<LinkChips chips={defaultChips} />)
expect(
screen.getByRole("link", { name: "Hotels in Stockholm" }).getAttribute("href")
).toBe("/hotels/stockholm")
expect(
screen.getByRole("link", { name: "Hotels in Copenhagen" }).getAttribute("href")
).toBe("/hotels/copenhagen")
expect(
screen.getByRole("link", { name: "Hotels in Oslo" }).getAttribute("href")
).toBe("/hotels/oslo")
})
it("returns null when chips array is empty", () => {
const { container } = render(<LinkChips chips={[]} />)
expect(container.firstChild).toBeNull()
})
it("handles single chip", () => {
const singleChip = [{ title: "View all hotels", url: "/hotels" }]
render(<LinkChips chips={singleChip} />)
expect(screen.getByRole("link", { name: "View all hotels" })).toBeTruthy()
expect(
screen.getByRole("link", { name: "View all hotels" }).getAttribute("href")
).toBe("/hotels")
})
})
describe("keyboard navigation", () => {
it("allows keyboard navigation between links", async () => {
const user = userEvent.setup()
render(<LinkChips chips={defaultChips} />)
const firstLink = screen.getByRole("link", {
name: "Hotels in Stockholm",
})
const secondLink = screen.getByRole("link", {
name: "Hotels in Copenhagen",
})
await user.tab()
expect(document.activeElement).toBe(firstLink)
await user.tab()
expect(document.activeElement).toBe(secondLink)
})
})
describe("edge cases", () => {
it("handles chips with duplicate titles but different URLs", () => {
const duplicateTitles: LinkChipsProps["chips"] = [
{ title: "Hotels", url: "/hotels/stockholm" },
{ title: "Hotels", url: "/hotels/copenhagen" },
]
render(<LinkChips chips={duplicateTitles} />)
const links = screen.getAllByRole("link", { name: "Hotels" })
expect(links).toHaveLength(2)
expect(links[0].getAttribute("href")).toBe("/hotels/stockholm")
expect(links[1].getAttribute("href")).toBe("/hotels/copenhagen")
})
})
})

View File

@@ -0,0 +1,23 @@
"use client"
import { ChipLink } from "../ChipLink"
import { Chips } from "../Chips"
import { MaterialIcon } from "../Icons/MaterialIcon"
import type { LinkChipsProps } from "./types"
export function LinkChips({ chips }: LinkChipsProps) {
if (!chips.length) {
return null
}
return (
<Chips>
{chips.map(({ title, url }) => (
<ChipLink key={`${title}-${url}`} href={url}>
{title}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</ChipLink>
))}
</Chips>
)
}

View File

@@ -0,0 +1,2 @@
export { LinkChips } from "./LinkChips"
export type { LinkChipsProps } from "./types"

View File

@@ -0,0 +1,6 @@
export interface LinkChipsProps {
chips: {
url: string
title: string
}[]
}

View File

@@ -50,30 +50,15 @@
gap: var(--Space-x05);
}
.breadcrumb {
font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight);
}
.link.breadcrumb {
font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight);
}
.myPageMobileDropdown {
display: flex;
align-items: center;
color: var(--Scandic-Brand-Burgundy);
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
line-height: var(--typography-Body-Regular-lineHeight);
letter-spacing: var(--typography-Body-Regular-letterSpacing);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
line-height: 1.5;
letter-spacing: var(--Body-Paragraph-Letter-spacing);
padding: var(--Space-x1);
border-radius: var(--Corner-Radius-md);
gap: var(--Space-x1);
@@ -97,11 +82,12 @@
.shortcut {
display: grid;
align-items: center;
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
letter-spacing: var(--typography-Body-Regular-letterSpacing);
line-height: var(--typography-Body-Regular-lineHeight);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
gap: var(--Space-x2);
grid-template-columns: 1fr auto;
padding: var(--Space-x2) var(--Space-x3);
@@ -133,22 +119,13 @@
line-height: 140%;
}
/* Tiny should be removed, it's not a variant of the Link*/
.tiny {
font-family: var(--typography-Footnote-Regular-fontFamily);
font-size: var(--typography-Footnote-Regular-fontSize);
font-weight: var(--typography-Footnote-Regular-fontWeight);
letter-spacing: var(--typography-Footnote-Regular-letterSpacing);
line-height: var(--typography-Footnote-Regular-lineHeight);
}
.bold {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500
/* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight) */;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
}
.menu {

View File

@@ -18,7 +18,6 @@ export const linkVariants = cva(styles.link, {
size: {
small: styles.small,
large: styles.large,
tiny: styles.tiny,
none: "",
},
textDecoration: {
@@ -29,7 +28,6 @@ export const linkVariants = cva(styles.link, {
},
variant: {
icon: styles.icon,
breadcrumb: styles.breadcrumb,
myPageMobileDropdown: styles.myPageMobileDropdown,
navigation: styles.navigation,
menu: styles.menu,

View File

@@ -1,131 +1,3 @@
:root {
--typography-Body-Bold-fontFamily: "fira sans";
--typography-Body-Bold-fontSize: 16px;
--typography-Body-Bold-fontWeight: "medium";
--typography-Body-Bold-letterSpacing: 1.2000000476837158%;
--typography-Body-Bold-lineHeight: 150%;
--typography-Body-Bold-textDecoration: "none";
--typography-Body-Regular-fontFamily: "fira sans";
--typography-Body-Regular-fontSize: 16px;
--typography-Body-Regular-fontWeight: "regular";
--typography-Body-Regular-letterSpacing: 1.2000000476837158%;
--typography-Body-Regular-lineHeight: 150%;
--typography-Body-Regular-textDecoration: "none";
--typography-Body-Underline-fontFamily: "fira sans";
--typography-Body-Underline-fontSize: 16px;
--typography-Body-Underline-letterSpacing: 1.2000000476837158%;
--typography-Body-Underline-lineHeight: 150%;
--typography-Body-Underline-textDecoration: "underline";
--typography-Caption-Bold-fontFamily: "fira sans";
--typography-Caption-Bold-fontSize: 14px;
--typography-Caption-Bold-fontWeight: "medium";
--typography-Caption-Bold-letterSpacing: 1.399999976158142%;
--typography-Caption-Bold-lineHeight: 139.9999976158142%;
--typography-Caption-Bold-textDecoration: "none";
--typography-Caption-Labels-fontFamily: "brandon text";
--typography-Caption-Labels-fontSize: 14px;
--typography-Caption-Labels-fontWeight: "bold";
--typography-Caption-Labels-letterSpacing: 1.399999976158142%;
--typography-Caption-Labels-lineHeight: 150%;
--typography-Caption-Labels-textDecoration: "none";
--typography-Caption-Regular-fontFamily: "fira sans";
--typography-Caption-Regular-fontSize: 14px;
--typography-Caption-Regular-fontWeight: "regular";
--typography-Caption-Regular-letterSpacing: 1.399999976158142%;
--typography-Caption-Regular-lineHeight: 139.9999976158142%;
--typography-Caption-Regular-textDecoration: "none";
--typography-Caption-Underline-fontFamily: "fira sans";
--typography-Caption-Underline-fontSize: 14px;
--typography-Caption-Underline-fontWeight: "medium";
--typography-Caption-Underline-letterSpacing: 1.399999976158142%;
--typography-Caption-Underline-lineHeight: 139.9999976158142%;
--typography-Caption-Underline-textDecoration: "underline";
--typography-Footnote-Bold-fontFamily: "fira sans";
--typography-Footnote-Bold-fontSize: 12px;
--typography-Footnote-Bold-fontWeight: "medium";
--typography-Footnote-Bold-letterSpacing: 1.399999976158142%;
--typography-Footnote-Bold-lineHeight: 150%;
--typography-Footnote-Bold-textDecoration: "none";
--typography-Footnote-Labels-fontFamily: "brandon text";
--typography-Footnote-Labels-fontSize: 12px;
--typography-Footnote-Labels-fontWeight: "bold";
--typography-Footnote-Labels-letterSpacing: 1.399999976158142%;
--typography-Footnote-Labels-lineHeight: 150%;
--typography-Footnote-Labels-textDecoration: "none";
--typography-Footnote-Regular-fontFamily: "fira sans";
--typography-Footnote-Regular-fontSize: 12px;
--typography-Footnote-Regular-fontWeight: "regular";
--typography-Footnote-Regular-letterSpacing: 1.399999976158142%;
--typography-Footnote-Regular-lineHeight: 150%;
--typography-Footnote-Regular-textDecoration: "none";
--typography-Preamble-Desktop-fontSize: 20px;
--typography-Preamble-fontFamily: "fira sans";
--typography-Preamble-fontWeight: "regular";
--typography-Preamble-letterSpacing: 1%;
--typography-Preamble-lineHeight: 139.9999976158142%;
--typography-Preamble-Mobile-fontSize: 18px;
--typography-Preamble-textDecoration: "none";
--typography-Script-1-Desktop-fontSize: 32px;
--typography-Script-1-fontFamily: "biro script plus";
--typography-Script-1-fontWeight: "regular";
--typography-Script-1-letterSpacing: 2%;
--typography-Script-1-lineHeight: 110.00000238418579%;
--typography-Script-1-Mobile-fontSize: 24px;
--typography-Script-2-Desktop-fontSize: 24px;
--typography-Script-2-fontWeight: "regular";
--typography-Script-2-letterSpacing: 2%;
--typography-Script-2-lineHeight: 110.00000238418579%;
--typography-Script-2-Mobile-fontSize: 20px;
--typography-Subtitle-1-Desktop-fontSize: 24px;
--typography-Subtitle-1-fontFamily: "fira sans";
--typography-Subtitle-1-letterSpacing: 1%;
--typography-Subtitle-1-lineHeight: 120.00000476837158%;
--typography-Subtitle-1-Mobile-fontSize: 20px;
--typography-Subtitle-2-Desktop-fontSize: 20px;
--typography-Subtitle-2-fontFamily: "fira sans";
--typography-Subtitle-2-fontSize: 20px;
--typography-Subtitle-2-fontWeight: "medium";
--typography-Subtitle-2-letterSpacing: 1%;
--typography-Subtitle-2-lineHeight: 120.00000476837158%;
--typography-Subtitle-2-Mobile-fontSize: 18px;
--typography-Title-1-Desktop-fontSize: 64px;
--typography-Title-1-fontFamily: "brandon text";
--typography-Title-1-fontSize: 64px;
--typography-Title-1-fontWeight: "black";
--typography-Title-1-letterSpacing: 0.25%;
--typography-Title-1-lineHeight: 110.00000238418579%;
--typography-Title-1-Mobile-fontSize: 48px;
--typography-Title-1-textDecoration: "none";
--typography-Title-2-Desktop-fontSize: 48px;
--typography-Title-2-fontFamily: "brandon text";
--typography-Title-2-fontWeight: "black";
--typography-Title-2-letterSpacing: 0.25%;
--typography-Title-2-lineHeight: 110.00000238418579%;
--typography-Title-2-Mobile-fontSize: 36px;
--typography-Title-2-textDecoration: "none";
--typography-Title-3-Desktop-fontSize: 36px;
--typography-Title-3-fontFamily: "brandon text";
--typography-Title-3-fontSize: 36px;
--typography-Title-3-fontWeight: "black";
--typography-Title-3-letterSpacing: 0.25%;
--typography-Title-3-lineHeight: 110.00000238418579%;
--typography-Title-3-Mobile-fontSize: 30px;
--typography-Title-3-textDecoration: "none";
--typography-Title-4-Desktop-fontSize: 28px;
--typography-Title-4-fontFamily: "brandon text";
--typography-Title-4-fontWeight: "bold";
--typography-Title-4-letterSpacing: 0.25%;
--typography-Title-4-lineHeight: 110.00000238418579%;
--typography-Title-4-Mobile-fontSize: 24px;
--typography-Title-4-textDecoration: "none";
--typography-Title-5-Desktop-fontSize: 24px;
--typography-Title-5-fontFamily: "brandon text";
--typography-Title-5-fontWeight: "black";
--typography-Title-5-letterSpacing: 0.25%;
--typography-Title-5-lineHeight: 110.00000238418579%;
--typography-Title-5-Mobile-fontSize: 20px;
--typography-Title-5-textDecoration: "none";
}
:root {
--Base-Border-Hover: var(--Scandic-Peach-80);
--Base-Border-Inverted: var(--UI-Opacity-White-100);

Some files were not shown because too many files have changed in this diff Show More