Merge branch 'develop' of bitbucket.org:scandic-swap/web into feature/tracking

This commit is contained in:
Linus Flood
2024-10-03 15:18:54 +02:00
99 changed files with 3064 additions and 766 deletions
@@ -63,7 +63,7 @@ async function PointsColumn({
</Title>
{subtitle ? (
<Body color="white" textAlign="center">
{subtitle}
{formatMessage({ id: subtitle })}
</Body>
) : null}
</article>
@@ -11,7 +11,7 @@ export default function Stats({ user }: UserProps) {
return (
<section className={styles.stats}>
<Points user={user} />
<Divider variant="default" color="pale" />
<Divider color="pale" />
<ExpiringPoints user={user} />
</section>
)
+107
View File
@@ -0,0 +1,107 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { dt } from "@/lib/dt"
import Form from "@/components/Forms/BookingWidget"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import { CloseLarge } from "@/components/Icons"
import { debounce } from "@/utils/debounce"
import MobileToggleButton from "./MobileToggleButton"
import styles from "./bookingWidget.module.css"
import type {
BookingWidgetClientProps,
BookingWidgetSchema,
} from "@/types/components/bookingWidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export default function BookingWidgetClient({
locations,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const sessionStorageSearchData =
typeof window !== "undefined"
? sessionStorage.getItem("searchData")
: undefined
const initialSelectedLocation: Location | undefined = sessionStorageSearchData
? JSON.parse(sessionStorageSearchData)
: undefined
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: initialSelectedLocation?.name ?? "",
location: sessionStorageSearchData
? encodeURIComponent(sessionStorageSearchData)
: undefined,
date: {
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
// This is specifically to handle timezones falling in different dates.
from: dt().utc().format("YYYY-MM-DD"),
to: dt().utc().add(1, "day").format("YYYY-MM-DD"),
},
bookingCode: "",
redemption: false,
voucher: false,
rooms: [
{
adults: 1,
children: [],
},
],
},
shouldFocusError: false,
mode: "all",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onChange",
})
function closeMobileSearch() {
setIsOpen(false)
document.body.style.overflowY = "visible"
}
function openMobileSearch() {
setIsOpen(true)
document.body.style.overflowY = "hidden"
}
useEffect(() => {
const debouncedResizeHandler = debounce(function ([
entry,
]: ResizeObserverEntry[]) {
if (entry.contentRect.width > 1366) {
closeMobileSearch()
}
})
const observer = new ResizeObserver(debouncedResizeHandler)
observer.observe(document.body)
return () => {
if (observer) {
observer.unobserve(document.body)
}
}
}, [])
return (
<FormProvider {...methods}>
<section className={styles.container} data-open={isOpen}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<CloseLarge />
</button>
<Form locations={locations} />
</section>
<MobileToggleButton openMobileSearch={openMobileSearch} />
</FormProvider>
)
}
@@ -0,0 +1,35 @@
.complete,
.partial {
align-items: center;
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.16);
cursor: pointer;
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x2);
}
.complete {
grid-template-columns: 1fr 36px;
}
.partial {
grid-template-columns: min(1fr, 150px) min-content min(1fr, 150px) 1fr;
}
.icon {
align-items: center;
background-color: var(--Base-Button-Primary-Fill-Normal);
border-radius: 50%;
display: flex;
height: 36px;
justify-content: center;
justify-self: flex-end;
width: 36px;
}
@media screen and (min-width: 768px) {
.complete,
.partial {
display: none;
}
}
@@ -0,0 +1,102 @@
"use client"
import { useEffect, useState } from "react"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { EditIcon, SearchIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import styles from "./button.module.css"
import type {
BookingWidgetSchema,
BookingWidgetToggleButtonProps,
} from "@/types/components/bookingWidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export default function MobileToggleButton({
openMobileSearch,
}: BookingWidgetToggleButtonProps) {
const [hasMounted, setHasMounted] = useState(false)
const intl = useIntl()
const lang = useLang()
const d = useWatch({ name: "date" })
const location = useWatch({ name: "location" })
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
const parsedLocation: Location | null = location
? JSON.parse(decodeURIComponent(location))
: null
const nights = dt(d.to).diff(dt(d.from), "days")
const selectedFromDate = dt(d.from).locale(lang).format("D MMM")
const selectedToDate = dt(d.to).locale(lang).format("D MMM")
useEffect(() => {
setHasMounted(true)
}, [])
if (!hasMounted) {
return null
}
if (parsedLocation && d) {
const totalRooms = rooms.length
const totalAdults = rooms.reduce((acc, room) => {
if (room.adults) {
acc = acc + room.adults
}
return acc
}, 0)
return (
<div className={styles.complete} onClick={openMobileSearch} role="button">
<div>
<Caption color="red">{parsedLocation.name}</Caption>
<Caption>
{`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: nights }
)}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
</Caption>
</div>
<div className={styles.icon}>
<EditIcon color="white" />
</div>
</div>
)
}
return (
<div className={styles.partial} onClick={openMobileSearch} role="button">
<div>
<Caption color="red">{intl.formatMessage({ id: "Where to" })}</Caption>
<Body color="uiTextPlaceholder">
{parsedLocation
? parsedLocation.name
: intl.formatMessage({ id: "Destination" })}
</Body>
</div>
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
<div>
<Caption color="red">
{intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: nights }
)}
</Caption>
<Body>
{selectedFromDate} - {selectedToDate}
</Body>
</div>
<div className={styles.icon}>
<SearchIcon color="white" />
</div>
</div>
)
}
@@ -1,12 +1,38 @@
.container {
background-color: var(--Base-Surface-Primary-light-Normal);
border-top: 1px solid var(--Base-Border-Subtle);
border-bottom: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x2) var(--Spacing-x5);
@media screen and (max-width: 1366px) {
.container {
background-color: var(--UI-Input-Controls-Surface-Normal);
bottom: -100%;
display: grid;
gap: var(--Spacing-x3);
grid-template-rows: 36px 1fr;
height: 100dvh;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
transition: bottom 300ms ease;
width: 100%;
z-index: 10000;
}
.container[data-open="true"] {
bottom: 0;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
}
}
@media screen and (max-width: 1367px) {
@media screen and (min-width: 1367px) {
.container {
border-bottom: 1px solid var(--Base-Border-Subtle);
border-top: 1px solid var(--Base-Border-Subtle);
display: block;
}
.close {
display: none;
}
}
+2 -8
View File
@@ -1,8 +1,6 @@
import { getLocations } from "@/lib/trpc/memoizedRequests"
import Form from "@/components/Forms/BookingWidget"
import styles from "./bookingWidget.module.css"
import BookingWidgetClient from "./Client"
export function preload() {
void getLocations()
@@ -15,9 +13,5 @@ export default async function BookingWidget() {
return null
}
return (
<section className={styles.container}>
<Form locations={locations.data} />
</section>
)
return <BookingWidgetClient locations={locations.data} />
}
@@ -1,9 +1,7 @@
/*nav:has(+ .contentPage) {*/
/* background-color: var(--Base-Surface-Subtle-Normal);*/
/*}*/
.contentPage {
padding-bottom: var(--Spacing-x9);
container-name: content-page;
container-type: inline-size;
}
.header {
@@ -17,21 +15,32 @@
max-width: var(--max-width-content);
margin: 0 auto;
}
.headerIntro {
display: grid;
max-width: var(--max-width-text-block);
gap: var(--Spacing-x3);
}
.content {
.heroContainer {
width: 100%;
padding: var(--Spacing-x4) var(--Spacing-x2);
display: grid;
justify-items: center;
}
.innerContent {
width: 100%;
.heroContainer img {
max-width: var(--max-width-content);
margin: 0 auto;
display: block;
}
.contentContainer {
display: grid;
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
gap: var(--Spacing-x4);
}
.mainContent {
width: 100%;
}
@media (min-width: 768px) {
@@ -39,3 +48,16 @@
gap: var(--Spacing-x3);
}
}
@media (min-width: 1367px) {
.heroContainer {
padding: var(--Spacing-x4) 0;
}
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
gap: var(--Spacing-x9);
max-width: var(--max-width-content);
margin: 0 auto;
padding: var(--Spacing-x4) 0 0;
}
}
+25 -18
View File
@@ -23,31 +23,38 @@ export default async function ContentPage() {
return (
<>
<section className={styles.contentPage}>
{sidebar?.length ? <Sidebar blocks={sidebar} /> : null}
<header className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.headerIntro}>
<Title as="h2">{header.heading}</Title>
<Preamble>{header.preamble}</Preamble>
</div>
{header.navigation_links ? (
<LinkChips chips={header.navigation_links} />
{header ? (
<>
<div className={styles.headerIntro}>
<Title as="h2">{header.heading}</Title>
<Preamble>{header.preamble}</Preamble>
</div>
{header.navigation_links ? (
<LinkChips chips={header.navigation_links} />
) : null}
</>
) : null}
</div>
</header>
<main className={styles.content}>
<div className={styles.innerContent}>
{hero_image ? (
<Hero
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
src={hero_image.url}
/>
) : null}
{blocks ? <Blocks blocks={blocks} /> : null}
{hero_image ? (
<div className={styles.heroContainer}>
<Hero
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
src={hero_image.url}
/>
</div>
</main>
) : null}
<div className={styles.contentContainer}>
<main className={styles.mainContent}>
{blocks ? <Blocks blocks={blocks} /> : null}
</main>
{sidebar?.length ? <Sidebar blocks={sidebar} /> : null}
</div>
</section>
<TrackingSDK pageData={tracking} />
@@ -14,3 +14,9 @@
top: var(--main-menu-desktop-height);
}
}
.closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
}
@@ -6,9 +6,11 @@ import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
import CloseLargeIcon from "@/components/Icons/CloseLarge"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import Button from "@/components/TempDesignSystem/Button"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import MapContent from "./Map"
import Sidebar from "./Sidebar"
import styles from "./dynamicMap.module.css"
@@ -52,6 +54,20 @@ export default function DynamicMap({
}
}, [isDynamicMapOpen, scrollHeightWhenOpened])
const closeButton = (
<Button
theme="base"
intent="inverted"
variant="icon"
size="small"
className={styles.closeButton}
onClick={closeDynamicMap}
>
<CloseLargeIcon color="burgundy" />
<span>{intl.formatMessage({ id: "Close the map" })}</span>
</Button>
)
return (
<APIProvider apiKey={apiKey}>
<Modal isOpen={isDynamicMapOpen}>
@@ -68,7 +84,8 @@ export default function DynamicMap({
pointsOfInterest={pointsOfInterest}
onActivePoiChange={setActivePoi}
/>
<MapContent
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
activePoi={activePoi}
@@ -55,6 +55,7 @@ export default function MapCard({ hotelName, pois }: MapCardProps) {
theme="base"
intent="secondary"
size="small"
fullWidth
className={styles.ctaButton}
onClick={openDynamicMap}
>
@@ -6,6 +6,8 @@
position: relative;
justify-content: center;
align-items: flex-start;
container-name: loyalty-page;
container-type: inline-size;
}
.blocks {
-62
View File
@@ -1,62 +0,0 @@
"use client"
import { da, de, fi, nb, sv } from "date-fns/locale"
import { useState } from "react"
import { type DateRange, DayPicker } from "react-day-picker"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang"
import classNames from "react-day-picker/style.module.css"
const locales = {
[Lang.da]: da,
[Lang.de]: de,
[Lang.fi]: fi,
[Lang.no]: nb,
[Lang.sv]: sv,
}
export interface DatePickerProps {
handleOnSelect: (selected: DateRange) => void
initialSelected?: DateRange
}
export default function DatePicker({
handleOnSelect,
initialSelected = {
from: undefined,
to: undefined,
},
}: DatePickerProps) {
const lang = useLang()
const [selectedDate, setSelectedDate] = useState<DateRange>(initialSelected)
function handleSelectDate(selected: DateRange) {
handleOnSelect(selected)
setSelectedDate(selected)
}
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
return (
<DayPicker
classNames={classNames}
disabled={{ from: startOfMonth, to: yesterday }}
excludeDisabled
locale={locale}
mode="range"
onSelect={handleSelectDate}
pagedNavigation
required
selected={selectedDate}
showWeekNumber
startMonth={currentDate}
/>
)
}
+127
View File
@@ -0,0 +1,127 @@
"use client"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { ChevronLeftIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker"
export default function DatePickerDesktop({
close,
handleOnSelect,
locales,
selectedDate,
}: DatePickerProps) {
const lang = useLang()
const intl = useIntl()
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
return (
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
footer: styles.footer,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: `${classNames.months} ${styles.months}`,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={{ from: startOfMonth, to: yesterday }}
excludeDisabled
footer
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
lang={lang}
locale={locale}
mode="range"
numberOfMonths={2}
onSelect={handleOnSelect}
pagedNavigation
required
selected={selectedDate}
startMonth={currentDate}
weekStartsOn={1}
components={{
Chevron(props) {
return <ChevronLeftIcon {...props} height={20} width={20} />
},
Footer(props) {
return (
<>
<Divider className={styles.divider} color="primaryLightSubtle" />
<footer className={props.className}>
<Button
intent="tertiary"
onPress={close}
size="small"
theme="base"
>
<Caption color="white" textTransform="bold">
{intl.formatMessage({ id: "Select dates" })}
</Caption>
</Button>
</footer>
</>
)
},
MonthCaption(props) {
return (
<div className={props.className}>
<Subtitle asChild type="two">
{props.children}
</Subtitle>
</div>
)
},
Nav(props) {
if (Array.isArray(props.children)) {
const prevButton = props.children?.[0]
const nextButton = props.children?.[1]
return (
<>
{prevButton ? (
<nav
className={`${props.className} ${styles.previousButton}`}
>
{prevButton}
</nav>
) : null}
{nextButton ? (
<nav className={`${props.className} ${styles.nextButton}`}>
{nextButton}
</nav>
) : null}
</>
)
}
return <></>
},
}}
/>
)
}
+140
View File
@@ -0,0 +1,140 @@
"use client"
import { type ChangeEvent, useState } from "react"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { CloseLarge } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./mobile.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker"
function addOneYear(_: undefined, i: number) {
return new Date().getFullYear() + i
}
const fiftyYearsAhead = Array.from({ length: 50 }, addOneYear)
export default function DatePickerMobile({
close,
handleOnSelect,
locales,
selectedDate,
}: DatePickerProps) {
const [selectedYear, setSelectedYear] = useState(() => dt().year())
const lang = useLang()
const intl = useIntl()
function handleSelectYear(evt: ChangeEvent<HTMLSelectElement>) {
setSelectedYear(Number(evt.currentTarget.value))
}
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfCurrentMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
const startMonth = dt().set("year", selectedYear).startOf("year").toDate()
const decemberOfYear = dt().set("year", selectedYear).endOf("year").toDate()
return (
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
footer: styles.footer,
month: styles.month,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: styles.months,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={{ from: startOfCurrentMonth, to: yesterday }}
endMonth={decemberOfYear}
excludeDisabled
footer
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
hideNavigation
lang={lang}
locale={locale}
mode="range"
/** Showing full year or what's left of it */
numberOfMonths={12}
onSelect={handleOnSelect}
required
selected={selectedDate}
startMonth={startMonth}
weekStartsOn={1}
components={{
Footer(props) {
return (
<footer className={props.className}>
<Button
className={styles.button}
intent="tertiary"
onPress={close}
size="large"
theme="base"
>
<Body color="white" textTransform="bold">
{intl.formatMessage({ id: "Select dates" })}
</Body>
</Button>
<div className={styles.backdrop} />
</footer>
)
},
MonthCaption(props) {
return (
<div className={props.className}>
<Subtitle asChild type="two">
{props.children}
</Subtitle>
</div>
)
},
Root({ children, ...props }) {
return (
<div {...props}>
<header className={styles.header}>
<select
className={styles.select}
defaultValue={selectedYear}
onChange={handleSelectYear}
>
{fiftyYearsAhead.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<button className={styles.close} onClick={close} type="button">
<CloseLarge />
</button>
</header>
{children}
</div>
)
},
}}
/>
)
}
@@ -0,0 +1,120 @@
@media screen and (max-width: 1366px) {
.container {
display: none;
}
}
div.months {
flex-wrap: nowrap;
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Base-Background-Primary-Normal);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day button.dayButton:hover {
background: var(--Base-Surface-Secondary-light-Hover);
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.day:not(td.rangeEnd, td.rangeStart)[aria-selected="true"],
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Base-Background-Primary-Normal);
border: none;
border-radius: 0;
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: var(--Spacing-x2);
}
.divider {
margin-top: var(--Spacing-x2);
}
.nextButton {
transform: rotate(180deg);
right: 0;
}
.previousButton {
left: 0;
}
@@ -0,0 +1,173 @@
.container {
--header-height: 68px;
--sticky-button-height: 120px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.header {
align-self: flex-start;
background-color: var(--Main-Grey-White);
display: grid;
grid-area: header;
grid-template-columns: 1fr 24px;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 10;
}
.select {
justify-self: center;
min-width: 100px;
transform: translateX(24px);
}
.close {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
}
div.months {
display: grid;
grid-area: content;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.month {
display: grid;
justify-items: center;
scroll-snap-align: start;
}
.month:last-of-type {
padding-bottom: var(--sticky-button-height);
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
.footer {
align-self: flex-start;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
display: flex;
grid-area: content;
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
top: calc(100vh - var(--sticky-button-height));
width: 100%;
z-index: 10;
}
.footer .button {
width: 100%;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Base-Background-Primary-Normal);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day button.dayButton:hover {
background: var(--Base-Surface-Secondary-light-Hover);
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.day:not(td.rangeEnd, td.rangeStart)[aria-selected="true"],
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Base-Background-Primary-Normal);
border: none;
border-radius: 0;
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--Base-Text-Medium-contrast);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
}
@media screen and (min-width: 1367px) {
.container {
display: none;
}
}
+40 -10
View File
@@ -7,16 +7,6 @@
}
}
.hideWrapper {
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Medium);
box-shadow: 0px 16px 24px 0px rgba(0, 0, 0, 0.08);
padding: var(--Spacing-x-one-and-half);
position: absolute;
/** BookingWidget padding + border-width */
top: calc(100% + var(--Spacing-x2) + 1px);
}
.btn {
background: none;
border: none;
@@ -29,3 +19,43 @@
.body {
opacity: 0.8;
}
.hideWrapper {
background-color: var(--Main-Grey-White);
}
@media screen and (max-width: 1366px) {
.container {
z-index: 10001;
}
.hideWrapper {
bottom: 0;
left: 0;
overflow: auto;
position: fixed;
right: 0;
top: 100%;
transition: top 300ms ease;
z-index: 10001;
}
.container[data-isopen="true"] .hideWrapper {
top: 0;
}
}
@media screen and (min-width: 1367px) {
.hideWrapper {
border-radius: var(--Corner-radius-Large);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
/**
BookingWidget padding +
border-width +
wanted space below booking widget
*/
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
}
}
+28 -7
View File
@@ -1,13 +1,16 @@
"use client"
import { da, de, fi, nb, sv } from "date-fns/locale"
import { useEffect, useRef, useState } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import DatePicker from "./DatePicker"
import DatePickerDesktop from "./Screen/Desktop"
import DatePickerMobile from "./Screen/Mobile"
import styles from "./date-picker.module.css"
@@ -15,6 +18,14 @@ import type { DateRange } from "react-day-picker"
import type { DatePickerFormProps } from "@/types/components/datepicker"
const locales = {
[Lang.da]: da,
[Lang.de]: de,
[Lang.fi]: fi,
[Lang.no]: nb,
[Lang.sv]: sv,
}
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const lang = useLang()
const [isOpen, setIsOpen] = useState(false)
@@ -22,6 +33,10 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const { register, setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null)
function close() {
setIsOpen(false)
}
function handleOnClick() {
setIsOpen((prevIsOpen) => !prevIsOpen)
}
@@ -40,11 +55,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
setIsOpen(false)
}
}
document.addEventListener("click", handleClickOutside)
document.body.addEventListener("click", handleClickOutside)
return () => {
document.removeEventListener("click", handleClickOutside)
document.body.removeEventListener("click", handleClickOutside)
}
}, [setIsOpen])
@@ -63,9 +76,17 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
<input {...register("date.from")} type="hidden" />
<input {...register("date.to")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog">
<DatePicker
<DatePickerDesktop
close={close}
handleOnSelect={handleSelectDate}
initialSelected={selectedDate}
locales={locales}
selectedDate={selectedDate}
/>
<DatePickerMobile
close={close}
handleOnSelect={handleSelectDate}
locales={locales}
selectedDate={selectedDate}
/>
</div>
</div>
@@ -30,6 +30,7 @@
height: 24px;
outline: none;
position: relative;
width: 100%;
z-index: 2;
}
@@ -1,45 +1,80 @@
.input {
display: flex;
gap: var(--Spacing-x2);
}
.input input[type="text"] {
border: none;
height: 24px;
}
.rooms,
.vouchers,
.when,
.where {
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
width: 100%;
}
.rooms,
.when {
max-width: 240px;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.vouchers {
max-width: 200px;
padding: var(--Spacing-x1) 0;
}
.where {
max-width: 280px;
position: relative;
}
.options {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
max-width: 158px;
}
.option {
display: flex;
}
}
@media screen and (max-width: 1366px) {
.input {
display: grid;
gap: var(--Spacing-x2);
}
.rooms,
.vouchers,
.when,
.where {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
}
.rooms,
.vouchers,
.when {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.options {
gap: var(--Spacing-x2);
margin-top: var(--Spacing-x2);
}
.option {
gap: var(--Spacing-x2);
}
}
@media screen and (min-width: 1367px) {
.input {
display: flex;
gap: var(--Spacing-x2);
}
.rooms,
.vouchers,
.when,
.where {
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
width: 100%;
}
.input input[type="text"] {
border: none;
height: 24px;
}
.rooms,
.when {
max-width: 240px;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.vouchers {
max-width: 200px;
padding: var(--Spacing-x1) 0;
}
.where {
max-width: 280px;
position: relative;
}
.options {
max-width: 158px;
}
}
@@ -19,8 +19,8 @@ export default function FormContent({
const intl = useIntl()
const selectedDate = useWatch({ name: "date" })
const rooms = intl.formatMessage({ id: "Rooms & Guests" })
const vouchers = intl.formatMessage({ id: "Booking codes and vouchers" })
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
const reward = intl.formatMessage({ id: "Book reward night" })
@@ -33,10 +33,10 @@ export default function FormContent({
</div>
<div className={styles.when}>
<Caption color="red" textTransform="bold">
{nights}{" "}
{nights > 1
? intl.formatMessage({ id: "nights" })
: intl.formatMessage({ id: "night" })}
{intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: nights }
)}
</Caption>
<DatePicker />
</div>
@@ -47,20 +47,20 @@ export default function FormContent({
<input type="text" placeholder={rooms} />
</div>
<div className={styles.vouchers}>
<Caption color="textMediumContrast" textTransform="bold">
<Caption color="uiTextMediumContrast" textTransform="bold">
{vouchers}
</Caption>
<input type="text" placeholder={vouchers} />
</div>
<div className={styles.options}>
<div className={styles.option}>
<label className={styles.option}>
<input type="checkbox" />
<Caption color="textMediumContrast">{bonus}</Caption>
</div>
<div className={styles.option}>
</label>
<label className={styles.option}>
<input type="checkbox" />
<Caption color="textMediumContrast">{reward}</Caption>
</div>
</label>
</div>
</div>
)
+28 -7
View File
@@ -1,15 +1,36 @@
.section {
align-items: center;
display: flex;
display: grid;
margin: 0 auto;
max-width: var(--max-width-navigation);
}
.form {
width: 100%;
}
.button {
width: 118px;
justify-content: center;
.form {
display: grid;
gap: var(--Spacing-x2);
width: 100%;
}
@media screen and (max-width: 1366px) {
.form {
align-self: flex-start;
}
.button {
align-self: flex-end;
justify-content: center;
width: 100%;
}
}
@media screen and (min-width: 1367px) {
.section {
display: flex;
}
.button {
justify-content: center;
width: 118px;
}
}
+11 -49
View File
@@ -1,22 +1,17 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import FormContent from "./FormContent"
import { bookingWidgetSchema } from "./schema"
import styles from "./form.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
const formId = "booking-widget"
@@ -24,40 +19,8 @@ export default function Form({ locations }: BookingWidgetFormProps) {
const intl = useIntl()
const router = useRouter()
const sessionStorageSearchData =
typeof window !== "undefined"
? sessionStorage.getItem("searchData")
: undefined
const initialSelectedLocation: Location | undefined = sessionStorageSearchData
? JSON.parse(sessionStorageSearchData)
: undefined
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: initialSelectedLocation?.name ?? "",
location: sessionStorageSearchData
? encodeURIComponent(sessionStorageSearchData)
: undefined,
date: {
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
// This is specifically to handle timezones falling in different dates.
from: dt().utc().format("YYYY-MM-DD"),
to: dt().utc().add(1, "day").format("YYYY-MM-DD"),
},
bookingCode: "",
redemption: false,
voucher: false,
rooms: [
{
adults: 1,
childs: [],
},
],
},
shouldFocusError: false,
mode: "all",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onChange",
})
const { formState, handleSubmit, register } =
useFormContext<BookingWidgetSchema>()
function onSubmit(data: BookingWidgetSchema) {
data.location = JSON.parse(decodeURIComponent(data.location))
@@ -70,25 +33,24 @@ export default function Form({ locations }: BookingWidgetFormProps) {
return (
<section className={styles.section}>
<form
onSubmit={methods.handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)}
className={styles.form}
id={formId}
>
<FormProvider {...methods}>
<input {...methods.register("location")} type="hidden" />
<FormContent locations={locations} />
</FormProvider>
<input {...register("location")} type="hidden" />
<FormContent locations={locations} />
</form>
<Button
type="submit"
className={styles.button}
disabled={!formState.isValid}
form={formId}
intent="primary"
size="small"
theme="base"
intent="primary"
className={styles.button}
type="submit"
>
<Caption color="white" textTransform="bold">
{intl.formatMessage({ id: "Find hotels" })}
{intl.formatMessage({ id: "Search" })}
</Caption>
</Button>
</section>
+5 -4
View File
@@ -3,8 +3,9 @@ import { z } from "zod"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export const bookingWidgetSchema = z.object({
search: z.string({ coerce: true }).min(1, "Required"),
bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({
// Update this as required once started working with Date picker in Nights component
from: z.string(),
to: z.string(),
}),
@@ -23,14 +24,12 @@ export const bookingWidgetSchema = z.object({
},
{ message: "Required" }
),
bookingCode: z.string(), // Update this as required when working with booking codes component
redemption: z.boolean().default(false),
voucher: z.boolean().default(false),
rooms: z.array(
// This will be updated when working in guests component
z.object({
adults: z.number().default(1),
childs: z.array(
children: z.array(
z.object({
age: z.number(),
bed: z.number(),
@@ -38,4 +37,6 @@ export const bookingWidgetSchema = z.object({
),
})
),
search: z.string({ coerce: true }).min(1, "Required"),
voucher: z.boolean().default(false),
})
@@ -1,8 +1,7 @@
import { Link } from "react-feather"
import { myPages } from "@/constants/routes/myPages"
import { serverClient } from "@/lib/trpc/server"
import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
@@ -48,12 +48,6 @@
gap: var(--Spacing-x-half);
}
.link {
display: flex;
padding: var(--Spacing-x2) var(--Spacing-x0);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.prices {
display: flex;
flex-direction: column;
@@ -115,7 +109,7 @@
padding-bottom: var(--Spacing-x2);
}
.link {
.detailsButton {
border-bottom: none;
}
@@ -1,11 +1,5 @@
import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import {
ChevronRightIcon,
PriceTagIcon,
ScandicLogoIcon,
} from "@/components/Icons"
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
@@ -14,13 +8,16 @@ import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import ReadMore from "../ReadMore"
import styles from "./hotelCard.module.css"
import { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
export default function HotelCard({ hotel }: HotelCardProps) {
const intl = useIntl()
export default async function HotelCard({ hotel }: HotelCardProps) {
const intl = await getIntl()
const { hotelData } = hotel
const { price } = hotel
@@ -51,7 +48,7 @@ export default function HotelCard({ hotel }: HotelCardProps) {
<Title as="h4" textTransform="capitalize">
{hotelData.name}
</Title>
<Footnote color="textMediumContrast" className={styles.adress}>
<Footnote color="textMediumContrast">
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
</Footnote>
<Footnote color="textMediumContrast">
@@ -70,10 +67,7 @@ export default function HotelCard({ hotel }: HotelCardProps) {
)
})}
</div>
<Link href="#" color="burgundy" className={styles.link}>
{intl.formatMessage({ id: "See hotel details" })}
<ChevronRightIcon color="burgundy" />
</Link>
<ReadMore hotelId={hotelData.operaId} hotel={hotelData} />
</section>
<section className={styles.prices}>
<div>
@@ -1,5 +1,3 @@
"use client"
import Title from "@/components/TempDesignSystem/Text/Title"
import HotelCard from "../HotelCard"
@@ -0,0 +1,4 @@
.buttons {
display: flex;
gap: var(--Spacing-x3);
}
@@ -0,0 +1,58 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import styles from "./hotelDetailSidePeek.module.css"
export default function HotelDetailSidePeek() {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
function toggleSidePeek() {
setIsOpen(!isOpen)
}
return (
<>
<div className={styles.buttons}>
<Button
variant="icon"
theme="base"
intent="text"
wrapping
onClick={toggleSidePeek}
>
{intl.formatMessage({
id: "See hotel details",
})}
<ChevronRightSmallIcon aria-hidden="true" color="burgundy" />
</Button>
<Button
variant="icon"
theme="base"
intent="text"
wrapping
onClick={toggleSidePeek}
>
{intl.formatMessage({
id: "Show all amenities",
})}
<ChevronRightSmallIcon aria-hidden="true" color="burgundy" />
</Button>
</div>
<SidePeek
contentKey="hotel-detail-side-peek"
title="Hotel Details"
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
>
<div>TBD</div>
</SidePeek>
</>
)
}
@@ -0,0 +1,52 @@
.hotelSelectionHeader {
display: flex;
flex-direction: column;
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x3) var(--Spacing-x2);
justify-content: center;
gap: var(--Spacing-x3);
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--Spacing-x1);
}
.descriptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.address {
display: flex;
gap: var(--Spacing-x-one-and-half);
font-style: normal;
}
.dividerContainer {
display: none;
}
@media (min-width: 768px) {
.hotelSelectionHeader {
flex-direction: row;
padding: var(--Spacing-x4) var(--Spacing-x5);
gap: var(--Spacing-x6);
}
.titleContainer > h1 {
white-space: nowrap;
}
.dividerContainer {
display: block;
}
.address {
gap: var(--Spacing-x3);
}
}
@@ -0,0 +1,52 @@
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import HotelDetailSidePeek from "./HotelDetailSidePeek"
import styles from "./hotelSelectionHeader.module.css"
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
export default async function HotelSelectionHeader({
hotel,
}: HotelSelectionHeaderProps) {
const intl = await getIntl()
return (
<header className={styles.hotelSelectionHeader}>
<div className={styles.titleContainer}>
<Title as="h3" level="h1">
{hotel.name}
</Title>
<address className={styles.address}>
<Caption color="textMediumContrast">
{hotel.address.streetAddress}, {hotel.address.city}
</Caption>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Caption color="textMediumContrast">
{intl.formatMessage(
{
id: "Distance to city centre",
},
{ number: hotel.location.distanceToCentre }
)}
</Caption>
</address>
</div>
<div className={styles.dividerContainer}>
<Divider variant="vertical" color="subtle" />
</div>
<div className={styles.descriptionContainer}>
<Body color="textHighContrast">
{hotel.hotelContent.texts.descriptions.short}
</Body>
<HotelDetailSidePeek />
</div>
</header>
)
}
@@ -0,0 +1,48 @@
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
gap: var(--Spacing-x2);
font-family: var(--typography-Body-Regular-fontFamily);
}
.address,
.contactInfo {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: 1 / 3;
grid-row: 1 / 4;
}
.contactInfo > li {
font-style: normal;
list-style-type: none;
display: flex;
flex-direction: column;
}
.heading {
font-weight: 500;
}
.soMeIcons {
display: flex;
gap: var(--Spacing-x-one-and-half);
}
.ecoLabel {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--Spacing-x-one-and-half);
grid-column: 2 / 3;
grid-row: 3 / 4;
font-size: var(--typography-Footnote-Regular-fontSize);
line-height: ();
}
.ecoLabelText {
display: flex;
flex-direction: column;
justify-content: center;
}
@@ -0,0 +1,85 @@
"use client"
import { useIntl } from "react-intl"
import FacebookIcon from "@/components/Icons/Facebook"
import InstagramIcon from "@/components/Icons/Instagram"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import styles from "./contact.module.css"
import { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
export default function Contact({ hotel }: ContactProps) {
const lang = useLang()
const intl = useIntl()
return (
<section className={styles.wrapper}>
<address className={styles.address}>
<ul className={styles.contactInfo}>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Address" })}
</span>
<span>{hotel.address.streetAddress}</span>
<span>{hotel.address.city}</span>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Driving directions" })}
</span>
<Link href="#">{intl.formatMessage({ id: "Google Maps" })}</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Email" })}
</span>
<Link href={`mailto:${hotel.contactInformation.email}`}>
{hotel.contactInformation.email}
</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Contact us" })}
</span>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
{hotel.contactInformation.phoneNumber}
</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Follow us" })}
</span>
<div className={styles.soMeIcons}>
<Link href="#" target="_blank">
<InstagramIcon color="burgundy" />
</Link>
<Link href="#" target="_blank">
<FacebookIcon color="burgundy" />
</Link>
</div>
</li>
</ul>
</address>
{hotel.hotelFacts.ecoLabels.nordicEcoLabel ? (
<div className={styles.ecoLabel}>
<Image
height={38}
width={38}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
<div className={styles.ecoLabelText}>
<span>{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}</span>
<span>
{hotel.hotelFacts.ecoLabels.svanenEcoLabelCertificateNumber}
</span>
</div>
</div>
) : null}
</section>
)
}
@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { ChevronRightIcon } from "@/components/Icons"
import Accordion from "@/components/TempDesignSystem/Accordion"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Contact from "./Contact"
import styles from "./readMore.module.css"
import {
DetailedAmenity,
ParkingProps,
ReadMoreProps,
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { Hotel } from "@/types/hotel"
function getAmenitiesList(hotel: Hotel) {
const detailedAmenities: DetailedAmenity[] = Object.entries(
hotel.hotelFacts.hotelFacilityDetail
).map(([key, value]) => ({ name: key, ...value }))
// Remove Parking facilities since parking accordion is based on hotel.parking
const simpleAmenities = hotel.detailedFacilities.filter(
(facility) => !facility.name.startsWith("Parking")
)
return [...detailedAmenities, ...simpleAmenities]
}
export default function ReadMore({ hotel, hotelId }: ReadMoreProps) {
const intl = useIntl()
const [sidePeekOpen, setSidePeekOpen] = useState(false)
const amenitiesList = getAmenitiesList(hotel)
return (
<>
<Button
onPress={() => {
setSidePeekOpen(true)
}}
intent={"text"}
color="burgundy"
className={styles.detailsButton}
>
{intl.formatMessage({ id: "See hotel details" })}
<ChevronRightIcon color="burgundy" />
</Button>
<SidePeek
title={hotel.name}
isOpen={sidePeekOpen}
contentKey={`${hotelId}`}
handleClose={() => {
setSidePeekOpen(false)
}}
>
<div className={styles.content}>
<Subtitle>
{intl.formatMessage({ id: "Practical information" })}
</Subtitle>
<Contact hotel={hotel} />
<Accordion>
{/* parking */}
{hotel.parking.length ? (
<AccordionItem title={intl.formatMessage({ id: "Parking" })}>
{hotel.parking.map((p) => (
<Parking key={p.name} parking={p} />
))}
</AccordionItem>
) : null}
<AccordionItem title={intl.formatMessage({ id: "Accessibility" })}>
TODO: What content should be in the accessibility section?
</AccordionItem>
{amenitiesList.map((amenity) => {
return "description" in amenity ? (
<AccordionItem key={amenity.name} title={amenity.heading}>
{amenity.description}
</AccordionItem>
) : (
<div key={amenity.id} className={styles.amenity}>
{amenity.name}
</div>
)
})}
</Accordion>
{/* TODO: handle linking to Hotel Page */}
<Button theme={"base"}>To the hotel</Button>
</div>
</SidePeek>
</>
)
}
function Parking({ parking }: ParkingProps) {
const intl = useIntl()
return (
<div>
<Body>{`${intl.formatMessage({ id: parking.type })} (${parking.name})`}</Body>
<ul className={styles.list}>
<li>
{`${intl.formatMessage({
id: "Number of charging points for electric cars",
})}: ${parking.numberOfChargingSpaces}`}
</li>
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
<li>{`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}</li>
<li>{`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel}`}</li>
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
</ul>
</div>
)
}
@@ -0,0 +1,25 @@
.detailsButton {
align-self: start;
border-radius: 0;
height: auto;
padding-left: 0;
padding-right: 0;
}
.content {
display: grid;
gap: var(--Spacing-x2);
}
.amenity {
font-family: var(--typography-Body-Regular-fontFamily);
border-bottom: 1px solid var(--Base-Border-Subtle);
/* padding set to align with AccordionItem which has a different composition */
padding: var(--Spacing-x2)
calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half));
}
.list {
font-family: var(--typography-Body-Regular-fontFamily);
list-style: inside;
}
@@ -4,7 +4,7 @@ import { useIntl } from "react-intl"
import styles from "./hotelFilter.module.css"
import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFiltersProps"
import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function HotelFilter({ filters }: HotelFiltersProps) {
const intl = useIntl()
@@ -0,0 +1,11 @@
"use client"
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
// TODO: This component is copied from
// components/ContentType/HotelPage/Map/DynamicMap/Sidebar.
// Look at that for inspiration on how to do the interaction with the map.
export default function HotelListing({}: HotelListingProps) {
return <section>Hotel listing TBI</section>
}
@@ -0,0 +1,57 @@
"use client"
import { APIProvider } from "@vis.gl/react-google-maps"
import { useState } from "react"
import { useIntl } from "react-intl"
import { selectHotel } from "@/constants/routes/hotelReservation"
import { CloseIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import HotelListing from "./HotelListing"
import styles from "./selectHotelMap.module.css"
import { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function SelectHotelMap({
apiKey,
coordinates,
pointsOfInterest,
mapId,
}: SelectHotelMapProps) {
const lang = useLang()
const intl = useIntl()
const [activePoi, setActivePoi] = useState<string | null>(null)
const closeButton = (
<Button
asChild
intent="inverted"
size="small"
theme="base"
className={styles.closeButton}
>
<Link href={selectHotel[lang]} keepSearchParams color="burgundy">
<CloseIcon color="burgundy" />
{intl.formatMessage({ id: "Close the map" })}
</Link>
</Button>
)
return (
<APIProvider apiKey={apiKey}>
<HotelListing />
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
activePoi={activePoi}
onActivePoiChange={setActivePoi}
mapId={mapId}
/>
</APIProvider>
)
}
@@ -0,0 +1,5 @@
.closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
}
+36
View File
@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function EditIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
height="20"
id="mask0_162_2666"
maskUnits="userSpaceOnUse"
style={{ maskType: "alpha" }}
width="20"
x="0"
y="0"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_162_2666)">
<path
d="M4.58333 16.8125C4.19949 16.8125 3.87088 16.6758 3.59752 16.4025C3.32417 16.1291 3.1875 15.8005 3.1875 15.4167V4.58333C3.1875 4.19375 3.32313 3.86024 3.5944 3.58281C3.86566 3.30538 4.19531 3.17361 4.58333 3.1875H11.7292L10.3333 4.58333H4.58333V15.4167H15.4167V9.65625L16.8125 8.26042V15.4167C16.8125 15.8005 16.6758 16.1291 16.4025 16.4025C16.1291 16.6758 15.8005 16.8125 15.4167 16.8125H4.58333ZM8.05208 11.9479V8.83333L15.4583 1.42708C15.6042 1.28125 15.7569 1.17882 15.9167 1.11979C16.0764 1.06076 16.245 1.03125 16.4224 1.03125C16.6116 1.03125 16.7899 1.06076 16.9573 1.11979C17.1247 1.17882 17.2808 1.27974 17.4259 1.42256L18.5625 2.5625C18.7083 2.70833 18.8125 2.86585 18.875 3.03504C18.9375 3.20424 18.9688 3.38006 18.9688 3.5625C18.9688 3.74529 18.9373 3.91971 18.8745 4.08575C18.8118 4.25179 18.7077 4.40724 18.5625 4.55208L11.1667 11.9479H8.05208ZM9.44792 10.5521H10.5938L15.4583 5.67708L14.8958 5.09375L14.3229 4.54167L9.44792 9.40625V10.5521Z"
fill="#26201E"
/>
</g>
</svg>
)
}
+1
View File
@@ -23,6 +23,7 @@ export { default as CrossCircle } from "./CrossCircle"
export { default as CulturalIcon } from "./Cultural"
export { default as DeleteIcon } from "./Delete"
export { default as DoorOpenIcon } from "./DoorOpen"
export { default as EditIcon } from "./Edit"
export { default as ElectricBikeIcon } from "./ElectricBike"
export { default as EmailIcon } from "./Email"
export { default as ErrorCircleIcon } from "./ErrorCircle"
+19 -6
View File
@@ -18,16 +18,12 @@
color: var(--Primary-Light-On-Surface-Accent);
}
.li:has(.heart),
.li:has(.check) {
list-style: none;
}
.li {
margin-left: var(--Spacing-x3);
}
.li:has(.heart):before {
.heart > .li::before,
.li:has(.heart)::before {
content: url("/_static/icons/heart.svg");
position: relative;
height: 8px;
@@ -36,6 +32,14 @@
margin-left: calc(var(--Spacing-x3) * -1);
}
.heart > .li,
.check > .li,
.li:has(.check),
.li:has(.heart) {
list-style: none;
}
.check > .li::before,
.li:has(.check)::before {
content: url("/_static/icons/check-ring.svg");
position: relative;
@@ -59,6 +63,7 @@
max-width: 100%;
overflow-x: auto;
}
@media screen and (min-width: 768px) {
.ol:has(li:nth-last-child(n + 5)),
.ul:has(li:nth-last-child(n + 5)) {
@@ -66,3 +71,11 @@
grid-auto-flow: column;
}
}
@container sidebar (max-width: 360px) {
.ol,
.ul {
display: flex;
flex-direction: column;
}
}
+19 -3
View File
@@ -12,7 +12,11 @@ import Caption from "../TempDesignSystem/Text/Caption"
import Footnote from "../TempDesignSystem/Text/Footnote"
import Subtitle from "../TempDesignSystem/Text/Subtitle"
import Title from "../TempDesignSystem/Text/Title"
import { hasAvailableParagraphFormat, hasAvailableULFormat } from "./utils"
import {
hasAvailableParagraphFormat,
hasAvailableULFormat,
makeCssModuleCompatibleClassName,
} from "./utils"
import styles from "./jsontohtml.module.css"
@@ -217,8 +221,16 @@ export const renderOptions: RenderOptions = {
fullRenderOptions: RenderOptions
) => {
const props = extractPossibleAttributes(node.attrs)
const compatibleClassName = makeCssModuleCompatibleClassName(
props.className,
"ul"
)
return (
<li key={node.uid} {...props} className={styles.li}>
<li
key={node.uid}
{...props}
className={`${styles.li} ${compatibleClassName}`}
>
{next(node.children, embeds, fullRenderOptions)}
</li>
)
@@ -525,6 +537,10 @@ export const renderOptions: RenderOptions = {
fullRenderOptions: RenderOptions
) => {
const props = extractPossibleAttributes(node.attrs)
const compatibleClassName = makeCssModuleCompatibleClassName(
props.className,
"ul"
)
// Set the number of rows dynamically to create even rows for each column. We want the li:s
// to flow with the column, so therefore this is needed.
@@ -538,7 +554,7 @@ export const renderOptions: RenderOptions = {
<ul
key={node.uid}
{...props}
className={styles.ul}
className={`${styles.ul} ${compatibleClassName}`}
style={
numberOfRows
? {
+18
View File
@@ -1,5 +1,7 @@
import { renderOptions } from "./renderOptions"
import styles from "./jsontohtml.module.css"
import type { Node } from "@/types/requests/utils/edges"
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
import {
@@ -136,3 +138,19 @@ export function nodesToHtml(
const fullRenderOptions = { ...renderOptions, ...overrideRenderOptions }
return nodes.map((node) => nodeToHtml(node, embeds, fullRenderOptions))
}
export function makeCssModuleCompatibleClassName(
className: string | undefined,
formatType: "ul"
): string {
if (!className) return ""
if (formatType === "ul" && hasAvailableULFormat(className)) {
// @ts-ignore: We want to set css modules classNames even if it does not correspond
// to an existing class in the module style sheet. Due to our css modules plugin for
// typescript, we cannot do this without the ts-ignore
return styles[className] || className
}
return className
}
@@ -16,6 +16,7 @@ import type { LanguageSwitcherContentProps } from "@/types/components/languageSw
export default function LanguageSwitcherContent({
urls,
onLanguageSwitch,
}: LanguageSwitcherContentProps) {
const intl = useIntl()
const currentLanguage = useLang()
@@ -39,6 +40,7 @@ export default function LanguageSwitcherContent({
<Link
className={`${styles.link} ${isActive ? styles.active : ""}`}
href={url}
onClick={onLanguageSwitch}
>
{languages[key]}
{isActive ? <CheckIcon color="burgundy" /> : null}
+34 -2
View File
@@ -1,5 +1,6 @@
"use client"
import { useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { languages } from "@/constants/languages"
@@ -28,6 +29,7 @@ export default function LanguageSwitcher({
const intl = useIntl()
const currentLanguage = useLang()
const toggleDropdown = useDropdownStore((state) => state.toggleDropdown)
const languageSwitcherRef = useRef<HTMLDivElement>(null)
const isFooterLanguageSwitcherOpen = useDropdownStore(
(state) => state.isFooterLanguageSwitcherOpen
)
@@ -70,10 +72,37 @@ export default function LanguageSwitcher({
})
}
useEffect(() => {
function handleClickOutside(evt: Event) {
const target = evt.target as HTMLElement
if (
languageSwitcherRef.current &&
target &&
!languageSwitcherRef.current.contains(target) &&
isLanguageSwitcherOpen &&
!isHeaderLanguageSwitcherMobileOpen
) {
toggleDropdown(dropdownType)
}
}
if (languageSwitcherRef.current) {
document.addEventListener("click", handleClickOutside)
}
return () => {
document.removeEventListener("click", handleClickOutside)
}
}, [
dropdownType,
toggleDropdown,
isLanguageSwitcherOpen,
isHeaderLanguageSwitcherMobileOpen,
])
const classNames = languageSwitcherVariants({ color, position })
return (
<div className={classNames}>
<div className={classNames} ref={languageSwitcherRef}>
<button
type="button"
className={styles.button}
@@ -99,7 +128,10 @@ export default function LanguageSwitcher({
>
{isLanguageSwitcherOpen ? (
<LanguageSwitcherContainer type={type}>
<LanguageSwitcherContent urls={urls} />
<LanguageSwitcherContent
urls={urls}
onLanguageSwitch={() => toggleDropdown(dropdownType)}
/>
</LanguageSwitcherContainer>
) : null}
</div>
@@ -8,29 +8,26 @@ import {
} from "@vis.gl/react-google-maps"
import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
import { MinusIcon, PlusIcon } from "@/components/Icons"
import CloseLargeIcon from "@/components/Icons/CloseLarge"
import PoiMarker from "@/components/Maps/Markers/Poi"
import ScandicMarker from "@/components/Maps/Markers/Scandic"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./map.module.css"
import styles from "./interactiveMap.module.css"
import type { MapContentProps } from "@/types/components/hotelPage/map/mapContent"
import type { InteractiveMapProps } from "@/types/components/hotelPage/map/interactiveMap"
export default function MapContent({
export default function InteractiveMap({
coordinates,
pointsOfInterest,
activePoi,
mapId,
onActivePoiChange,
}: MapContentProps) {
closeButton,
}: InteractiveMapProps) {
const intl = useIntl()
const { closeDynamicMap } = useHotelPageStore()
const map = useMap()
const mapOptions: MapProps = {
@@ -98,17 +95,7 @@ export default function MapContent({
))}
</Map>
<div className={styles.ctaButtons}>
<Button
theme="base"
intent="inverted"
variant="icon"
size="small"
className={styles.closeButton}
onClick={closeDynamicMap}
>
<CloseLargeIcon color="burgundy" />
<span>{intl.formatMessage({ id: "Close the map" })}</span>
</Button>
{closeButton}
<div className={styles.zoomButtons}>
<Button
theme="base"
+10 -2
View File
@@ -1,15 +1,23 @@
.aside {
display: none;
display: grid;
container-name: sidebar;
container-type: inline-size;
}
.content {
padding: var(--Spacing-x0) var(--Spacing-x2);
}
@media screen and (min-width: 1366px) {
@media screen and (min-width: 1367px) {
.aside {
align-content: flex-start;
display: grid;
gap: var(--Spacing-x4);
}
}
@container loyalty-page (max-width: 1366px) {
.aside {
display: none;
}
}
@@ -1,7 +1,6 @@
.btn {
background: none;
/* No variable yet for radius 50px */
border-radius: 50px;
border-radius: var(--Corner-radius-Rounded);
cursor: pointer;
margin: 0;
padding: 0;
@@ -10,12 +9,12 @@
background-color 300ms ease,
color 300ms ease;
/* TODO: Waiting for variables for buttons from Design team */
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
line-height: 24px;
line-height: var(--typography-Body-Bold-lineHeight);
letter-spacing: 0.6%;
text-decoration: none;
}
.wrapping {
@@ -23,6 +22,10 @@
padding-right: 0 !important;
}
.fullWidth {
width: 100%;
}
/* INTENT */
.primary,
a.primary {
@@ -69,21 +72,33 @@ a.default {
}
/* SIZES */
.small {
.btn.small {
font-size: var(--typography-Caption-Bold-fontSize);
line-height: var(--typography-Caption-Bold-lineHeight);
gap: var(--Spacing-x-quarter);
height: 40px;
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2); /* Special case padding to adjust the missing border */
}
.btn.small.secondary {
padding: var(--Spacing-x1) var(--Spacing-x2);
}
.medium {
.btn.medium {
gap: var(--Spacing-x-half);
height: 48px;
padding: calc(var(--Spacing-x-one-and-half) + 2px) var(--Spacing-x2); /* Special case padding to adjust the missing border */
}
.medium.secondary {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
}
.large {
.btn.large {
gap: var(--Spacing-x-half);
padding: calc(var(--Spacing-x2) + 2px) var(--Spacing-x3); /* Special case padding to adjust the missing border */
}
.large.secondary {
gap: var(--Spacing-x-half);
height: 56px;
padding: var(--Spacing-x2) var(--Spacing-x3);
}
@@ -170,19 +185,19 @@ a.default {
fill: var(--Base-Button-Secondary-On-Fill-Disabled);
}
.baseTertiary {
.btn.baseTertiary {
background-color: var(--Base-Button-Tertiary-Fill-Normal);
color: var(--Base-Button-Tertiary-On-Fill-Normal);
}
.baseTertiary:active,
.baseTertiary:focus,
.baseTertiary:hover {
.btn.baseTertiary:active,
.btn.baseTertiary:focus,
.btn.baseTertiary:hover {
background-color: var(--Base-Button-Tertiary-Fill-Hover);
color: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.baseTertiary:disabled {
.btn.baseTertiary:disabled {
background-color: var(--Base-Button-Tertiary-Fill-Disabled);
color: var(--Base-Button-Tertiary-On-Fill-Disabled);
}
@@ -800,4 +815,4 @@ a.default {
.icon.tertiaryLightSecondary:disabled svg,
.icon.tertiaryLightSecondary:disabled svg * {
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled);
}
}
+11 -2
View File
@@ -8,14 +8,23 @@ import { buttonVariants } from "./variants"
import type { ButtonProps } from "./button"
export default function Button(props: ButtonProps) {
const { className, intent, size, theme, wrapping, variant, ...restProps } =
props
const {
className,
intent,
size,
theme,
fullWidth,
wrapping,
variant,
...restProps
} = props
const classNames = buttonVariants({
className,
intent,
size,
theme,
fullWidth,
wrapping,
variant,
})
@@ -33,6 +33,9 @@ export const buttonVariants = cva(styles.btn, {
wrapping: {
true: styles.wrapping,
},
fullWidth: {
true: styles.fullWidth,
},
},
defaultVariants: {
intent: "primary",
+2
View File
@@ -23,6 +23,8 @@ export interface CardProps
heading?: string | null
bodyText?: string | null
backgroundImage?: ImageVaultAsset
imageHeight?: number
imageWidth?: number
onPrimaryButtonClick?: () => void
onSecondaryButtonClick?: () => void
}
+14 -6
View File
@@ -1,6 +1,7 @@
import Link from "next/link"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
@@ -21,10 +22,19 @@ export default function Card({
className,
theme,
backgroundImage,
imageHeight,
imageWidth,
onPrimaryButtonClick,
onSecondaryButtonClick,
}: CardProps) {
const { buttonTheme, primaryLinkColor, secondaryLinkColor } = getTheme(theme)
const buttonTheme = getTheme(theme)
imageHeight = imageHeight || 320
imageWidth =
imageWidth ||
(backgroundImage
? backgroundImage.dimensions.aspectRatio * imageHeight
: 420)
return (
<article
@@ -39,8 +49,8 @@ export default function Card({
src={backgroundImage.url}
className={styles.image}
alt={backgroundImage.meta.alt || backgroundImage.title}
width={backgroundImage.dimensions.width || 420}
height={backgroundImage.dimensions.height || 320}
width={imageWidth}
height={imageHeight}
/>
</div>
)}
@@ -76,7 +86,6 @@ export default function Card({
<Link
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
color={primaryLinkColor}
onClick={onPrimaryButtonClick}
>
{primaryButton.title}
@@ -94,7 +103,6 @@ export default function Card({
<Link
href={secondaryButton.href}
target={secondaryButton.openInNewTab ? "_blank" : undefined}
color={secondaryLinkColor}
onClick={onSecondaryButtonClick}
>
{secondaryButton.title}
@@ -1,36 +1,47 @@
.divider {
border-bottom-style: solid;
border-bottom-width: 1px;
pointer-events: none;
}
.horizontal {
height: 1px;
width: 100%;
}
.dotted {
border-bottom-style: dotted;
.vertical {
height: 100%;
width: 1px;
}
.burgundy {
border-bottom-color: var(--Scandic-Brand-Burgundy);
background-color: var(--Scandic-Brand-Burgundy);
}
.pale {
border-bottom-color: var(--Primary-Dark-On-Surface-Text);
background-color: var(--Primary-Dark-On-Surface-Text);
}
.peach {
border-bottom-color: var(--Primary-Light-On-Surface-Divider);
background-color: var(--Primary-Light-On-Surface-Divider);
}
.beige {
border-bottom-color: var(--Scandic-Beige-20);
background-color: var(--Scandic-Beige-20);
}
.white {
border-bottom-color: var(--UI-Opacity-White-100);
background-color: var(--UI-Opacity-White-100);
}
.subtle {
border-bottom-color: var(--Base-Border-Subtle);
background-color: var(--Base-Border-Subtle);
}
.primaryLightSubtle {
background-color: var(--Primary-Light-On-Surface-Divider-subtle);
}
.baseSurfaceSubtleNormal {
background-color: var(--Base-Surface-Subtle-Normal);
}
.opacity100 {
@@ -5,25 +5,27 @@ import styles from "./divider.module.css"
export const dividerVariants = cva(styles.divider, {
variants: {
color: {
burgundy: styles.burgundy,
peach: styles.peach,
baseSurfaceSubtleNormal: styles.baseSurfaceSubtleNormal,
beige: styles.beige,
white: styles.white,
subtle: styles.subtle,
burgundy: styles.burgundy,
pale: styles.pale,
peach: styles.peach,
primaryLightSubtle: styles.primaryLightSubtle,
subtle: styles.subtle,
white: styles.white,
},
opacity: {
100: styles.opacity100,
8: styles.opacity8,
},
variant: {
default: styles.default,
dotted: styles.dotted,
horizontal: styles.horizontal,
vertical: styles.vertical,
},
},
defaultVariants: {
color: "burgundy",
opacity: 100,
variant: "default",
variant: "horizontal",
},
})
+20 -4
View File
@@ -1,7 +1,7 @@
"use client"
import NextLink from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { startTransition, useCallback } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { startTransition, useCallback, useMemo } from "react"
import { trackClick } from "@/utils/tracking"
@@ -22,9 +22,14 @@ export default function Link({
variant,
trackingId,
onClick,
/**
* Decides if the link should include the current search params in the URL
*/
keepSearchParams,
...props
}: LinkProps) {
const currentPageSlug = usePathname()
const searchParams = useSearchParams()
let isActive = active || currentPageSlug === href
if (partialMatch && !isActive) {
@@ -42,6 +47,12 @@ export default function Link({
const router = useRouter()
const fullUrl = useMemo(() => {
const search =
keepSearchParams && searchParams.size ? `?${searchParams}` : ""
return `${href}${search}`
}, [href, searchParams, keepSearchParams])
const trackClickById = useCallback(() => {
if (trackingId) {
trackClick(trackingId)
@@ -65,12 +76,17 @@ export default function Link({
// track navigation nor start a router transition.
return
}
if (href.startsWith("tel:") || href.startsWith("mailto:")) {
// If href contains tel or mailto protocols we don't want to
// track navigation nor start a router transition.
return
}
e.preventDefault()
startTransition(() => {
router.push(href, { scroll })
router.push(fullUrl, { scroll })
})
}}
href={href}
href={fullUrl}
id={trackingId}
{...props}
/>
+1
View File
@@ -10,4 +10,5 @@ export interface LinkProps
partialMatch?: boolean
prefetch?: boolean
trackingId?: string
keepSearchParams?: boolean
}
+48 -36
View File
@@ -1,7 +1,7 @@
"use client"
import { useIsSSR } from "@react-aria/ssr"
import { useContext } from "react"
import { useContext, useState } from "react"
import {
Dialog,
DialogTrigger,
@@ -29,6 +29,15 @@ function SidePeek({
}: React.PropsWithChildren<SidePeekProps>) {
const isSSR = useIsSSR()
const intl = useIntl()
const [rootDiv, setRootDiv] = useState<HTMLDivElement | undefined>(undefined)
function setRef(node: HTMLDivElement | null) {
if (node) {
setRootDiv(node)
}
}
const context = useContext(SidePeekContext)
function onClose() {
const closeHandler = handleClose || context?.handleClose
@@ -44,42 +53,45 @@ function SidePeek({
)
}
return (
<DialogTrigger>
<ModalOverlay
className={styles.overlay}
isOpen={isOpen || contentKey === context?.activeSidePeek}
onOpenChange={onClose}
isDismissable
>
<Modal className={styles.modal}>
<Dialog className={styles.dialog}>
<aside className={styles.sidePeek}>
<header className={styles.header}>
{title ? (
<Title
color="burgundy"
textTransform="uppercase"
level="h2"
as="h3"
<div ref={setRef}>
<DialogTrigger>
<ModalOverlay
UNSTABLE_portalContainer={rootDiv}
className={styles.overlay}
isOpen={isOpen || contentKey === context?.activeSidePeek}
onOpenChange={onClose}
isDismissable
>
<Modal className={styles.modal}>
<Dialog className={styles.dialog}>
<aside className={styles.sidePeek}>
<header className={styles.header}>
{title ? (
<Title
color="burgundy"
textTransform="uppercase"
level="h2"
as="h3"
>
{title}
</Title>
) : null}
<Button
aria-label={intl.formatMessage({ id: "Close" })}
className={styles.closeButton}
intent="text"
onPress={onClose}
>
{title}
</Title>
) : null}
<Button
aria-label={intl.formatMessage({ id: "Close" })}
className={styles.closeButton}
intent="text"
onPress={onClose}
>
<CloseIcon color="burgundy" height={32} width={32} />
</Button>
</header>
<div className={styles.sidePeekContent}>{children}</div>
</aside>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
<CloseIcon color="burgundy" height={32} width={32} />
</Button>
</header>
<div className={styles.sidePeekContent}>{children}</div>
</aside>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</div>
)
}
@@ -78,6 +78,7 @@
.sidePeekContent {
padding: var(--Spacing-x4);
overflow-y: auto;
}
@media screen and (min-width: 1367px) {
.modal {
@@ -94,8 +95,4 @@
.modal[data-exiting] {
animation: slide-in 250ms reverse;
}
.overlay {
top: 0;
}
}
@@ -76,6 +76,10 @@
color: var(--UI-Text-Medium-contrast);
}
.textHighContrast {
color: var(--UI-Text-High-contrast);
}
.white {
color: var(--UI-Opacity-White-100);
}
@@ -11,6 +11,7 @@ const config = {
pale: styles.pale,
red: styles.red,
textMediumContrast: styles.textMediumContrast,
textHighContrast: styles.textHighContrast,
white: styles.white,
peach50: styles.peach50,
peach80: styles.peach80,
@@ -63,6 +63,10 @@ p.caption {
color: var(--UI-Text-Active);
}
.uiTextMediumContrast {
color: var(--UI-Text-Medium-contrast);
}
.center {
text-align: center;
}
@@ -12,6 +12,7 @@ const config = {
red: styles.red,
white: styles.white,
uiTextActive: styles.uiTextActive,
uiTextMediumContrast: styles.uiTextMediumContrast,
},
textTransform: {
bold: styles.bold,