Compare commits
10 Commits
fbdbd35813
...
089bbe7c4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089bbe7c4f | ||
|
|
c62999879f | ||
|
|
989b18527e | ||
|
|
0cda37808e | ||
|
|
b3c4761ae5 | ||
|
|
dd65467573 | ||
|
|
eb45e6b294 | ||
|
|
6553fcf685 | ||
|
|
c2cf6b03a7 | ||
|
|
310ad7bc7f |
@@ -1,3 +1,4 @@
|
|||||||
.layout {
|
.layout {
|
||||||
font-family: var(--typography-Body-Regular-fontFamily);
|
font-family:
|
||||||
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,3 +52,4 @@ DTMC_ENTRA_ID_CLIENT=""
|
|||||||
DTMC_ENTRA_ID_ISSUER=""
|
DTMC_ENTRA_ID_ISSUER=""
|
||||||
DTMC_ENTRA_ID_SECRET=""
|
DTMC_ENTRA_ID_SECRET=""
|
||||||
|
|
||||||
|
NEXT_PUBLIC_NEW_POINTCLAIMS="true"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
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;
|
grid-template-rows: auto 1fr;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
max-width: var(--max-width-page);
|
max-width: var(--max-width-page);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
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;
|
grid-template-rows: auto 1fr;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.layout {
|
.layout {
|
||||||
font-family: var(--typography-Body-Regular-fontFamily);
|
font-family:
|
||||||
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
|
||||||
import Image from "@scandic-hotels/design-system/Image"
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
@@ -94,27 +93,24 @@ export default async function SASxScandicLoginPage(
|
|||||||
{intentDescriptions[parsedParams.intent]}
|
{intentDescriptions[parsedParams.intent]}
|
||||||
</p>
|
</p>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Footnote textAlign="center">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
{intl.formatMessage(
|
<p style={{ textAlign: "center" }}>
|
||||||
{
|
{intl.formatMessage(
|
||||||
id: "linkEuroBonusAccount.manualRedirectLinkMessage",
|
{
|
||||||
defaultMessage:
|
id: "linkEuroBonusAccount.manualRedirectLinkMessage",
|
||||||
"If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
defaultMessage:
|
||||||
},
|
"If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||||
{
|
},
|
||||||
loginLink: (str) => (
|
{
|
||||||
<Link
|
loginLink: (str) => (
|
||||||
href={loginLink}
|
<TextLink typography="Link/sm" href={loginLink}>
|
||||||
color="red"
|
{str}
|
||||||
size="tiny"
|
</TextLink>
|
||||||
textDecoration="underline"
|
),
|
||||||
>
|
}
|
||||||
{str}
|
)}
|
||||||
</Link>
|
</p>
|
||||||
),
|
</Typography>
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Footnote>
|
|
||||||
</SASModal>
|
</SASModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
width: 34px;
|
width: 34px;
|
||||||
height: 0px;
|
height: 0px;
|
||||||
padding: var(--Space-x3) 0;
|
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: 1px solid var(--Base-Border-Normal);
|
||||||
border-radius: var(--Corner-Radius-md);
|
border-radius: var(--Corner-Radius-md);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.layout {
|
.layout {
|
||||||
background-color: var(--Background-Primary);
|
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;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.layout {
|
.layout {
|
||||||
font-family: var(--typography-Body-Regular-fontFamily);
|
font-family:
|
||||||
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
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"
|
import type { AlertBlock } from "@scandic-hotels/trpc/types/blocks"
|
||||||
|
|
||||||
interface AlertBlockProps extends Pick<AlertBlock, "alert"> {}
|
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) {
|
if (!alert) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Alert {...alert} />
|
const phoneContact =
|
||||||
|
alert.phoneContact && contactConfig
|
||||||
|
? getAlertPhoneContactData(alert, contactConfig)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
{...alert}
|
||||||
|
phoneContact={phoneContact}
|
||||||
|
sidepeekCtaText={alert.sidepeekButton?.cta_text}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,14 @@
|
|||||||
|
|
||||||
.iconTh {
|
.iconTh {
|
||||||
padding: var(--Space-x5) var(--Space-x2) var(--Space-x2);
|
padding: var(--Space-x5) var(--Space-x2) var(--Space-x2);
|
||||||
font-weight: var(--typography-Caption-Regular-fontWeight);
|
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryTh {
|
.summaryTh {
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
|
||||||
font-weight: var(--typography-Caption-Regular-fontWeight);
|
|
||||||
padding: 0 var(--Space-x2) var(--Space-x2);
|
padding: 0 var(--Space-x2) var(--Space-x2);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select {
|
.select {
|
||||||
font-weight: var(--typography-Caption-Regular-fontWeight);
|
|
||||||
padding: 0 var(--Space-x2) var(--Space-x2);
|
padding: 0 var(--Space-x2) var(--Space-x2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||||
|
|
||||||
import LevelSummary from "../../LevelSummary"
|
import LevelSummary from "../../LevelSummary"
|
||||||
@@ -37,12 +39,14 @@ export default function DesktopHeader({
|
|||||||
<th />
|
<th />
|
||||||
{levels.map((level, idx) => {
|
{levels.map((level, idx) => {
|
||||||
return (
|
return (
|
||||||
<th
|
<Typography
|
||||||
key={"summary" + level.level_id + idx}
|
variant="Body/Supporting text (caption)/smRegular"
|
||||||
className={styles.summaryTh}
|
key={"name" + level.level_id + idx}
|
||||||
>
|
>
|
||||||
<LevelSummary level={level} />
|
<th className={styles.summaryTh}>
|
||||||
</th>
|
<LevelSummary level={level} />
|
||||||
|
</th>
|
||||||
|
</Typography>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -82,10 +82,12 @@ function RewardTableHeader({ name, description }: RewardTableHeaderProps) {
|
|||||||
</span>
|
</span>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
</summary>
|
</summary>
|
||||||
<p
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
className={styles.rewardDescription}
|
<p
|
||||||
dangerouslySetInnerHTML={{ __html: description }}
|
className={styles.rewardDescription}
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: description }}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.td {
|
.td {
|
||||||
font-size: var(--typography-Footnote-Regular-fontSize);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rewardTh {
|
.rewardTh {
|
||||||
padding: var(--Space-x3) var(--Space-x2);
|
padding: var(--Space-x3) var(--Space-x2);
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
|
||||||
font-weight: var(--typography-Caption-Regular-fontWeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.details[open] .chevron {
|
.details[open] .chevron {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import styles from "./levelSummary.module.css"
|
import styles from "./levelSummary.module.css"
|
||||||
|
|
||||||
import type { LevelSummaryProps } from "@/types/components/overviewTable"
|
import type { LevelSummaryProps } from "@/types/components/overviewTable"
|
||||||
@@ -32,7 +34,9 @@ export default function LevelSummary({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.levelSummary}>
|
<div className={styles.levelSummary}>
|
||||||
<span className={styles.levelRequirements}>{pointsMsg}</span>
|
<Typography variant="Label/xsRegular">
|
||||||
|
<span className={styles.levelRequirements}>{pointsMsg}</span>
|
||||||
|
</Typography>
|
||||||
{showDescription && (
|
{showDescription && (
|
||||||
<p className={styles.levelSummaryText}>{level.description}</p>
|
<p className={styles.levelSummaryText}>{level.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,16 +8,14 @@
|
|||||||
|
|
||||||
.levelRequirements {
|
.levelRequirements {
|
||||||
border-radius: var(--Corner-Radius-md);
|
border-radius: var(--Corner-Radius-md);
|
||||||
background-color: var(--Scandic-Brand-Pale-Peach);
|
background-color: var(--Surface-Brand-Primary-1-Default);
|
||||||
color: var(--Scandic-Peach-80);
|
color: var(--Text-Interactive-Secondary);
|
||||||
padding: var(--Space-x05) var(--Space-x1);
|
padding: var(--Space-x05) var(--Space-x1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.levelSummaryText {
|
.levelSummaryText {
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
|
||||||
line-height: var(--typography-Body-Regular-lineHeight);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,12 +24,3 @@
|
|||||||
padding: var(--Space-x05) var(--Space-x1);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ export default function RewardCard({
|
|||||||
</span>
|
</span>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
</summary>
|
</summary>
|
||||||
<p
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
className={styles.rewardCardDescription}
|
<p
|
||||||
dangerouslySetInnerHTML={{ __html: description }}
|
className={styles.rewardCardDescription}
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: description }}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rewardComparison}>
|
<div className={styles.rewardComparison}>
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rewardCardDescription {
|
.rewardCardDescription {
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
|
||||||
line-height: 150%;
|
|
||||||
padding-right: var(--Space-x4);
|
padding-right: var(--Space-x4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Minus } from "react-feather"
|
import { Minus } from "react-feather"
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import styles from "./rewardValue.module.css"
|
import styles from "./rewardValue.module.css"
|
||||||
|
|
||||||
@@ -21,8 +22,8 @@ export default function RewardValue({ reward }: RewardValueProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.rewardValueContainer}>
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
<span className={styles.rewardValue}>{reward.value}</span>
|
<div className={styles.rewardValueContainer}>{reward.value}</div>
|
||||||
</div>
|
</Typography>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,6 @@
|
|||||||
text-wrap: balance;
|
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 {
|
.checkIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 can’t 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,3 +6,100 @@
|
|||||||
gap: var(--Space-x2);
|
gap: var(--Space-x2);
|
||||||
white-space: nowrap;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,110 @@
|
|||||||
|
/* eslint-disable formatjs/no-literal-string-in-jsx */
|
||||||
|
/* TODO remove disable and add i18n */
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Dialog } from "react-aria-components"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { missingPoints } from "@/constants/missingPointsHrefs"
|
import { missingPoints } from "@/constants/missingPointsHrefs"
|
||||||
|
import { env } from "@/env/client"
|
||||||
|
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import { ClaimPointsWizard } from "./ClaimPointsWizard"
|
||||||
|
|
||||||
import styles from "./claimPoints.module.css"
|
import styles from "./claimPoints.module.css"
|
||||||
|
|
||||||
export default function ClaimPoints() {
|
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'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 intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
|
|||||||
|
|
||||||
if (isNonTransactional && transaction.attributes.nights === 0) {
|
if (isNonTransactional && transaction.attributes.nights === 0) {
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.pointsActivity",
|
id: "earnAndBurn.journeyTable.pointsActivity",
|
||||||
defaultMessage: "Point activity",
|
defaultMessage: "Point activity",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
|
|||||||
if (hotelInformation?.name) {
|
if (hotelInformation?.name) {
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "myPoints.pointTransactions.stayAt",
|
id: "earnAndBurn.journeyTable.stayAt",
|
||||||
defaultMessage: "Stay at {hotelName}",
|
defaultMessage: "Stay at {hotelName}",
|
||||||
},
|
},
|
||||||
{ hotelName: hotelInformation.name }
|
{ hotelName: hotelInformation.name }
|
||||||
@@ -124,53 +124,53 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
|
|||||||
case Transactions.rewardType.stayAdj:
|
case Transactions.rewardType.stayAdj:
|
||||||
if (transaction.attributes.hotelOperaId === "ORS") {
|
if (transaction.attributes.hotelOperaId === "ORS") {
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.formerScandicHotel",
|
id: "earnAndBurn.journeyTable.formerScandicHotel",
|
||||||
defaultMessage: "Former Scandic Hotel",
|
defaultMessage: "Former Scandic Hotel",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (isBalfwd) {
|
if (isBalfwd) {
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.pointsEarnedPriorMay2021",
|
id: "earnAndBurn.journeyTable.pointsEarnedPriorMay2021",
|
||||||
defaultMessage: "Points earned prior to May 1, 2021",
|
defaultMessage: "Points earned prior to May 1, 2021",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case Transactions.rewardType.redgift:
|
case Transactions.rewardType.redgift:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.redGift",
|
id: "earnAndBurn.journeyTable.redGift",
|
||||||
defaultMessage: "Reward Gift",
|
defaultMessage: "Reward Gift",
|
||||||
})
|
})
|
||||||
case Transactions.rewardType.rewardNight:
|
case Transactions.rewardType.rewardNight:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.rewardNight",
|
id: "earnAndBurn.journeyTable.rewardNight",
|
||||||
defaultMessage: "Reward Night",
|
defaultMessage: "Reward Night",
|
||||||
})
|
})
|
||||||
case Transactions.rewardType.ancillary:
|
case Transactions.rewardType.ancillary:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.extrasToBooking",
|
id: "earnAndBurn.journeyTable.extrasToBooking",
|
||||||
defaultMessage: "Extras to your booking",
|
defaultMessage: "Extras to your booking",
|
||||||
})
|
})
|
||||||
|
|
||||||
case Transactions.rewardType.enrollment:
|
case Transactions.rewardType.enrollment:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.signUpBonus",
|
id: "earnAndBurn.journeyTable.signUpBonus",
|
||||||
defaultMessage: "Sign up bonus",
|
defaultMessage: "Sign up bonus",
|
||||||
})
|
})
|
||||||
|
|
||||||
case Transactions.rewardType.mastercard_points:
|
case Transactions.rewardType.mastercard_points:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.scandicFriendsMastercard",
|
id: "earnAndBurn.journeyTable.scandicFriendsMastercard",
|
||||||
defaultMessage: "Scandic Friends Mastercard",
|
defaultMessage: "Scandic Friends Mastercard",
|
||||||
})
|
})
|
||||||
|
|
||||||
case Transactions.rewardType.tui_points:
|
case Transactions.rewardType.tui_points:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.tuiPoints",
|
id: "earnAndBurn.journeyTable.tuiPoints",
|
||||||
defaultMessage: "TUI Points",
|
defaultMessage: "TUI Points",
|
||||||
})
|
})
|
||||||
|
|
||||||
case Transactions.rewardType.pointShop:
|
case Transactions.rewardType.pointShop:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "myPoints.pointTransactions.pointShop",
|
id: "earnAndBurn.journeyTable.pointShop",
|
||||||
defaultMessage: "Scandic Friends Point Shop",
|
defaultMessage: "Scandic Friends Point Shop",
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||||
|
|
||||||
import { getCampaignOverviewPage } from "@/lib/trpc/memoizedRequests"
|
import { getCampaignOverviewPage } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { TopCampaign } from "@/components/ContentType/CampaignOverviewPage/TopCampaign"
|
import { TopCampaign } from "@/components/ContentType/CampaignOverviewPage/TopCampaign"
|
||||||
import LinkChips from "@/components/TempDesignSystem/LinkChips"
|
|
||||||
|
|
||||||
import Blocks from "./Blocks"
|
import Blocks from "./Blocks"
|
||||||
import CampaignOverviewPageSkeleton from "./CampaignOverviewPageSkeleton"
|
import CampaignOverviewPageSkeleton from "./CampaignOverviewPageSkeleton"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@scandic-hotels/design-system/Breadcrumbs"
|
} from "@scandic-hotels/design-system/Breadcrumbs"
|
||||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||||
import Image from "@scandic-hotels/design-system/Image"
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||||
|
|
||||||
@@ -17,7 +18,6 @@ import { Breadcrumbs } from "@/components/Breadcrumbs"
|
|||||||
import HeaderDynamicContent from "@/components/Headers/DynamicContent"
|
import HeaderDynamicContent from "@/components/Headers/DynamicContent"
|
||||||
import { HeroVideo } from "@/components/HeroVideo"
|
import { HeroVideo } from "@/components/HeroVideo"
|
||||||
import MeetingPackageWidget from "@/components/MeetingPackageWidget"
|
import MeetingPackageWidget from "@/components/MeetingPackageWidget"
|
||||||
import LinkChips from "@/components/TempDesignSystem/LinkChips"
|
|
||||||
|
|
||||||
import styles from "./collectionPage.module.css"
|
import styles from "./collectionPage.module.css"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Suspense } from "react"
|
|||||||
|
|
||||||
import { BreadcrumbsSkeleton } from "@scandic-hotels/design-system/Breadcrumbs"
|
import { BreadcrumbsSkeleton } from "@scandic-hotels/design-system/Breadcrumbs"
|
||||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||||
|
|
||||||
@@ -16,7 +17,6 @@ import { HeroVideo } from "@/components/HeroVideo"
|
|||||||
import Sidebar from "@/components/Sidebar"
|
import Sidebar from "@/components/Sidebar"
|
||||||
import SidebarSkeleton from "@/components/Sidebar/SidebarSkeleton"
|
import SidebarSkeleton from "@/components/Sidebar/SidebarSkeleton"
|
||||||
import StickyMeetingPackageWidget from "@/components/StickyMeetingPackageWidget"
|
import StickyMeetingPackageWidget from "@/components/StickyMeetingPackageWidget"
|
||||||
import LinkChips from "@/components/TempDesignSystem/LinkChips"
|
|
||||||
|
|
||||||
import styles from "./contentPage.module.css"
|
import styles from "./contentPage.module.css"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useMediaQuery } from "usehooks-ts"
|
|||||||
|
|
||||||
import { useMarkerHover } from "@scandic-hotels/common/hooks/map/useMarkerHover"
|
import { useMarkerHover } from "@scandic-hotels/common/hooks/map/useMarkerHover"
|
||||||
import { InfoWindow } from "@scandic-hotels/design-system/Map/InfoWindow"
|
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"
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
@@ -79,7 +80,10 @@ export default function CityClusterMarker({
|
|||||||
})}
|
})}
|
||||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||||
>
|
>
|
||||||
<span className={styles.count}>{sizeAsText}</span>
|
<Typography variant="Title/Subtitle/md">
|
||||||
|
<span>{sizeAsText}</span>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
{isDesktop && isHovered ? (
|
{isDesktop && isHovered ? (
|
||||||
<InfoWindow
|
<InfoWindow
|
||||||
position={position}
|
position={position}
|
||||||
|
|||||||
@@ -20,9 +20,3 @@
|
|||||||
height: 46px !important;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ div.months {
|
|||||||
td.day,
|
td.day,
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
td.rangeStart {
|
td.rangeStart {
|
||||||
font-family: var(--typography-Body-Bold-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-weight: 500;
|
font-size: var(--Body-Paragraph-Size);
|
||||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
font-weight: var(--Body-Paragraph-Font-weight-2);
|
||||||
line-height: var(--typography-Body-Bold-lineHeight);
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
text-decoration: var(--typography-Body-Bold-textDecoration);
|
line-height: 1.5;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
@@ -90,14 +91,16 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weekDay {
|
.weekDay {
|
||||||
color: var(--UI-Text-Placeholder);
|
color: var(--Text-Tertiary);
|
||||||
font-family: var(--typography-Footnote-Labels-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Footnote-Labels-fontSize);
|
var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
|
||||||
font-weight: var(--typography-Footnote-Labels-fontWeight);
|
font-size: var(--Title-Overline-sm-Size);
|
||||||
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
|
font-style: normal;
|
||||||
line-height: var(--typography-Footnote-Labels-lineHeight);
|
font-weight: var(--Title-Overline-sm-Font-weight);
|
||||||
text-decoration: var(--typography-Footnote-Labels-textDecoration);
|
line-height: 1.5;
|
||||||
text-transform: uppercase;
|
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
|
||||||
|
text-transform: var(--Title-Overline-sm-Text-Transform);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|||||||
@@ -89,12 +89,13 @@ div.months {
|
|||||||
td.day,
|
td.day,
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
td.rangeStart {
|
td.rangeStart {
|
||||||
font-family: var(--typography-Body-Bold-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-weight: 500;
|
font-size: var(--Body-Paragraph-Size);
|
||||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
font-weight: var(--Body-Paragraph-Font-weight-2);
|
||||||
line-height: var(--typography-Body-Bold-lineHeight);
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
text-decoration: var(--typography-Body-Bold-textDecoration);
|
line-height: 1.5;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
@@ -156,14 +157,16 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weekDay {
|
.weekDay {
|
||||||
color: var(--UI-Text-Placeholder);
|
color: var(--Text-Tertiary);
|
||||||
font-family: var(--typography-Caption-Labels-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Caption-Labels-fontSize);
|
var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
|
||||||
font-weight: var(--typography-Caption-Labels-fontWeight);
|
font-size: var(--Title-Overline-sm-Size);
|
||||||
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
|
font-style: normal;
|
||||||
line-height: var(--typography-Caption-Labels-lineHeight);
|
font-weight: var(--Title-Overline-sm-Font-weight);
|
||||||
text-decoration: var(--typography-Caption-Labels-textDecoration);
|
line-height: 1.5;
|
||||||
text-transform: uppercase;
|
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) {
|
@media screen and (min-width: 1367px) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
text-decoration-skip-ink: none;
|
text-decoration-skip-ink: none;
|
||||||
text-decoration-thickness: auto;
|
text-decoration-thickness: auto;
|
||||||
text-underline-offset: auto;
|
text-underline-offset: auto;
|
||||||
|
text-align: center;
|
||||||
text-underline-position: from-font;
|
text-underline-position: from-font;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
import { trpc } from "@scandic-hotels/trpc/client"
|
import { trpc } from "@scandic-hotels/trpc/client"
|
||||||
|
|
||||||
@@ -66,9 +67,14 @@ export default function Steps({ closeModal }: ChangeDatesStepsProps) {
|
|||||||
|
|
||||||
setDates({ fromDate, toDate })
|
setDates({ fromDate, toDate })
|
||||||
|
|
||||||
|
const numberOfNights = dt(toDate).diff(dt(fromDate), "days")
|
||||||
|
|
||||||
const pkgsSum = sumPackages(packages)
|
const pkgsSum = sumPackages(packages)
|
||||||
const extraPrice =
|
const breakfastPrice = !!breakfast
|
||||||
pkgsSum.price + ((breakfast && breakfast.localPrice.totalPrice) || 0)
|
? breakfast.localPrice.price * numberOfNights
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const extraPrice = pkgsSum.price + breakfastPrice
|
||||||
if (isLoggedIn && "member" in data.product && data.product.member) {
|
if (isLoggedIn && "member" in data.product && data.product.member) {
|
||||||
const { currency, pricePerStay } = data.product.member.localPrice
|
const { currency, pricePerStay } = data.product.member.localPrice
|
||||||
setNewPrice(formatPrice(intl, pricePerStay + extraPrice, currency))
|
setNewPrice(formatPrice(intl, pricePerStay + extraPrice, currency))
|
||||||
|
|||||||
@@ -4,13 +4,6 @@
|
|||||||
padding: var(--Space-x3) var(--Space-x2);
|
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 {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
font-size: var(--Body-Paragraph-Size);
|
||||||
font-weight: var(--typography-Body-Bold-fontWeight);
|
font-weight: var(--Body-Paragraph-Font-weight-2);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Space-x05);
|
gap: var(--Space-x05);
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
|
||||||
import {
|
import {
|
||||||
MaterialIcon,
|
MaterialIcon,
|
||||||
type MaterialIconSetIconProps,
|
type MaterialIconSetIconProps,
|
||||||
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
} 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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { getValueFromContactConfig } from "@scandic-hotels/trpc/utils/contactConfig"
|
import { getValueFromContactConfig } from "@scandic-hotels/trpc/utils/contactConfig"
|
||||||
|
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
// import { getValueFromContactConfig } from "@/utils/contactConfig"
|
|
||||||
import styles from "./contactRow.module.css"
|
import styles from "./contactRow.module.css"
|
||||||
|
|
||||||
import type { ContactRowProps } from "@/types/components/sidebar/joinLoyaltyContact"
|
import type { ContactRowProps } from "@/types/components/sidebar/joinLoyaltyContact"
|
||||||
@@ -46,22 +44,27 @@ export default async function ContactRow({ contact }: ContactRowProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<Typography
|
{contact.display_text ? (
|
||||||
variant="Body/Paragraph/mdBold"
|
<Typography
|
||||||
className={styles.displayText}
|
variant="Body/Paragraph/mdBold"
|
||||||
>
|
className={styles.displayText}
|
||||||
<p>{contact.display_text}</p>
|
>
|
||||||
</Typography>
|
<p>{contact.display_text}</p>
|
||||||
<Link
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<TextLink
|
||||||
|
typography="Link/sm"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
href={openableLink}
|
href={openableLink}
|
||||||
textDecoration="underline"
|
|
||||||
size="small"
|
|
||||||
>
|
>
|
||||||
{Icon ? <Icon size={20} color="Icon/Interactive/Default" /> : null}
|
{Icon ? <Icon size={20} color="Icon/Interactive/Default" /> : null}
|
||||||
{val}
|
{val}
|
||||||
</Link>
|
</TextLink>
|
||||||
{footnote && <Footnote color="burgundy">{footnote}</Footnote>}
|
{footnote && (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p>{footnote}</p>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,9 @@
|
|||||||
gap: var(--Space-x15);
|
gap: var(--Space-x15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact > div {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.contactContainer {
|
.contactContainer {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding-top: var(--Space-x2);
|
padding-top: var(--Space-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact > div {
|
|
||||||
justify-content: start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
6
apps/scandic-web/env/client.ts
vendored
6
apps/scandic-web/env/client.ts
vendored
@@ -16,6 +16,11 @@ export const env = createEnv({
|
|||||||
.transform((s) =>
|
.transform((s) =>
|
||||||
getSemver("scandic-web", s, process.env.BRANCH || "development")
|
getSemver("scandic-web", s, process.env.BRANCH || "development")
|
||||||
),
|
),
|
||||||
|
NEXT_PUBLIC_NEW_POINTCLAIMS: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("false")
|
||||||
|
.transform((s) => s === "true"),
|
||||||
},
|
},
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
@@ -26,5 +31,6 @@ export const env = createEnv({
|
|||||||
process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE,
|
process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE,
|
||||||
NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
|
NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
|
||||||
NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
|
NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
|
||||||
|
NEXT_PUBLIC_NEW_POINTCLAIMS: process.env.NEXT_PUBLIC_NEW_POINTCLAIMS,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
import styles from "./list.module.css"
|
||||||
|
|
||||||
export default function Label({ children }: React.PropsWithChildren) {
|
export default function Label({ children }: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<li className={styles.label}>
|
<Typography variant="Title/Overline/sm">
|
||||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
<li className={styles.label}>{children}</li>
|
||||||
{children}
|
</Typography>
|
||||||
</Footnote>
|
|
||||||
</li>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
padding: 0 var(--Space-x1);
|
padding: 0 var(--Space-x1) var(--Space-x05);
|
||||||
|
color: var(--Text-Tertiary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useIntl } from "react-intl"
|
|||||||
import { useDebounceValue } from "usehooks-ts"
|
import { useDebounceValue } from "usehooks-ts"
|
||||||
|
|
||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { trpc } from "@scandic-hotels/trpc/client"
|
import { trpc } from "@scandic-hotels/trpc/client"
|
||||||
@@ -192,16 +191,14 @@ export default function SearchList({
|
|||||||
{typeFilteredSearchHistory && typeFilteredSearchHistory.length > 0 && (
|
{typeFilteredSearchHistory && typeFilteredSearchHistory.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider className={styles.noResultsDivider} />
|
<Divider className={styles.noResultsDivider} />
|
||||||
<Footnote
|
<Typography variant="Title/Overline/sm">
|
||||||
className={styles.text}
|
<p className={styles.text}>
|
||||||
color="uiTextPlaceholder"
|
{intl.formatMessage({
|
||||||
textTransform="uppercase"
|
id: "bookingWidget.searchList.latestSearches",
|
||||||
>
|
defaultMessage: "Latest searches",
|
||||||
{intl.formatMessage({
|
})}
|
||||||
id: "bookingWidget.searchList.latestSearches",
|
</p>
|
||||||
defaultMessage: "Latest searches",
|
</Typography>
|
||||||
})}
|
|
||||||
</Footnote>
|
|
||||||
<List
|
<List
|
||||||
getItemProps={getItemProps}
|
getItemProps={getItemProps}
|
||||||
highlightedIndex={highlightedIndex}
|
highlightedIndex={highlightedIndex}
|
||||||
@@ -226,12 +223,14 @@ export default function SearchList({
|
|||||||
if (displaySearchHistory) {
|
if (displaySearchHistory) {
|
||||||
return (
|
return (
|
||||||
<Dialog getMenuProps={getMenuProps}>
|
<Dialog getMenuProps={getMenuProps}>
|
||||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
<Typography variant="Title/Overline/sm">
|
||||||
{intl.formatMessage({
|
<p className={styles.text}>
|
||||||
id: "bookingWidget.searchList.latestSearches",
|
{intl.formatMessage({
|
||||||
defaultMessage: "Latest searches",
|
id: "bookingWidget.searchList.latestSearches",
|
||||||
})}
|
defaultMessage: "Latest searches",
|
||||||
</Footnote>
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
<List
|
<List
|
||||||
getItemProps={getItemProps}
|
getItemProps={getItemProps}
|
||||||
highlightedIndex={highlightedIndex}
|
highlightedIndex={highlightedIndex}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
|
|
||||||
.text {
|
.text {
|
||||||
padding: 0 var(--Space-x1);
|
padding: 0 var(--Space-x1);
|
||||||
|
color: var(--Text-Tertiary);
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.textPlaceholderColor {
|
.textPlaceholderColor {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function DatePickerRangeDesktop({
|
|||||||
range_start: styles.rangeStart,
|
range_start: styles.rangeStart,
|
||||||
root: `${classNames.root} ${styles.container}`,
|
root: `${classNames.root} ${styles.container}`,
|
||||||
week: styles.week,
|
week: styles.week,
|
||||||
weekday: `${classNames.weekday} ${styles.weekDay}`,
|
weekday: styles.weekDay,
|
||||||
nav: `${classNames.nav} ${styles.nav}`,
|
nav: `${classNames.nav} ${styles.nav}`,
|
||||||
button_next: `${classNames.button_next} ${styles.button_next}`,
|
button_next: `${classNames.button_next} ${styles.button_next}`,
|
||||||
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
|
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function DatePickerRangeMobile({
|
|||||||
range_start: styles.rangeStart,
|
range_start: styles.rangeStart,
|
||||||
root: `${classNames.root} ${styles.root}`,
|
root: `${classNames.root} ${styles.root}`,
|
||||||
week: styles.week,
|
week: styles.week,
|
||||||
weekday: `${classNames.weekday} ${styles.weekDay}`,
|
weekday: styles.weekDay,
|
||||||
}}
|
}}
|
||||||
disabled={[
|
disabled={[
|
||||||
{ from: lastDayOfPreviousMonth, to: yesterday },
|
{ from: lastDayOfPreviousMonth, to: yesterday },
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ div.months {
|
|||||||
td.day,
|
td.day,
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
td.rangeStart {
|
td.rangeStart {
|
||||||
font-family: var(--typography-Body-Bold-fontFamily);
|
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
font-size: var(--Body-Paragraph-Size);
|
||||||
font-weight: 500;
|
font-weight: var(--Body-Paragraph-Font-weight-2);
|
||||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
line-height: var(--typography-Body-Bold-lineHeight);
|
line-height: 1.5;
|
||||||
text-decoration: var(--typography-Body-Bold-textDecoration);
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
@@ -92,14 +92,15 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weekDay {
|
.weekDay {
|
||||||
color: var(--UI-Text-Placeholder);
|
color: var(--Text-Tertiary);
|
||||||
font-family: var(--typography-Footnote-Labels-fontFamily);
|
font-family: var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
|
||||||
font-size: var(--typography-Footnote-Labels-fontSize);
|
font-size: var(--Title-Overline-sm-Size);
|
||||||
font-weight: var(--typography-Footnote-Labels-fontWeight);
|
font-style: normal;
|
||||||
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
|
font-weight: var(--Title-Overline-sm-Font-weight);
|
||||||
line-height: var(--typography-Footnote-Labels-lineHeight);
|
line-height: 1.5;
|
||||||
text-decoration: var(--typography-Footnote-Labels-textDecoration);
|
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
|
||||||
text-transform: uppercase;
|
text-transform: var(--Title-Overline-sm-Text-Transform);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|||||||
@@ -97,12 +97,12 @@ div.months {
|
|||||||
td.day,
|
td.day,
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
td.rangeStart {
|
td.rangeStart {
|
||||||
font-family: var(--typography-Body-Bold-fontFamily);
|
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
font-size: var(--Body-Paragraph-Size);
|
||||||
font-weight: 500;
|
font-weight: var(--Body-Paragraph-Font-weight-2);
|
||||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
line-height: var(--typography-Body-Bold-lineHeight);
|
line-height: 1.5;
|
||||||
text-decoration: var(--typography-Body-Bold-textDecoration);
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td.rangeEnd,
|
td.rangeEnd,
|
||||||
@@ -165,15 +165,15 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weekDay {
|
.weekDay {
|
||||||
color: var(--Base-Text-Medium-contrast);
|
color: var(--Text-Tertiary);
|
||||||
opacity: 1;
|
font-family: var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
|
||||||
font-family: var(--typography-Caption-Labels-fontFamily);
|
font-size: var(--Title-Overline-sm-Size);
|
||||||
font-size: var(--typography-Caption-Labels-fontSize);
|
font-style: normal;
|
||||||
font-weight: var(--typography-Caption-Labels-fontWeight);
|
font-weight: var(--Title-Overline-sm-Font-weight);
|
||||||
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
|
line-height: 1.5;
|
||||||
line-height: var(--typography-Caption-Labels-lineHeight);
|
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
|
||||||
text-decoration: var(--typography-Caption-Labels-textDecoration);
|
text-transform: var(--Title-Overline-sm-Text-Transform);
|
||||||
text-transform: uppercase;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
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 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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
|
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
|
||||||
@@ -83,8 +82,8 @@ export default function JoinScandicFriendsCard({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|
||||||
<div className={styles.terms}>
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<Footnote color="uiTextPlaceholder">
|
<p className={styles.terms}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "enterDetails.joinScandicFriendsCard.terms",
|
id: "enterDetails.joinScandicFriendsCard.terms",
|
||||||
@@ -93,19 +92,18 @@ export default function JoinScandicFriendsCard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
termsAndConditionsLink: (str) => (
|
termsAndConditionsLink: (str) => (
|
||||||
<Link
|
<TextLink
|
||||||
textDecoration="underline"
|
typography="Link/sm"
|
||||||
size="tiny"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={routes.membershipTermsAndConditions[lang]}
|
href={routes.membershipTermsAndConditions[lang]}
|
||||||
>
|
>
|
||||||
{str}
|
{str}
|
||||||
</Link>
|
</TextLink>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Footnote>
|
</p>
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
.terms {
|
.terms {
|
||||||
grid-area: terms;
|
grid-area: terms;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
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 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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
|
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
|
||||||
@@ -97,8 +96,8 @@ export function PartnerSASJoinScandicFriendsCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.terms}>
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<Footnote color="uiTextPlaceholder">
|
<p className={styles.terms}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "enterDetails.joinScandicFriendsCard.terms",
|
id: "enterDetails.joinScandicFriendsCard.terms",
|
||||||
@@ -107,19 +106,18 @@ export function PartnerSASJoinScandicFriendsCard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
termsAndConditionsLink: (str) => (
|
termsAndConditionsLink: (str) => (
|
||||||
<Link
|
<TextLink
|
||||||
textDecoration="underline"
|
typography="Link/sm"
|
||||||
size="tiny"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={routes.membershipTermsAndConditions[lang]}
|
href={routes.membershipTermsAndConditions[lang]}
|
||||||
>
|
>
|
||||||
{str}
|
{str}
|
||||||
</Link>
|
</TextLink>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Footnote>
|
</p>
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
.terms {
|
.terms {
|
||||||
grid-area: terms;
|
grid-area: terms;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { useIntl } from "react-intl"
|
|||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
|
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
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 Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||||
import { LoginButton } from "@scandic-hotels/design-system/LoginButton"
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { trackEvent } from "@scandic-hotels/tracking/base"
|
import { trackEvent } from "@scandic-hotels/tracking/base"
|
||||||
import { trackLoginClick } from "@scandic-hotels/tracking/navigation"
|
import { trackLoginClick } from "@scandic-hotels/tracking/navigation"
|
||||||
@@ -101,8 +100,8 @@ export function JoinScandicFriendsCard({ name = "join" }: Props) {
|
|||||||
})}
|
})}
|
||||||
</LoginButton>
|
</LoginButton>
|
||||||
|
|
||||||
<div className={styles.terms}>
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<Footnote color="uiTextPlaceholder">
|
<p className={styles.terms}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "enterDetails.joinScandicFriendsCard.terms",
|
id: "enterDetails.joinScandicFriendsCard.terms",
|
||||||
@@ -111,19 +110,18 @@ export function JoinScandicFriendsCard({ name = "join" }: Props) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
termsAndConditionsLink: (str) => (
|
termsAndConditionsLink: (str) => (
|
||||||
<Link
|
<TextLink
|
||||||
textDecoration="underline"
|
typography="Link/sm"
|
||||||
size="tiny"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={routes.membershipTermsAndConditions[lang]}
|
href={routes.membershipTermsAndConditions[lang]}
|
||||||
>
|
>
|
||||||
{str}
|
{str}
|
||||||
</Link>
|
</TextLink>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Footnote>
|
</p>
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
.terms {
|
.terms {
|
||||||
grid-area: terms;
|
grid-area: terms;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
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 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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { trpc } from "@scandic-hotels/trpc/client"
|
import { trpc } from "@scandic-hotels/trpc/client"
|
||||||
|
|
||||||
@@ -94,8 +93,8 @@ export function PartnerSASJoinScandicFriendsCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.terms}>
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<Footnote color="uiTextPlaceholder">
|
<p className={styles.terms}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "enterDetails.joinScandicFriendsCard.terms",
|
id: "enterDetails.joinScandicFriendsCard.terms",
|
||||||
@@ -104,19 +103,18 @@ export function PartnerSASJoinScandicFriendsCard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
termsAndConditionsLink: (str) => (
|
termsAndConditionsLink: (str) => (
|
||||||
<Link
|
<TextLink
|
||||||
textDecoration="underline"
|
typography="Link/sm"
|
||||||
size="tiny"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={routes.membershipTermsAndConditions[lang]}
|
href={routes.membershipTermsAndConditions[lang]}
|
||||||
>
|
>
|
||||||
{str}
|
{str}
|
||||||
</Link>
|
</TextLink>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Footnote>
|
</p>
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
.terms {
|
.terms {
|
||||||
grid-area: terms;
|
grid-area: terms;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
|
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.wrapper} data-available={room.isAvailable}>
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
<div className={styles.headerContainer}>
|
<div className={styles.headerContainer}>
|
||||||
<Footnote
|
<Typography variant="Title/Overline/sm">
|
||||||
className={styles.title}
|
<h2 className={styles.title}>
|
||||||
asChild
|
|
||||||
textTransform="uppercase"
|
|
||||||
type="label"
|
|
||||||
color="uiTextHighContrast"
|
|
||||||
>
|
|
||||||
<h2>
|
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "common.room",
|
id: "common.room",
|
||||||
defaultMessage: "Room",
|
defaultMessage: "Room",
|
||||||
})}
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
</Footnote>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="Title/Subtitle/md"
|
variant="Title/Subtitle/md"
|
||||||
className={styles.description}
|
className={styles.description}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.facilities {
|
.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);
|
padding-bottom: var(--Space-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,12 +177,12 @@ export function SelectHotelMapContent({
|
|||||||
>
|
>
|
||||||
<MaterialIcon icon="close" size={20} color="CurrentColor" />
|
<MaterialIcon icon="close" size={20} color="CurrentColor" />
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
<p>
|
<span>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "selectHotel.closeMap",
|
id: "selectHotel.closeMap",
|
||||||
defaultMessage: "Close the map",
|
defaultMessage: "Close the map",
|
||||||
})}
|
})}
|
||||||
</p>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
.link {
|
.link {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Space-x05);
|
gap: var(--Space-x05);
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookingCodeFilter {
|
.bookingCodeFilter {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
||||||
@@ -37,9 +36,11 @@ export default function SignupPromoDesktop({
|
|||||||
data-testid="signup-promo-desktop"
|
data-testid="signup-promo-desktop"
|
||||||
>
|
>
|
||||||
{badgeContent && <span className={styles.badge}>{badgeContent}</span>}
|
{badgeContent && <span className={styles.badge}>{badgeContent}</span>}
|
||||||
<Footnote color="burgundy">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<Message price={price} isEnterDetailsPage={isEnterDetailsPage} />
|
<p>
|
||||||
</Footnote>
|
<Message price={price} isEnterDetailsPage={isEnterDetailsPage} />
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
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"
|
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
||||||
|
|
||||||
@@ -14,9 +14,11 @@ export default function SignupPromoMobile() {
|
|||||||
data-footer-spacing-signup
|
data-footer-spacing-signup
|
||||||
className={styles.memberDiscountBannerMobile}
|
className={styles.memberDiscountBannerMobile}
|
||||||
>
|
>
|
||||||
<Footnote color="burgundy">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<Message />
|
<p>
|
||||||
</Footnote>
|
<Message />
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--Text-Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.memberDiscountBannerDesktop {
|
.memberDiscountBannerDesktop {
|
||||||
@@ -16,10 +17,11 @@
|
|||||||
padding: var(--Space-x15) var(--Space-x2);
|
padding: var(--Space-x15) var(--Space-x2);
|
||||||
gap: var(--Space-x2);
|
gap: var(--Space-x2);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
color: var(--Text-Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.red {
|
.red {
|
||||||
color: var(--Text-Accent-Primary);
|
color: var(--Scandic-Brand-Scandic-Red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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} />
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -85,7 +85,10 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
|||||||
ref={mergeRefs(field.ref, ref)}
|
ref={mergeRefs(field.ref, ref)}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
onChange={field.onChange}
|
onChange={(event) => {
|
||||||
|
field.onChange(event)
|
||||||
|
props.onChange?.(event)
|
||||||
|
}}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
id={id ?? field.name}
|
id={id ?? field.name}
|
||||||
|
|||||||
@@ -35,8 +35,3 @@
|
|||||||
justify-content: start;
|
justify-content: start;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perNight {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { cx } from "class-variance-authority"
|
import { cx } from "class-variance-authority"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { Divider } from "../../Divider"
|
|
||||||
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
|
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
|
||||||
|
import { Divider } from "../../Divider"
|
||||||
import { Typography } from "../../Typography"
|
import { Typography } from "../../Typography"
|
||||||
import styles from "./hotelPriceCard.module.css"
|
import styles from "./hotelPriceCard.module.css"
|
||||||
|
|
||||||
@@ -117,14 +117,16 @@ export function HotelPriceCard({
|
|||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{productTypePrices.localPrice.currency}
|
{productTypePrices.localPrice.currency}
|
||||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<span className={styles.perNight}>
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
/
|
<span>
|
||||||
{intl.formatMessage({
|
/
|
||||||
id: "common.night",
|
{intl.formatMessage({
|
||||||
defaultMessage: "night",
|
id: "common.night",
|
||||||
})}
|
defaultMessage: "night",
|
||||||
</span>
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
</p>
|
</p>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ import EditOutlined from "./generated/EditOutlined"
|
|||||||
import EditFilled from "./generated/EditFilled"
|
import EditFilled from "./generated/EditFilled"
|
||||||
import EditCalendarOutlined from "./generated/EditCalendarOutlined"
|
import EditCalendarOutlined from "./generated/EditCalendarOutlined"
|
||||||
import EditCalendarFilled from "./generated/EditCalendarFilled"
|
import EditCalendarFilled from "./generated/EditCalendarFilled"
|
||||||
|
import EditDocumentOutlined from "./generated/EditDocumentOutlined"
|
||||||
|
import EditDocumentFilled from "./generated/EditDocumentFilled"
|
||||||
import EditSquareOutlined from "./generated/EditSquareOutlined"
|
import EditSquareOutlined from "./generated/EditSquareOutlined"
|
||||||
import EditSquareFilled from "./generated/EditSquareFilled"
|
import EditSquareFilled from "./generated/EditSquareFilled"
|
||||||
import ElectricBikeOutlined from "./generated/ElectricBikeOutlined"
|
import ElectricBikeOutlined from "./generated/ElectricBikeOutlined"
|
||||||
@@ -642,6 +644,9 @@ const _materialIcons = {
|
|||||||
edit_calendar: {
|
edit_calendar: {
|
||||||
rounded: { outlined: EditCalendarOutlined, filled: EditCalendarFilled },
|
rounded: { outlined: EditCalendarOutlined, filled: EditCalendarFilled },
|
||||||
},
|
},
|
||||||
|
edit_document: {
|
||||||
|
rounded: { outlined: EditDocumentOutlined, filled: EditDocumentFilled },
|
||||||
|
},
|
||||||
edit_square: {
|
edit_square: {
|
||||||
rounded: { outlined: EditSquareOutlined, filled: EditSquareFilled },
|
rounded: { outlined: EditSquareOutlined, filled: EditSquareFilled },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -4,9 +4,7 @@ import { nodesToHtml } from "./utils"
|
|||||||
|
|
||||||
import styles from "./jsontohtml.module.css"
|
import styles from "./jsontohtml.module.css"
|
||||||
|
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
|
||||||
import { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
|
import { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
|
||||||
import { AlertSidepeekContent } from "../../types/sidepeekContent"
|
|
||||||
import { ContentBlockType } from "./types/rte/enums"
|
import { ContentBlockType } from "./types/rte/enums"
|
||||||
import type { RTENode } from "./types/rte/node"
|
import type { RTENode } from "./types/rte/node"
|
||||||
import type { RenderOptions } from "./types/rte/option"
|
import type { RenderOptions } from "./types/rte/option"
|
||||||
@@ -17,7 +15,7 @@ export type Node<T> = {
|
|||||||
|
|
||||||
export type Embeds =
|
export type Embeds =
|
||||||
| {
|
| {
|
||||||
__typename: Exclude<ContentBlockType, "ImageContainer" | "Alert">
|
__typename: Exclude<ContentBlockType, "ImageContainer">
|
||||||
system?: { uid: string } | null
|
system?: { uid: string } | null
|
||||||
url?: string | null
|
url?: string | null
|
||||||
permanent_url?: string | null
|
permanent_url?: string | null
|
||||||
@@ -31,25 +29,6 @@ export type Embeds =
|
|||||||
image_left?: ImageVaultAsset
|
image_left?: ImageVaultAsset
|
||||||
image_right?: 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>>
|
export type EmbedByUid = Record<string, Node<Embeds>>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
mapImageVaultAssetResponseToImageVaultAsset,
|
mapImageVaultAssetResponseToImageVaultAsset,
|
||||||
mapInsertResponseToImageVaultAsset,
|
mapInsertResponseToImageVaultAsset,
|
||||||
} from "@scandic-hotels/common/utils/imageVault"
|
} from "@scandic-hotels/common/utils/imageVault"
|
||||||
import { Alert } from "../Alert"
|
|
||||||
import { TextLink } from "../TextLink"
|
import { TextLink } from "../TextLink"
|
||||||
import type { EmbedByUid } from "./JsonToHtml"
|
import type { EmbedByUid } from "./JsonToHtml"
|
||||||
import type { Attributes } from "./types/rte/attrs"
|
import type { Attributes } from "./types/rte/attrs"
|
||||||
@@ -459,8 +458,6 @@ export const renderOptions: RenderOptions = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} else if (entry?.node.__typename === "Alert") {
|
|
||||||
return <Alert key={node.uid} {...entry.node} />
|
|
||||||
} else if (
|
} else if (
|
||||||
entry?.node.__typename === "AccountPage" ||
|
entry?.node.__typename === "AccountPage" ||
|
||||||
entry?.node.__typename === "CampaignOverviewPage" ||
|
entry?.node.__typename === "CampaignOverviewPage" ||
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { LinkChips } from "./LinkChips"
|
||||||
|
export type { LinkChipsProps } from "./types"
|
||||||
6
packages/design-system/lib/components/LinkChips/types.ts
Normal file
6
packages/design-system/lib/components/LinkChips/types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface LinkChipsProps {
|
||||||
|
chips: {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
@@ -50,30 +50,15 @@
|
|||||||
gap: var(--Space-x05);
|
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 {
|
.myPageMobileDropdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--Scandic-Brand-Burgundy);
|
color: var(--Scandic-Brand-Burgundy);
|
||||||
font-family: var(--typography-Body-Regular-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Body-Regular-fontSize);
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
line-height: var(--typography-Body-Regular-lineHeight);
|
font-size: var(--Body-Paragraph-Size);
|
||||||
letter-spacing: var(--typography-Body-Regular-letterSpacing);
|
line-height: 1.5;
|
||||||
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
padding: var(--Space-x1);
|
padding: var(--Space-x1);
|
||||||
border-radius: var(--Corner-Radius-md);
|
border-radius: var(--Corner-Radius-md);
|
||||||
gap: var(--Space-x1);
|
gap: var(--Space-x1);
|
||||||
@@ -97,11 +82,12 @@
|
|||||||
.shortcut {
|
.shortcut {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: var(--typography-Body-Regular-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Body-Regular-fontSize);
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-weight: var(--typography-Body-Regular-fontWeight);
|
font-size: var(--Body-Paragraph-Size);
|
||||||
letter-spacing: var(--typography-Body-Regular-letterSpacing);
|
font-weight: var(--Body-Paragraph-Font-weight);
|
||||||
line-height: var(--typography-Body-Regular-lineHeight);
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
|
line-height: 1.5;
|
||||||
gap: var(--Space-x2);
|
gap: var(--Space-x2);
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
padding: var(--Space-x2) var(--Space-x3);
|
padding: var(--Space-x2) var(--Space-x3);
|
||||||
@@ -133,22 +119,13 @@
|
|||||||
line-height: 140%;
|
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 {
|
.bold {
|
||||||
font-family: var(--typography-Body-Bold-fontFamily);
|
font-family:
|
||||||
font-size: var(--typography-Body-Bold-fontSize);
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-weight: 500
|
font-size: var(--Body-Paragraph-Size);
|
||||||
/* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight) */;
|
font-weight: var(--Body-Paragraph-Font-weight-2);
|
||||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
letter-spacing: var(--Body-Paragraph-Letter-spacing);
|
||||||
line-height: var(--typography-Body-Bold-lineHeight);
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const linkVariants = cva(styles.link, {
|
|||||||
size: {
|
size: {
|
||||||
small: styles.small,
|
small: styles.small,
|
||||||
large: styles.large,
|
large: styles.large,
|
||||||
tiny: styles.tiny,
|
|
||||||
none: "",
|
none: "",
|
||||||
},
|
},
|
||||||
textDecoration: {
|
textDecoration: {
|
||||||
@@ -29,7 +28,6 @@ export const linkVariants = cva(styles.link, {
|
|||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
icon: styles.icon,
|
icon: styles.icon,
|
||||||
breadcrumb: styles.breadcrumb,
|
|
||||||
myPageMobileDropdown: styles.myPageMobileDropdown,
|
myPageMobileDropdown: styles.myPageMobileDropdown,
|
||||||
navigation: styles.navigation,
|
navigation: styles.navigation,
|
||||||
menu: styles.menu,
|
menu: styles.menu,
|
||||||
|
|||||||
@@ -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 {
|
:root {
|
||||||
--Base-Border-Hover: var(--Scandic-Peach-80);
|
--Base-Border-Hover: var(--Scandic-Peach-80);
|
||||||
--Base-Border-Inverted: var(--UI-Opacity-White-100);
|
--Base-Border-Inverted: var(--UI-Opacity-White-100);
|
||||||
|
|||||||
@@ -20,13 +20,13 @@
|
|||||||
"./ChipLink": "./lib/components/ChipLink/index.tsx",
|
"./ChipLink": "./lib/components/ChipLink/index.tsx",
|
||||||
"./Chips": "./lib/components/Chips/index.tsx",
|
"./Chips": "./lib/components/Chips/index.tsx",
|
||||||
"./ChipStatic": "./lib/components/ChipStatic/index.tsx",
|
"./ChipStatic": "./lib/components/ChipStatic/index.tsx",
|
||||||
|
"./LinkChips": "./lib/components/LinkChips/index.tsx",
|
||||||
"./CodeRateCard": "./lib/components/RateCard/Code/index.tsx",
|
"./CodeRateCard": "./lib/components/RateCard/Code/index.tsx",
|
||||||
"./ContentCard": "./lib/components/ContentCard/index.tsx",
|
"./ContentCard": "./lib/components/ContentCard/index.tsx",
|
||||||
"./DeprecatedSelect": "./lib/components/DeprecatedSelect/index.tsx",
|
"./DeprecatedSelect": "./lib/components/DeprecatedSelect/index.tsx",
|
||||||
"./Divider": "./lib/components/Divider/index.tsx",
|
"./Divider": "./lib/components/Divider/index.tsx",
|
||||||
"./FacilityToIcon": "./lib/components/FacilityToIcon/index.tsx",
|
"./FacilityToIcon": "./lib/components/FacilityToIcon/index.tsx",
|
||||||
"./FakeButton": "./lib/components/FakeButton/index.tsx",
|
"./FakeButton": "./lib/components/FakeButton/index.tsx",
|
||||||
"./Footnote": "./lib/components/Footnote/index.tsx",
|
|
||||||
"./Form/Checkbox": "./lib/components/Form/Checkbox/index.tsx",
|
"./Form/Checkbox": "./lib/components/Form/Checkbox/index.tsx",
|
||||||
"./Form/Country": "./lib/components/Form/Country/index.tsx",
|
"./Form/Country": "./lib/components/Form/Country/index.tsx",
|
||||||
"./Form/Date": "./lib/components/Form/Date/index.tsx",
|
"./Form/Date": "./lib/components/Form/Date/index.tsx",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { gql } from "graphql-tag"
|
import { gql } from "graphql-tag"
|
||||||
|
|
||||||
import { Alert } from "../Alert.graphql"
|
|
||||||
import { ImageContainer } from "../ImageContainer.graphql"
|
import { ImageContainer } from "../ImageContainer.graphql"
|
||||||
import { AccountPageLink } from "../PageLink/AccountPageLink.graphql"
|
import { AccountPageLink } from "../PageLink/AccountPageLink.graphql"
|
||||||
import { CampaignOverviewPageLink } from "../PageLink/CampaignOverviewPageLink.graphql"
|
import { CampaignOverviewPageLink } from "../PageLink/CampaignOverviewPageLink.graphql"
|
||||||
@@ -25,7 +24,6 @@ export const Content_ContentPage = gql`
|
|||||||
node {
|
node {
|
||||||
__typename
|
__typename
|
||||||
...SysAsset
|
...SysAsset
|
||||||
...Alert
|
|
||||||
...ImageContainer
|
...ImageContainer
|
||||||
...AccountPageLink
|
...AccountPageLink
|
||||||
...CampaignOverviewPageLink
|
...CampaignOverviewPageLink
|
||||||
@@ -47,7 +45,6 @@ export const Content_ContentPage = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
${SysAsset}
|
${SysAsset}
|
||||||
${Alert}
|
|
||||||
${ImageContainer}
|
${ImageContainer}
|
||||||
${AccountPageLink}
|
${AccountPageLink}
|
||||||
${CampaignOverviewPageLink}
|
${CampaignOverviewPageLink}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { router } from "../.."
|
import { router } from "../.."
|
||||||
|
import { findBookingForCurrentUserRoute } from "./query/findBookingForCurrentUserRoute"
|
||||||
import { findBookingRoute } from "./query/findBookingRoute"
|
import { findBookingRoute } from "./query/findBookingRoute"
|
||||||
import { getBookingRoute } from "./query/getBookingRoute"
|
import { getBookingRoute } from "./query/getBookingRoute"
|
||||||
import { getBookingStatusRoute } from "./query/getBookingStatusRoute"
|
import { getBookingStatusRoute } from "./query/getBookingStatusRoute"
|
||||||
@@ -7,6 +8,7 @@ import { getLinkedReservationsRoute } from "./query/getLinkedReservationsRoute"
|
|||||||
export const bookingQueryRouter = router({
|
export const bookingQueryRouter = router({
|
||||||
get: getBookingRoute,
|
get: getBookingRoute,
|
||||||
findBooking: findBookingRoute,
|
findBooking: findBookingRoute,
|
||||||
|
findBookingForCurrentUser: findBookingForCurrentUserRoute,
|
||||||
linkedReservations: getLinkedReservationsRoute,
|
linkedReservations: getLinkedReservationsRoute,
|
||||||
status: getBookingStatusRoute,
|
status: getBookingStatusRoute,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||||
|
|
||||||
|
import { notFoundError } from "../../../errors"
|
||||||
|
import { safeProtectedServiceProcedure } from "../../../procedures"
|
||||||
|
import { findBooking } from "../../../services/booking/findBooking"
|
||||||
|
import { getHotel } from "../../hotels/services/getHotel"
|
||||||
|
import { findBookingInput } from "../input"
|
||||||
|
|
||||||
|
export const findBookingForCurrentUserRoute = safeProtectedServiceProcedure
|
||||||
|
.input(
|
||||||
|
findBookingInput.omit({ lastName: true, firstName: true, email: true })
|
||||||
|
)
|
||||||
|
.query(async function ({ ctx, input }) {
|
||||||
|
const lang = input.lang ?? ctx.lang
|
||||||
|
const { confirmationNumber } = input
|
||||||
|
const user = await ctx.getScandicUser()
|
||||||
|
const token = await ctx.getScandicUserToken()
|
||||||
|
|
||||||
|
const findBookingCounter = createCounter(
|
||||||
|
"trpc.booking.findBookingForCurrentUser"
|
||||||
|
)
|
||||||
|
const metricsFindBooking = findBookingCounter.init({
|
||||||
|
confirmationNumber,
|
||||||
|
})
|
||||||
|
metricsFindBooking.start()
|
||||||
|
|
||||||
|
if (!user || !token) {
|
||||||
|
metricsFindBooking.dataError(
|
||||||
|
`Fail to find user when finding booking for ${confirmationNumber}`,
|
||||||
|
{ confirmationNumber }
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await findBooking(
|
||||||
|
{
|
||||||
|
confirmationNumber,
|
||||||
|
lang,
|
||||||
|
lastName: user.lastName,
|
||||||
|
firstName: user.firstName,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
token
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
metricsFindBooking.dataError(
|
||||||
|
`Fail to find booking data for ${confirmationNumber}`,
|
||||||
|
{ confirmationNumber }
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelData = await getHotel(
|
||||||
|
{
|
||||||
|
hotelId: booking.hotelId,
|
||||||
|
isCardOnlyPayment: false,
|
||||||
|
language: lang,
|
||||||
|
},
|
||||||
|
ctx.serviceToken
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hotelData) {
|
||||||
|
metricsFindBooking.dataError(
|
||||||
|
`Failed to find hotel data for ${booking.hotelId}`,
|
||||||
|
{
|
||||||
|
hotelId: booking.hotelId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
throw notFoundError({
|
||||||
|
message: "Hotel data not found",
|
||||||
|
errorDetails: { hotelId: booking.hotelId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsFindBooking.success()
|
||||||
|
|
||||||
|
return {
|
||||||
|
hotel: {
|
||||||
|
name: hotelData.hotel.name,
|
||||||
|
cityName: hotelData.hotel.cityName,
|
||||||
|
},
|
||||||
|
booking,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -46,7 +46,7 @@ const getContactConfig = cache(async (lang: Lang) => {
|
|||||||
GetContactConfig,
|
GetContactConfig,
|
||||||
variables,
|
variables,
|
||||||
{
|
{
|
||||||
key: `${lang}:contact`,
|
key: `${lang}:contact_config`,
|
||||||
ttl: "max",
|
ttl: "max",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { BlocksEnums } from "../../../../types/blocksEnum"
|
import { BlocksEnums } from "../../../../types/blocksEnum"
|
||||||
import { ContentEnum } from "../../../../types/content"
|
|
||||||
import { alertSchema, transformAlertSchema } from "../alert"
|
|
||||||
import { rawLinkUnionSchema, transformPageLink } from "../pageLinks"
|
import { rawLinkUnionSchema, transformPageLink } from "../pageLinks"
|
||||||
import { imageContainerSchema } from "./imageContainer"
|
import { imageContainerSchema } from "./imageContainer"
|
||||||
import { sysAssetSchema } from "./sysAsset"
|
import { sysAssetSchema } from "./sysAsset"
|
||||||
@@ -24,11 +22,7 @@ export const contentSchema = z.object({
|
|||||||
.discriminatedUnion("__typename", [
|
.discriminatedUnion("__typename", [
|
||||||
imageContainerSchema,
|
imageContainerSchema,
|
||||||
sysAssetSchema,
|
sysAssetSchema,
|
||||||
alertSchema.merge(
|
|
||||||
z.object({
|
|
||||||
__typename: z.literal(ContentEnum.blocks.Alert),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
...rawLinkUnionSchema.options,
|
...rawLinkUnionSchema.options,
|
||||||
])
|
])
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
@@ -36,12 +30,6 @@ export const contentSchema = z.object({
|
|||||||
if (link) {
|
if (link) {
|
||||||
return link
|
return link
|
||||||
}
|
}
|
||||||
if (data.__typename === ContentEnum.blocks.Alert) {
|
|
||||||
return {
|
|
||||||
__typename: data.__typename,
|
|
||||||
...transformAlertSchema(data),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data
|
return data
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { FilterType, HotelFilter } from "./output"
|
|||||||
|
|
||||||
export async function getHotelFilters(lang: Lang) {
|
export async function getHotelFilters(lang: Lang) {
|
||||||
const cacheClient = await getCacheClient()
|
const cacheClient = await getCacheClient()
|
||||||
const cacheKey = `${lang}:getHotelFilters`
|
const cacheKey = `${lang}:hotel_filter:outer`
|
||||||
|
|
||||||
return await cacheClient.cacheOrGet(
|
return await cacheClient.cacheOrGet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
@@ -27,7 +27,7 @@ export async function getHotelFilters(lang: Lang) {
|
|||||||
const response = await request<unknown>(
|
const response = await request<unknown>(
|
||||||
GetHotelFilters,
|
GetHotelFilters,
|
||||||
{ locale: lang },
|
{ locale: lang },
|
||||||
{ key: `${lang}:hotel_filters`, ttl: "1d" }
|
{ key: `${lang}:hotel_filter`, ttl: "1d" }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import z from "zod"
|
||||||
|
|
||||||
import { signupVerify } from "@scandic-hotels/common/constants/routes/signup"
|
import { signupVerify } from "@scandic-hotels/common/constants/routes/signup"
|
||||||
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
import { createCounter } from "@scandic-hotels/common/telemetry"
|
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||||
@@ -318,4 +320,33 @@ export const userMutationRouter = router({
|
|||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
claimPoints: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
city: z.string(),
|
||||||
|
hotel: z.string(),
|
||||||
|
firstName: z.string(),
|
||||||
|
lastName: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
phone: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async function ({ input, ctx }) {
|
||||||
|
userMutationLogger.info("api.user.claimPoints start")
|
||||||
|
const user = await ctx.getScandicUser()
|
||||||
|
if (!user) {
|
||||||
|
throw "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Claiming points", input, user.membershipNumber)
|
||||||
|
|
||||||
|
// TODO Waiting for API endpoint, simulating delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||||
|
|
||||||
|
userMutationLogger.info("api.user.claimPoints success")
|
||||||
|
return true
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const ICONS = [
|
|||||||
"download",
|
"download",
|
||||||
"dresser",
|
"dresser",
|
||||||
"edit_calendar",
|
"edit_calendar",
|
||||||
|
"edit_document",
|
||||||
"edit_square",
|
"edit_square",
|
||||||
"edit",
|
"edit",
|
||||||
"electric_bike",
|
"electric_bike",
|
||||||
|
|||||||
Reference in New Issue
Block a user