Merge branch 'develop' into feat/sw-386-header-fixes

This commit is contained in:
Linus Flood
2024-09-17 12:42:58 +02:00
24 changed files with 513 additions and 82 deletions

View File

@@ -0,0 +1,27 @@
# Booking flow
The booking flow is the user journey of booking one or more rooms at our
hotels. Everything from choosing the date to payment and confirmation is
part of the booking flow.
## Booking widget
On most of the pages on the website we have a booking widget. This is where
the user starts the booking flow, by filling the form and submit. If they
entered a city as the destination they will land on the select hotel page
and if they entered a specific hotel they will land on the select rate page.
## Select hotel
Lists available hotels based on the search criteria. When the user selects
a hotel they land on the select rate page.
## Select rate, room, breakfast etc
This is a page with an accordion like design, but every accordion is handled
as its own page with its own URL.
## State management
The state, like search parameters and selected alternatives, is kept
throughout the booking flow in the URL.

View File

@@ -1,6 +1,7 @@
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Card from "@/components/TempDesignSystem/Card"
import ContentCard from "@/components/TempDesignSystem/ContentCard"
import Grids from "@/components/TempDesignSystem/Grids"
import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard"
@@ -21,8 +22,19 @@ export default function CardsGrid({
<Grids.Stackable>
{cards_grid.cards.map((card) => {
switch (card.__typename) {
case CardsGridEnum.Card: {
return (
case CardsGridEnum.Card:
return card.isContentCard ? (
<ContentCard
key={card.system.uid}
title={card.heading || ""}
description={card.body_text || ""}
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
sidePeekButton={card.sidePeekButton}
backgroundImage={card.background_image}
style="default"
/>
) : (
<Card
theme={cards_grid.theme || "one"}
key={card.system.uid}
@@ -33,7 +45,6 @@ export default function CardsGrid({
primaryButton={card.primaryButton}
/>
)
}
case CardsGridEnum.LoyaltyCard:
return (
<LoyaltyCard

View File

@@ -74,7 +74,7 @@ export function RoomCard({
onClick={handleRoomCtaClick}
>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightIcon className={styles.chevron} />
<ChevronRightIcon />
</Button>
</div>
</article>

View File

@@ -0,0 +1,63 @@
import { useIntl } from "react-intl"
import useDropdownStore from "@/stores/main-menu"
import { ChevronLeftIcon } from "@/components/Icons"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./languageSwitcherContainer.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import {
type LanguageSwitcherContainerProps,
LanguageSwitcherTypesEnum,
} from "@/types/components/languageSwitcher/languageSwitcher"
export default function LanguageSwitcherContainer({
children,
type,
}: LanguageSwitcherContainerProps) {
const { toggleDropdown } = useDropdownStore()
const intl = useIntl()
const isFooter = type === LanguageSwitcherTypesEnum.Footer
const isMobileHeader = type === LanguageSwitcherTypesEnum.MobileHeader
const position = isFooter
? DropdownTypeEnum.FooterLanguageSwitcher
: DropdownTypeEnum.HamburgerMenu
return (
<div>
{isMobileHeader ? (
<div className={styles.backWrapper}>
<button
type="button"
className={styles.backButton}
onClick={() => toggleDropdown(position)}
>
<ChevronLeftIcon color="red" />
<Subtitle type="one">
{intl.formatMessage({
id: "Main menu",
})}
</Subtitle>
</button>
</div>
) : null}
{isFooter ? (
<div className={styles.closeWrapper}>
<button
type="button"
className={styles.closeButton}
aria-label={intl.formatMessage({
id: "Close menu",
})}
onClick={() => toggleDropdown(position)}
>
<span className={styles.bar}></span>
</button>
</div>
) : null}
{children}
</div>
)
}

View File

@@ -0,0 +1,70 @@
.backWrapper {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x2);
}
.backButton {
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
.closeWrapper {
display: flex;
justify-content: flex-end;
padding: var(--Spacing-x2);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.closeButton {
background-color: transparent;
border: none;
cursor: pointer;
justify-self: flex-start;
padding: 11px var(--Spacing-x1) var(--Spacing-x2);
user-select: none;
}
.bar,
.bar::after,
.bar::before {
background: var(--Base-Text-High-contrast);
border-radius: 2.3px;
display: inline-block;
height: 3px;
position: relative;
transition: all 0.3s;
width: var(--Spacing-x4);
}
.bar::after,
.bar::before {
content: "";
left: 0;
position: absolute;
top: 0;
transform-origin: 50% 50%;
width: var(--Spacing-x4);
}
.bar {
background: transparent;
}
.bar::after {
transform: rotate(-45deg);
}
.bar::before {
transform: rotate(45deg);
}
@media screen and (min-width: 768px) {
.closeWrapper {
display: none;
}
}

View File

@@ -3,9 +3,8 @@
import { useIntl } from "react-intl"
import { Lang, languages } from "@/constants/languages"
import useDropdownStore from "@/stores/main-menu"
import { CheckIcon, ChevronLeftIcon } from "@/components/Icons"
import { CheckIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
@@ -13,38 +12,19 @@ import { useTrapFocus } from "@/hooks/useTrapFocus"
import styles from "./languageSwitcherContent.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { LanguageSwitcherProps } from "@/types/components/languageSwitcher/languageSwitcher"
import type { LanguageSwitcherContentProps } from "@/types/components/languageSwitcher/languageSwitcher"
export default function LanguageSwitcherContent({
urls,
type,
}: LanguageSwitcherProps) {
}: LanguageSwitcherContentProps) {
const intl = useIntl()
const currentLanguage = useLang()
const { toggleDropdown } = useDropdownStore()
const languageSwitcherRef = useTrapFocus()
const urlKeys = Object.keys(urls) as Lang[]
const position =
type === "footer"
? DropdownTypeEnum.FooterLanguageSwitcher
: DropdownTypeEnum.HamburgerMenu
return (
<div className={styles.languageSwitcherContent} ref={languageSwitcherRef}>
{type === "mobileHeader" ? (
<div className={styles.backWrapper}>
<button
type="button"
className={styles.backButton}
onClick={() => toggleDropdown(position)}
>
<ChevronLeftIcon color="red" />
<Subtitle type="one">Main Menu</Subtitle>
</button>
</div>
) : null}
<div className={styles.languageWrapper}>
<Subtitle className={styles.subtitle} type="two">
{intl.formatMessage({ id: "Select your language" })}

View File

@@ -1,22 +1,3 @@
.backWrapper {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x2);
}
.backButton {
background-color: transparent;
border: none;
color: var(--Base-Text-High-contrast);
font-family: var(--typography-Subtitle-1-fontFamily);
font-weight: var(--typography-Subtitle-1-fontWeight);
font-size: var(--typography-Subtitle-1-Mobile-fontSize);
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
.languageWrapper {
display: grid;
gap: var(--Spacing-x3);

View File

@@ -1,5 +1,6 @@
"use client"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { languages } from "@/constants/languages"
@@ -9,13 +10,17 @@ import { ChevronDownIcon, GlobeIcon } from "@/components/Icons"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import useLang from "@/hooks/useLang"
import LanguageSwitcherContainer from "./LanguageSwitcherContainer"
import LanguageSwitcherContent from "./LanguageSwitcherContent"
import { languageSwitcherVariants } from "./variants"
import styles from "./languageSwitcher.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { LanguageSwitcherProps } from "@/types/components/languageSwitcher/languageSwitcher"
import {
type LanguageSwitcherProps,
LanguageSwitcherTypesEnum,
} from "@/types/components/languageSwitcher/languageSwitcher"
export default function LanguageSwitcher({
urls,
@@ -30,8 +35,11 @@ export default function LanguageSwitcher({
isHeaderLanguageSwitcherMobileOpen,
} = useDropdownStore()
const position = type === "footer" ? "footer" : "header"
const color = type === "footer" ? "pale" : "burgundy"
const isFooter = type === LanguageSwitcherTypesEnum.Footer
const isHeader = !isFooter
const position = isFooter ? "footer" : "header"
const color = isFooter ? "pale" : "burgundy"
const dropdownType = {
footer: DropdownTypeEnum.FooterLanguageSwitcher,
@@ -40,8 +48,8 @@ export default function LanguageSwitcher({
}[type]
const isLanguageSwitcherOpen =
(type === "footer" && isFooterLanguageSwitcherOpen) ||
(type !== "footer" &&
(isFooter && isFooterLanguageSwitcherOpen) ||
(isHeader &&
(isHeaderLanguageSwitcherOpen || isHeaderLanguageSwitcherMobileOpen))
useHandleKeyUp((event: KeyboardEvent) => {
@@ -50,6 +58,18 @@ export default function LanguageSwitcher({
}
})
useEffect(() => {
if (isFooter && isFooterLanguageSwitcherOpen) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
}
}, [isFooter, isFooterLanguageSwitcherOpen])
const classNames = languageSwitcherVariants({ color, position })
return (
@@ -78,7 +98,9 @@ export default function LanguageSwitcher({
className={`${styles.dropdown} ${isLanguageSwitcherOpen ? styles.isExpanded : ""}`}
>
{isLanguageSwitcherOpen ? (
<LanguageSwitcherContent urls={urls} type={type} />
<LanguageSwitcherContainer type={type}>
<LanguageSwitcherContent urls={urls} />
</LanguageSwitcherContainer>
) : null}
</div>
</div>

View File

@@ -31,20 +31,36 @@
.dropdown {
position: fixed;
top: var(--main-menu-mobile-height);
right: -100vw;
bottom: 0;
width: 100%;
background-color: var(--Base-Surface-Primary-light-Normal);
transition: right 0.3s;
z-index: var(--menu-overlay-z-index);
}
.dropdown.isExpanded {
.top .dropdown {
right: -100vw;
top: var(--main-menu-mobile-height);
bottom: 0;
transition: right 0.3s;
}
.top .dropdown.isExpanded {
display: block;
right: 0;
}
.bottom .dropdown {
transition: transform 0.3s;
width: 100%;
height: 100vh;
left: 0;
bottom: 0;
transform: translateY(100%);
}
.bottom .dropdown.isExpanded {
transform: translateY(0);
}
@media screen and (min-width: 768px) {
.languageSwitcher {
position: relative;
@@ -81,10 +97,16 @@
}
.bottom .dropdown {
top: auto;
transition: none;
height: auto;
left: -100%;
bottom: 2.25rem;
}
.bottom .dropdown.isExpanded {
display: block;
}
.bottom .dropdown::before {
top: 100%;
}

View File

@@ -0,0 +1,71 @@
.card {
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
max-width: 399px;
overflow: hidden;
}
.default {
background-color: var(--Base-Surface-Subtle-Normal);
}
.featured {
background-color: var(--Main-Grey-White);
}
.default,
.featured {
border: 1px solid var(--Base-Border-Subtle);
}
.imageContainer {
width: 100%;
height: 12.58625rem; /* 201.38px / 16 = 12.58625rem */
overflow: hidden;
}
.backgroundImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.content {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
align-items: flex-start;
padding: var(--Spacing-x2) var(--Spacing-x3);
}
.description {
color: var(--Base-Text-Medium-contrast);
}
.ctaContainer {
display: grid;
grid-template-columns: 1fr;
gap: var(--Spacing-x1);
width: 100%;
}
.ctaButton {
width: 100%;
}
@media (min-width: 1367px) {
.card:not(.alwaysStack) .ctaContainer {
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
.card:not(.alwaysStack) .ctaContainer:has(:only-child) {
grid-template-columns: 1fr;
}
}
.sidePeekCTA {
/* TODO: Create ticket to remove padding on "link" buttons,
align w. design on this. */
padding: 0 !important;
}

View File

@@ -0,0 +1,98 @@
import React from "react"
import { ChevronRightIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "../Text/Subtitle"
import { contentCardVariants } from "./variants"
import styles from "./contentCard.module.css"
import type { ContentCardProps } from "@/types/components/contentCard"
export default function ContentCard({
title,
description,
primaryButton,
secondaryButton,
sidePeekButton,
backgroundImage,
style = "default",
alwaysStack = false,
className,
}: ContentCardProps) {
const cardClasses = contentCardVariants({ style, alwaysStack, className })
return (
<div className={cardClasses}>
{backgroundImage && (
<div className={styles.imageContainer}>
<Image
src={backgroundImage.url}
alt={backgroundImage.meta?.alt || ""}
className={styles.backgroundImage}
width={399}
height={201}
/>
</div>
)}
<div className={styles.content}>
<Subtitle textAlign="left" type="two" color="black">
{title}
</Subtitle>
<Body color="black">{description}</Body>
{!!sidePeekButton ? (
<Button
// onClick={() => {
// // TODO: Implement sidePeek functionality once SW-341 is merged.
// }}
theme="base"
variant="icon"
intent="text"
size="small"
className={styles.sidePeekCTA}
>
{sidePeekButton.title}
<ChevronRightIcon />
</Button>
) : (
<div className={styles.ctaContainer}>
{primaryButton && (
<Button
asChild
intent="primary"
size="small"
className={styles.ctaButton}
>
<Link
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
>
{primaryButton.title}
</Link>
</Button>
)}
{secondaryButton && (
<Button
asChild
intent="secondary"
size="small"
className={styles.ctaButton}
>
<Link
href={secondaryButton.href}
target={secondaryButton.openInNewTab ? "_blank" : undefined}
>
{secondaryButton.title}
</Link>
</Button>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { cva } from "class-variance-authority"
import styles from "./contentCard.module.css"
export const contentCardVariants = cva(styles.card, {
variants: {
style: {
default: styles.default,
featured: styles.featured,
},
alwaysStack: {
true: styles.alwaysStack,
false: "",
},
},
defaultVariants: {
style: "default",
alwaysStack: false,
},
})

View File

@@ -100,6 +100,7 @@
"Log in here": "Log ind her",
"Log in/Join": "Log på/Tilmeld dig",
"Log out": "Log ud",
"Main menu": "Hovedmenu",
"Manage preferences": "Administrer præferencer",
"Map": "Kort",
"Map of HOTEL_NAME": "Map of {hotelName}",

View File

@@ -98,6 +98,7 @@
"Log in here": "Hier einloggen",
"Log in/Join": "Log in/Anmelden",
"Log out": "Ausloggen",
"Main menu": "Hauptmenü",
"Manage preferences": "Verwalten von Voreinstellungen",
"Map": "Karte",
"Map of HOTEL_NAME": "Map of {hotelName}",

View File

@@ -104,6 +104,7 @@
"Log in here": "Log in here",
"Log in/Join": "Log in/Join",
"Log out": "Log out",
"Main menu": "Main menu",
"Manage preferences": "Manage preferences",
"Map": "Map",
"Map of HOTEL_NAME": "Map of {hotelName}",

View File

@@ -99,6 +99,7 @@
"Log in here": "Kirjaudu sisään",
"Log in/Join": "Kirjaudu sisään/Liittyä",
"Log out": "Kirjaudu ulos",
"Main menu": "Päävalikko",
"Manage preferences": "Asetusten hallinta",
"Map": "Kartta",
"Map of HOTEL_NAME": "Map of {hotelName}",

View File

@@ -99,6 +99,7 @@
"Log in here": "Logg inn her",
"Log in/Join": "Logg på/Bli med",
"Log out": "Logg ut",
"Main menu": "Hovedmeny",
"Manage preferences": "Administrer preferanser",
"Map": "Kart",
"Map of HOTEL_NAME": "Map of {hotelName}",

View File

@@ -101,6 +101,7 @@
"Log in here": "Logga in här",
"Log in/Join": "Logga in/Gå med",
"Log out": "Logga ut",
"Main menu": "Huvudmeny",
"Manage preferences": "Hantera inställningar",
"Map": "Karta",
"Map of HOTEL_NAME": "Map of {hotelName}",

View File

@@ -1,30 +1,13 @@
fragment CardBlock on Card {
is_content_card
heading
body_text
background_image
scripted_top_title
title
has_secondary_button
secondary_button {
is_contentstack_link
cta_text
open_in_new_tab
external_link {
title
href
}
linkConnection {
edges {
node {
__typename
...LoyaltyPageLink
...ContentPageLink
...AccountPageLink
}
}
}
}
has_primary_button
has_secondary_button
has_sidepeek_button
primary_button {
is_contentstack_link
cta_text
@@ -44,6 +27,28 @@ fragment CardBlock on Card {
}
}
}
secondary_button {
is_contentstack_link
cta_text
open_in_new_tab
external_link {
title
href
}
linkConnection {
edges {
node {
__typename
...LoyaltyPageLink
...ContentPageLink
...AccountPageLink
}
}
}
}
sidepeek_button {
call_to_action_text
}
system {
locale
uid

View File

@@ -8,7 +8,7 @@ import {
GetLoyaltyPageSettings,
} from "@/lib/graphql/Query/BookingWidgetToggle.graphql"
import { request } from "@/lib/graphql/request"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { contentstackBaseProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag"
@@ -21,7 +21,7 @@ import { affix as bookingwidgetAffix } from "./utils"
import { ContentTypeEnum } from "@/types/requests/contentType"
export const bookingwidgetQueryRouter = router({
getToggle: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
getToggle: contentstackBaseProcedure.query(async ({ ctx }) => {
const failedResponse = { hideBookingWidget: false }
const { contentType, uid, lang } = ctx

View File

@@ -68,6 +68,7 @@ export const contentPageDynamicContent = z.object({
export const cardBlock = z.object({
__typename: z.literal(CardsGridEnum.Card),
isContentCard: z.boolean(),
heading: z.string().nullable(),
body_text: z.string().nullable(),
background_image: z.any(),
@@ -88,6 +89,11 @@ export const cardBlock = z.object({
isExternal: z.boolean(),
})
.optional(),
sidePeekButton: z
.object({
title: z.string(),
})
.optional(),
system: z.object({
locale: z.nativeEnum(Lang),
uid: z.string(),

View File

@@ -102,6 +102,7 @@ export const contentPageQueryRouter = router({
case CardsGridEnum.Card:
return {
...card,
isContentCard: !!card.is_content_card,
backgroundImage: makeImageVaultImage(
card.background_image
),
@@ -111,6 +112,14 @@ export const contentPageQueryRouter = router({
secondaryButton: card.has_secondary_button
? makeButtonObject(card.secondary_button)
: undefined,
sidePeekButton:
card.has_sidepeek_button ||
!!card.sidepeek_button?.call_to_action_text
? {
title:
card.sidepeek_button.call_to_action_text,
}
: undefined,
}
case CardsGridEnum.LoyaltyCard:
return {

View File

@@ -0,0 +1,21 @@
import { VariantProps } from "class-variance-authority"
import { contentCardVariants } from "@/components/TempDesignSystem/ContentCard/variants"
import { ImageVaultAsset } from "@/types/components/imageVault"
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
interface SidePeekButton {
title: string
}
export interface ContentCardProps
extends VariantProps<typeof contentCardVariants> {
title: string
description: string
primaryButton?: CardProps["primaryButton"]
secondaryButton?: CardProps["secondaryButton"]
sidePeekButton?: SidePeekButton
backgroundImage?: ImageVaultAsset
className?: string
}

View File

@@ -1,6 +1,25 @@
import { ReactElement } from "react"
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
export enum LanguageSwitcherTypesEnum {
MobileHeader = "mobileHeader",
DesktopHeader = "desktopHeader",
Footer = "footer",
}
export type LanguageSwitcherTypes = `${LanguageSwitcherTypesEnum}`
export interface LanguageSwitcherProps {
type: "mobileHeader" | "desktopHeader" | "footer"
type: LanguageSwitcherTypes
urls: LanguageSwitcherData
}
export interface LanguageSwitcherContentProps {
urls: LanguageSwitcherData
}
export interface LanguageSwitcherContainerProps {
type: LanguageSwitcherTypes
children: ReactElement<any, any>
}