Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions
@@ -0,0 +1,14 @@
.avatar {
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: var(--Corner-radius-Rounded);
width: 2rem;
height: 2rem;
background-color: var(--UI-Grey-40);
}
.initials {
background-color: var(--Base-Icon-Low-contrast);
}
@@ -0,0 +1,28 @@
import { PersonIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./avatar.module.css"
import type { AvatarProps } from "@/types/components/header/avatar"
export default function Avatar({ image, initials }: AvatarProps) {
let classNames = [styles.avatar]
let element = <PersonIcon color="white" />
if (image) {
classNames.push(styles.image)
element = <Image src={image.src} alt={image.alt} width={28} height={28} />
} else if (initials) {
classNames.push(styles.initials)
element = (
<Footnote type="label" color="white" textTransform="uppercase" asChild>
<span>{initials}</span>
</Footnote>
)
}
return (
<span data-hj-suppress className={classNames.join(" ")}>
{element}
</span>
)
}
@@ -0,0 +1,16 @@
import styles from "./menuButton.module.css"
import type { MainMenuButtonProps } from "@/types/components/header/mainMenuButton"
export default function MainMenuButton({
className = "",
...props
}: MainMenuButtonProps) {
return (
<button
type="button"
className={`${styles.menuButton} ${className}`}
{...props}
/>
)
}
@@ -0,0 +1,14 @@
.menuButton {
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
width: 100%;
background-color: transparent;
color: var(--Base-Text-High-contrast);
border-width: 0;
padding: var(--Spacing-x-half) 0;
cursor: pointer;
font-family: var(--typography-Body-Bold-fontFamily);
font-weight: 500; /* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight); */
font-size: var(--typography-Body-Bold-fontSize);
}
@@ -0,0 +1,116 @@
"use client"
import { useEffect } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import useDropdownStore from "@/stores/main-menu"
import LanguageSwitcher from "@/components/LanguageSwitcher"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import HeaderLink from "../../HeaderLink"
import TopLink from "../../TopLink"
import styles from "./mobileMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { MobileMenuProps } from "@/types/components/header/mobileMenu"
import { IconName } from "@/types/components/icon"
export default function MobileMenu({
children,
topLink,
isLoggedIn,
}: React.PropsWithChildren<MobileMenuProps>) {
const intl = useIntl()
const {
toggleDropdown,
isHamburgerMenuOpen,
isMyPagesMobileMenuOpen,
isHeaderLanguageSwitcherMobileOpen,
isFooterLanguageSwitcherOpen,
} = useDropdownStore()
const isHamburgerExtended =
isHamburgerMenuOpen ||
isMyPagesMobileMenuOpen ||
isHeaderLanguageSwitcherMobileOpen ||
isFooterLanguageSwitcherOpen
const isAboveMobile = useMediaQuery("(min-width: 768px)")
useEffect(() => {
if (isAboveMobile && isHamburgerMenuOpen) {
toggleDropdown(DropdownTypeEnum.HamburgerMenu)
}
}, [isAboveMobile, isHamburgerMenuOpen, toggleDropdown])
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && isHamburgerMenuOpen) {
toggleDropdown(DropdownTypeEnum.HamburgerMenu)
}
})
// Making sure the menu is always opened at the top of the page, just below the header.
useEffect(() => {
if (isHamburgerMenuOpen) {
window.scrollTo({ top: 0, behavior: "instant" })
}
}, [isHamburgerMenuOpen])
const closeMsg = intl.formatMessage({
id: "Close menu",
})
const openMsg = intl.formatMessage({
id: "Open menu",
})
return (
<>
<button
type="button"
className={`${styles.hamburger} ${isHamburgerExtended ? styles.isExpanded : ""}`}
aria-label={isHamburgerExtended ? closeMsg : openMsg}
onClick={() => toggleDropdown(DropdownTypeEnum.HamburgerMenu)}
>
<span className={styles.bar} />
</button>
<Modal className={styles.modal} isOpen={isHamburgerMenuOpen}>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Menu" })}
>
{children}
<footer className={styles.footer}>
<HeaderLink href="#" iconName={IconName.Search}>
{intl.formatMessage({ id: "Find booking" })}
</HeaderLink>
<TopLink isLoggedIn={isLoggedIn} topLink={topLink} iconSize={20} />
<HeaderLink href="#" iconName={IconName.Service}>
{intl.formatMessage({ id: "Customer service" })}
</HeaderLink>
<LanguageSwitcher type="mobileHeader" />
</footer>
</Dialog>
</Modal>
</>
)
}
export function MobileMenuSkeleton() {
const intl = useIntl()
return (
<button
type="button"
disabled
className={styles.hamburger}
aria-label={intl.formatMessage({
id: "Open menu",
})}
>
<span className={styles.bar} />
</button>
)
}
@@ -0,0 +1,106 @@
@keyframes slide-in {
from {
right: -100vw;
}
to {
right: 0;
}
}
.hamburger {
background-color: transparent;
border: none;
cursor: pointer;
justify-self: flex-start;
padding: 11px 8px 16px;
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: 32px;
}
.bar::after,
.bar::before {
content: "";
left: 0;
position: absolute;
transform-origin: 2.286px center;
}
.bar::after {
top: -8px;
}
.bar::before {
top: 8px;
}
.isExpanded .bar {
background: transparent;
}
.isExpanded .bar::after,
.isExpanded .bar::before {
top: 0;
transform-origin: 50% 50%;
width: 32px;
}
.isExpanded .bar::after {
transform: rotate(-45deg);
}
.isExpanded .bar::before {
transform: rotate(45deg);
}
.modal {
position: fixed;
top: calc(
var(--main-menu-mobile-height) + var(--sitewide-alert-height) + 1px
);
right: auto;
bottom: 0;
width: 100%;
background-color: var(--Base-Surface-Primary-light-Normal);
transition: right 0.3s;
z-index: var(--menu-overlay-z-index);
}
.modal[data-entering] {
animation: slide-in 0.3s;
}
.modal[data-exiting] {
animation: slide-in 0.3s reverse;
}
.dialog {
height: 100%;
overflow-y: auto;
display: grid;
align-content: space-between;
}
.footer {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x4) var(--Spacing-x2);
display: grid;
gap: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.hamburger,
.modal {
display: none;
}
}
@@ -0,0 +1,27 @@
import { getHeader } from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import { isValidSession } from "@/utils/session"
import MobileMenu from "../MobileMenu"
export default async function MobileMenuWrapper({
children,
}: React.PropsWithChildren) {
// preloaded
const header = await getHeader()
const session = await auth()
if (!header) {
return null
}
return (
<MobileMenu
topLink={header.data.topLink}
isLoggedIn={isValidSession(session)}
>
{children}
</MobileMenu>
)
}
@@ -0,0 +1,96 @@
"use client"
import { useRef } from "react"
import { useIntl } from "react-intl"
import useDropdownStore from "@/stores/main-menu"
import { ChevronDownSmallIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Body from "@/components/TempDesignSystem/Text/Body"
import useClickOutside from "@/hooks/useClickOutside"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import { type FriendsMembership, getInitials } from "@/utils/user"
import Avatar from "../Avatar"
import MainMenuButton from "../MainMenuButton"
import MyPagesMenuContent from "../MyPagesMenuContent"
import styles from "./myPagesMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { User } from "@/types/user"
import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output"
export type MyPagesMenuProps = {
user: Pick<User, "firstName" | "lastName">
membership?: FriendsMembership | null
membershipLevel: LoyaltyLevel | null
}
export default function MyPagesMenu({
user,
membership,
membershipLevel,
}: MyPagesMenuProps) {
const intl = useIntl()
const myPagesMenuRef = useRef<HTMLDivElement>(null)
const { toggleDropdown, isMyPagesMenuOpen } = useDropdownStore()
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && isMyPagesMenuOpen) {
toggleDropdown(DropdownTypeEnum.MyPagesMenu)
}
})
useClickOutside(myPagesMenuRef, isMyPagesMenuOpen, () => {
toggleDropdown(DropdownTypeEnum.MyPagesMenu)
})
return (
<div className={styles.myPagesMenu} ref={myPagesMenuRef}>
<MainMenuButton
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
>
<Avatar initials={getInitials(user.firstName, user.lastName)} />
<Body textTransform="bold" color="baseTextHighContrast" asChild>
<span data-hj-suppress>
{intl.formatMessage(
{ id: "Hi {firstName}!" },
{ firstName: user.firstName }
)}
</span>
</Body>
<ChevronDownSmallIcon
className={`${styles.chevron} ${isMyPagesMenuOpen ? styles.isExpanded : ""}`}
color="red"
/>
</MainMenuButton>
{isMyPagesMenuOpen ? (
<div className={styles.dropdown}>
<MyPagesMenuContent
membershipLevel={membershipLevel}
user={user}
membership={membership}
toggleOpenStateFn={() =>
toggleDropdown(DropdownTypeEnum.MyPagesMenu)
}
/>
</div>
) : null}
</div>
)
}
export function MyPagesMenuSkeleton() {
return (
<div className={styles.myPagesMenu}>
<MainMenuButton>
<Avatar />
<SkeletonShimmer width="10ch" />
<ChevronDownSmallIcon className={`${styles.chevron}`} color="red" />
</MainMenuButton>
</div>
)
}
@@ -0,0 +1,30 @@
.myPagesMenu {
display: none;
}
@media screen and (min-width: 768px) {
.myPagesMenu {
display: block;
}
.chevron {
transition: transform 0.3s;
}
.chevron.isExpanded {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(
3.5rem - 2px
); /* 3.5rem is the height of the main menu + bottom padding. */
right: 0;
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Large);
box-shadow: 0 0 0.875rem 0.375rem rgba(0, 0, 0, 0.1);
min-width: 20rem;
z-index: var(--menu-overlay-z-index);
}
}
@@ -0,0 +1,155 @@
"use client"
import { useIntl } from "react-intl"
import { logout } from "@/constants/routes/handleAuth"
import { trpc } from "@/lib/trpc/client"
import { ArrowRightIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { useTrapFocus } from "@/hooks/useTrapFocus"
import styles from "./myPagesMenuContent.module.css"
import type { MyPagesMenuProps } from "../MyPagesMenu"
type Props = MyPagesMenuProps & { toggleOpenStateFn: () => void }
export default function MyPagesMenuContent({
membership,
toggleOpenStateFn,
user,
membershipLevel,
}: Props) {
const intl = useIntl()
const myPagesMenuContentRef = useTrapFocus()
const { data: myPagesNavigation } = useMyPagesNavigation()
const primaryLinks = myPagesNavigation?.primaryLinks ?? []
const secondaryLinks = myPagesNavigation?.secondaryLinks ?? []
const membershipPoints = membership?.currentPoints
const introClassName =
membershipLevel && membershipPoints
? `${styles.intro}`
: `${styles.intro} ${styles.noMembership}`
if (primaryLinks.length === 0 && secondaryLinks.length === 0) {
return null
}
return (
<nav className={styles.myPagesMenuContent} ref={myPagesMenuContentRef}>
<div className={introClassName}>
<Subtitle type="two" className={styles.userName}>
{intl.formatMessage(
{ id: "Hi {firstName}!" },
{ firstName: user.firstName }
)}
</Subtitle>
{membershipLevel && membershipPoints ? (
<Caption className={styles.friendTypeWrapper}>
<span className={styles.friendType}>{membershipLevel.name}</span>
<span>
{intl.formatMessage(
{ id: "{pointsAmount, number} points" },
{ pointsAmount: membershipPoints }
)}
</span>
</Caption>
) : null}
</div>
<ul className={styles.groups}>
<li>
<Divider color="subtle" className={styles.divider} />
<PrimaryLinks toggleOpenStateFn={toggleOpenStateFn} />
<Divider color="subtle" className={styles.divider} />
<SecondaryLinks toggleOpenStateFn={toggleOpenStateFn} />
</li>
</ul>
</nav>
)
}
function PrimaryLinks({
toggleOpenStateFn,
}: {
toggleOpenStateFn: () => void
}) {
const { data: myPagesNavigation } = useMyPagesNavigation()
const primaryLinks = myPagesNavigation?.primaryLinks ?? []
return (
<ul className={styles.menuItems}>
{primaryLinks.map((link, i) => (
<li key={link.href + i}>
<Link
href={link.href}
onClick={toggleOpenStateFn}
variant="menu"
weight={"bold"}
className={styles.link}
>
{link.text}
<ArrowRightIcon className={styles.arrow} color="burgundy" />
</Link>
</li>
))}
</ul>
)
}
function SecondaryLinks({
toggleOpenStateFn,
}: {
toggleOpenStateFn: () => void
}) {
const intl = useIntl()
const lang = useLang()
const { data: myPagesNavigation } = useMyPagesNavigation()
const secondaryLinks = myPagesNavigation?.secondaryLinks ?? []
return (
<ul className={styles.menuItems}>
{secondaryLinks.map((link, i) => (
<li key={link.href + i}>
<Link
href={link.href}
onClick={toggleOpenStateFn}
variant="menu"
className={styles.link}
>
{link.text}
<ArrowRightIcon className={styles.arrow} color="burgundy" />
</Link>
</li>
))}
<li>
<Link
href={logout[lang]}
prefetch={false}
variant="menu"
className={styles.link}
>
{intl.formatMessage({ id: "Log out" })}
</Link>
</li>
</ul>
)
}
const useMyPagesNavigation = () => {
const lang = useLang()
return trpc.navigation.myPages.useQuery({
lang: lang,
})
}
@@ -0,0 +1,51 @@
.myPagesMenuContent {
padding: var(--Spacing-x3) var(--Spacing-x2);
}
.intro {
padding: 0 var(--Spacing-x1);
}
.myPagesMenuContent .friendTypeWrapper {
color: var(--UI-Text-Medium-contrast);
}
.divider {
margin: var(--Spacing-x2) 0;
}
.friendType {
font-family: var(--typography-Title-5-fontFamily);
letter-spacing: var(--typography-Title-5-letterSpacing);
font-size: var(--typography-Caption-Bold-fontSize);
text-transform: uppercase;
}
.friendType::after {
content: " · ";
display: inline;
padding: 0 var(--Spacing-x-half);
}
.groups,
.menuItems {
list-style: none;
}
.link:not(:hover) .arrow {
opacity: 0;
}
.arrow {
flex-shrink: 0;
}
@media screen and (min-width: 768px) {
.myPagesMenuContent {
padding: var(--Spacing-x2) var(--Spacing-x4);
}
.intro.noMembership,
.userName {
display: none;
}
}
@@ -0,0 +1,81 @@
"use client"
import { useIntl } from "react-intl"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { trpc } from "@/lib/trpc/client"
import LoginButton from "@/components/LoginButton"
import useLang from "@/hooks/useLang"
import Avatar from "../Avatar"
import MyPagesMenu, { MyPagesMenuSkeleton } from "../MyPagesMenu"
import MyPagesMobileMenu, {
MyPagesMobileMenuSkeleton,
} from "../MyPagesMobileMenu"
import styles from "./myPagesMenuWrapper.module.css"
export default function MyPagesMenuWrapper() {
const intl = useIntl()
const lang = useLang()
const { data: user, isLoading: isLoadingUser } = trpc.user.name.useQuery()
const { data: membership, isLoading: isLoadingMembership } =
trpc.user.safeMembershipLevel.useQuery()
const { data: membershipLevel, isLoading: isLoadingMembershipLevel } =
trpc.contentstack.loyaltyLevels.byLevel.useQuery(
{
lang: lang,
level: MembershipLevelEnum[membership?.membershipLevel ?? "L1"],
},
{
enabled: !!membership?.membershipLevel,
}
)
if (isLoadingUser || isLoadingMembership || isLoadingMembershipLevel) {
return <MyPagesMenuWrapperSkeleton />
}
return (
<>
{user ? (
<>
<MyPagesMenu
membershipLevel={membershipLevel ?? null}
membership={membership}
user={user}
/>
<MyPagesMobileMenu
membershipLevel={membershipLevel ?? null}
membership={membership}
user={user}
/>
</>
) : (
<LoginButton
className={styles.loginLink}
aria-label={intl.formatMessage({ id: "Log in/Join" })}
position="top menu"
trackingId="loginStartNewTopMenu"
>
<Avatar />
<span className={styles.loginText}>
{intl.formatMessage({ id: "Log in/Join" })}
</span>
</LoginButton>
)}
</>
)
}
export function MyPagesMenuWrapperSkeleton() {
return (
<div>
<MyPagesMenuSkeleton />
<MyPagesMobileMenuSkeleton />
</div>
)
}
@@ -0,0 +1,11 @@
.loginLink {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
@media screen and (max-width: 767px) {
.loginText {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client"
import { useEffect } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import useDropdownStore from "@/stores/main-menu"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import { getInitials } from "@/utils/user"
import Avatar from "../Avatar"
import MainMenuButton from "../MainMenuButton"
import MyPagesMenuContent from "../MyPagesMenuContent"
import styles from "./myPagesMobileMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { MyPagesMenuProps } from "../MyPagesMenu"
export default function MyPagesMobileMenu({
membershipLevel,
membership,
user,
}: MyPagesMenuProps) {
const intl = useIntl()
const { isMyPagesMobileMenuOpen, toggleDropdown } = useDropdownStore()
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && isMyPagesMobileMenuOpen) {
toggleDropdown(DropdownTypeEnum.MyPagesMobileMenu)
}
})
const isAboveMobile = useMediaQuery("(min-width: 768px)")
useEffect(() => {
if (isAboveMobile && isMyPagesMobileMenuOpen) {
toggleDropdown(DropdownTypeEnum.MyPagesMobileMenu)
}
}, [isAboveMobile, isMyPagesMobileMenuOpen, toggleDropdown])
// Making sure the menu is always opened at the top of the page, just below the header.
useEffect(() => {
if (isMyPagesMobileMenuOpen) {
window.scrollTo({ top: 0, behavior: "instant" })
}
}, [isMyPagesMobileMenuOpen])
return (
<div className={styles.myPagesMobileMenu}>
<MainMenuButton
className={styles.button}
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMobileMenu)}
aria-label={intl.formatMessage({ id: "Open my pages menu" })}
>
<Avatar initials={getInitials(user.firstName, user.lastName)} />
</MainMenuButton>
<Modal className={styles.modal} isOpen={isMyPagesMobileMenuOpen}>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "My pages menu" })}
>
<MyPagesMenuContent
membershipLevel={membershipLevel}
membership={membership}
user={user}
toggleOpenStateFn={() =>
toggleDropdown(DropdownTypeEnum.MyPagesMobileMenu)
}
/>
</Dialog>
</Modal>
</div>
)
}
export function MyPagesMobileMenuSkeleton() {
return (
<div className={styles.myPagesMobileMenu}>
<MainMenuButton className={styles.button}>
<Avatar />
</MainMenuButton>
</div>
)
}
@@ -0,0 +1,40 @@
@keyframes slide-in {
from {
right: -100vw;
}
to {
right: 0;
}
}
.modal {
position: fixed;
top: calc(
var(--main-menu-mobile-height) + var(--sitewide-alert-height) + 1px
);
right: auto;
bottom: 0;
width: 100%;
background-color: var(--Base-Surface-Primary-light-Normal);
z-index: var(--menu-overlay-z-index);
}
.modal[data-entering] {
animation: slide-in 0.3s;
}
.modal[data-exiting] {
animation: slide-in 0.3s reverse;
}
.dialog {
height: 100%;
overflow-y: auto;
}
@media screen and (min-width: 768px) {
.myPagesMobileMenu,
.modal {
display: none;
}
}
@@ -0,0 +1,121 @@
"use client"
import useDropdownStore from "@/stores/main-menu"
import { ArrowRightIcon, ChevronLeftIcon } from "@/components/Icons"
import Card from "@/components/TempDesignSystem/Card"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useTrapFocus } from "@/hooks/useTrapFocus"
import styles from "./megaMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { MegaMenuProps } from "@/types/components/header/megaMenu"
export default function MegaMenu({
isMobile,
title,
seeAllLink,
submenu,
card,
}: MegaMenuProps) {
const { toggleMegaMenu, toggleDropdown, isHamburgerMenuOpen } =
useDropdownStore()
const megaMenuRef = useTrapFocus()
function handleNavigate() {
toggleMegaMenu(false)
if (isHamburgerMenuOpen) {
toggleDropdown(DropdownTypeEnum.HamburgerMenu)
}
}
return (
<nav className={styles.megaMenu}>
{isMobile ? (
<div className={styles.backWrapper}>
<button
type="button"
className={styles.backButton}
onClick={() => toggleMegaMenu(false)}
>
<ChevronLeftIcon color="red" height={20} width={20} />
<Subtitle type="one" color="burgundy" asChild>
<span>{title}</span>
</Subtitle>
</button>
</div>
) : null}
<div className={styles.megaMenuContent} ref={megaMenuRef}>
<div className={styles.seeAllLink}>
{seeAllLink?.link ? (
<Link
href={seeAllLink.link.url}
color="burgundy"
variant="icon"
weight="bold"
onClick={handleNavigate}
>
{seeAllLink.title}
<ArrowRightIcon color="burgundy" />
</Link>
) : null}
</div>
<ul className={styles.submenus}>
{submenu.map((item) => (
<li key={item.title} className={styles.submenusItem}>
<Caption
type="label"
color="uiTextPlaceholder"
textTransform="uppercase"
asChild
>
<span className={styles.submenuTitle}>{item.title}</span>
</Caption>
<ul className={styles.submenu}>
{item.links.map(({ title, link }) =>
link ? (
<li key={title} className={styles.submenuItem}>
<Link
href={link.url}
variant="menu"
className={styles.link}
onClick={handleNavigate}
>
{title}
<ArrowRightIcon
color="burgundy"
className={styles.arrow}
/>
</Link>
</li>
) : null
)}
</ul>
</li>
))}
</ul>
{card ? (
<div className={styles.cardWrapper}>
<Card
className={styles.card}
backgroundImage={card.backgroundImage}
bodyText={card.body_text}
heading={card.heading}
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
scriptedTopTitle={card.scripted_top_title}
onPrimaryButtonClick={handleNavigate}
onSecondaryButtonClick={handleNavigate}
imageGradient
theme="image"
height="dynamic"
/>
</div>
) : null}
</div>
</nav>
)
}
@@ -0,0 +1,140 @@
.megaMenuContent {
display: grid;
}
.seeAllLink {
display: flex;
padding: var(--Spacing-x2) var(--Spacing-x3);
align-items: center;
gap: var(--Spacing-x1);
background-color: var(--Base-Surface-Secondary-light-Normal);
}
.submenus {
list-style: none;
display: grid;
}
.submenuTitle {
padding-left: var(--Spacing-x1);
}
.submenu {
list-style: none;
}
.submenuItem {
display: flex;
}
.submenusItem {
display: grid;
gap: var(--Spacing-x1);
align-content: start;
}
.linkIcon {
display: none;
}
.link:not(:hover) .arrow {
opacity: 0;
}
.arrow {
flex-shrink: 0;
}
.backWrapper {
padding: var(--Spacing-x2);
}
.backButton {
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--Spacing-x1);
width: 100%;
}
@media screen and (max-width: 767px) {
.megaMenu {
display: flex;
flex-direction: column;
height: 100%;
}
.megaMenuContent {
flex-grow: 1;
grid-template-rows: max-content 1fr;
gap: var(--Spacing-x2);
align-items: start;
}
.megaMenuContent:has(.cardWrapper) {
grid-template-rows: max-content 1fr max-content;
}
.submenus {
padding: var(--Spacing-x2);
}
.submenusItem:first-child {
padding-bottom: var(--Spacing-x2);
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.submenusItem:last-child {
padding-top: var(--Spacing-x3);
}
.cardWrapper {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x4) var(--Spacing-x2);
}
}
@media screen and (min-width: 768px) {
.megaMenuContent {
grid-template-rows: auto 1fr;
grid-template-areas:
"seeAllLink"
"submenus";
width: 600px;
max-width: calc(100vw - var(--Spacing-x4));
}
.megaMenuContent:has(.cardWrapper) {
width: 900px;
grid-template-columns: repeat(3, 1fr);
grid-template-areas:
"seeAllLink seeAllLink card"
"submenus submenus card";
}
.seeAllLink {
grid-area: seeAllLink;
}
.submenus {
grid-area: submenus;
grid-template-columns: repeat(2, 1fr);
padding: var(--Spacing-x2) var(--Spacing-x3);
}
.submenusItem:first-child {
padding-right: var(--Spacing-x5);
border-right: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.submenusItem:last-child {
padding-left: var(--Spacing-x5);
}
.cardWrapper {
grid-area: card;
}
.cardWrapper .card {
border-radius: 0;
}
}
@@ -0,0 +1,82 @@
"use client"
import { useRef } from "react"
import useDropdownStore from "@/stores/main-menu"
import { ChevronDownSmallIcon, ChevronRightIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import useClickOutside from "@/hooks/useClickOutside"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import MainMenuButton from "../../MainMenuButton"
import MegaMenu from "../MegaMenu"
import styles from "./navigationMenuItem.module.css"
import type { NavigationMenuItemProps } from "@/types/components/header/navigationMenuItem"
export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
const { openMegaMenu, toggleMegaMenu } = useDropdownStore()
const megaMenuRef = useRef<HTMLDivElement>(null)
const { submenu, title, link, seeAllLink, card } = item
const megaMenuTitle = `${title}-${isMobile ? "mobile" : "desktop"}`
const isMegaMenuOpen = openMegaMenu === megaMenuTitle
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && isMegaMenuOpen) {
toggleMegaMenu(false)
}
})
useClickOutside(megaMenuRef, isMegaMenuOpen && !isMobile, () => {
toggleMegaMenu(false)
})
return submenu.length ? (
<>
<MainMenuButton
onClick={() => toggleMegaMenu(megaMenuTitle)}
className={`${styles.navigationMenuItem} ${isMobile ? styles.mobile : styles.desktop}`}
>
{title}
{isMobile ? (
<ChevronRightIcon
className={`${styles.chevron}`}
color="red"
height={20}
width={20}
/>
) : (
<ChevronDownSmallIcon
className={`${styles.chevron} ${isMegaMenuOpen ? styles.isExpanded : ""}`}
color="red"
/>
)}
</MainMenuButton>
<div
ref={megaMenuRef}
className={`${styles.dropdown} ${isMegaMenuOpen ? styles.isExpanded : ""}`}
>
{isMegaMenuOpen ? (
<MegaMenu
isMobile={isMobile}
title={title}
seeAllLink={seeAllLink}
submenu={submenu}
card={card}
/>
) : null}
</div>
</>
) : (
<Link
className={`${styles.navigationMenuItem} ${isMobile ? styles.mobile : styles.desktop}`}
variant="navigation"
weight="bold"
href={link!.url}
>
{title}
</Link>
)
}
@@ -0,0 +1,58 @@
.navigationMenuItem {
font-weight: 500; /* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight); */
}
.navigationMenuItem.mobile {
display: flex;
justify-content: space-between;
padding: var(--Spacing-x2) 0;
font-size: var(--typography-Subtitle-1-Mobile-fontSize);
}
.chevron {
transition: transform 0.3s;
}
.chevron.isExpanded {
transform: rotate(180deg);
}
.dropdown {
background-color: var(--Base-Surface-Primary-light-Normal);
z-index: var(--menu-overlay-z-index);
overflow: hidden;
}
@media screen and (max-width: 767px) {
.dropdown {
position: fixed;
width: 100%;
top: calc(
var(--main-menu-mobile-height) + var(--sitewide-alert-height) + 1px
);
right: -100vw;
bottom: 0;
transition: right 0.3s;
overflow-y: auto;
}
.dropdown.isExpanded {
right: 0;
}
}
@media screen and (min-width: 768px) {
.dropdown {
display: none;
position: absolute;
top: calc(
3.5rem - 2px
); /* 3.5rem is the height of the main menu + bottom padding. */
left: 50%;
transform: translateX(-50%);
border-radius: var(--Corner-radius-Large);
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
}
.dropdown.isExpanded {
display: grid;
}
}
@@ -0,0 +1,36 @@
import { cx } from "class-variance-authority"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import NavigationMenuItem from "../NavigationMenuItem"
import styles from "./navigationMenuList.module.css"
import type { NavigationMenuListProps } from "@/types/components/header/navigationMenuList"
export default function NavigationMenuList({
isMobile,
items,
}: NavigationMenuListProps) {
return (
<ul
className={`${styles.navigationMenu} ${isMobile ? styles.mobile : styles.desktop}`}
>
{items.map((item) => (
<li key={item.title} className={styles.item}>
<NavigationMenuItem isMobile={isMobile} item={item} />
</li>
))}
</ul>
)
}
export function NavigationMenuListSkeleton() {
return (
<ul className={cx(styles.navigationMenu, styles.desktop)}>
<li className={styles.item}>
<SkeletonShimmer width="30ch" />
</li>
</ul>
)
}
@@ -0,0 +1,26 @@
.navigationMenu {
list-style: none;
margin: 0;
justify-content: space-between;
align-items: center;
gap: var(--Spacing-x3);
display: none;
}
.navigationMenu.mobile {
display: grid;
width: 100%;
gap: 0;
justify-content: stretch;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x2);
}
.navigationMenu.mobile .item {
border-bottom: 1px solid var(--Base-Surface-Subtle-Normal);
}
@media screen and (min-width: 768px) {
.navigationMenu.desktop {
display: flex;
}
}
@@ -0,0 +1,25 @@
import { getHeader } from "@/lib/trpc/memoizedRequests"
import NavigationMenuList from "./NavigationMenuList"
import type { NavigationMenuProps } from "@/types/components/header/navigationMenu"
export default async function NavigationMenu({
isMobile,
}: NavigationMenuProps) {
const header = await getHeader()
if (!header) {
return null
}
const filteredItems = header.data.menuItems.filter(
({ link, submenu }) => submenu.length || link
)
if (!filteredItems?.length) {
return null
}
return <NavigationMenuList isMobile={isMobile} items={filteredItems} />
}
@@ -0,0 +1,64 @@
import NextLink from "next/link"
import { Suspense } from "react"
import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { NavigationMenuListSkeleton } from "./NavigationMenu/NavigationMenuList"
import { MobileMenuSkeleton } from "./MobileMenu"
import MobileMenuWrapper from "./MobileMenuWrapper"
import MyPagesMenuWrapper, {
MyPagesMenuWrapperSkeleton,
} from "./MyPagesMenuWrapper"
import NavigationMenu from "./NavigationMenu"
import styles from "./mainMenu.module.css"
export default function MainMenu() {
return (
<div className={styles.mainMenu}>
<nav className={styles.nav}>
<Suspense fallback={<Logo alt="..." />}>
<MainMenuLogo />
</Suspense>
<div className={styles.menus}>
<Suspense fallback={<NavigationMenuListSkeleton />}>
<NavigationMenu isMobile={false} />
</Suspense>
<Suspense fallback={<MyPagesMenuWrapperSkeleton />}>
<MyPagesMenuWrapper />
</Suspense>
<Suspense fallback={<MobileMenuSkeleton />}>
<MobileMenuWrapper>
<NavigationMenu isMobile={true} />
</MobileMenuWrapper>
</Suspense>
</div>
</nav>
</div>
)
}
async function MainMenuLogo() {
const intl = await getIntl()
return <Logo alt={intl.formatMessage({ id: "Back to scandichotels.com" })} />
}
function Logo({ alt }: { alt: string }) {
const lang = getLang()
return (
<NextLink className={styles.logoLink} href={`/${lang}`}>
<Image
alt={alt}
className={styles.logo}
height={22}
src="/_static/img/scandic-logotype.svg"
priority
width={103}
/>
</NextLink>
)
}
@@ -0,0 +1,47 @@
.mainMenu {
background-color: var(--Base-Surface-Primary-light-Normal);
padding: var(--Spacing-x2) 0;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.nav {
position: relative;
max-width: var(--max-width-navigation);
margin: 0 auto;
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
gap: var(--Spacing-x2);
max-width: var(--max-width-page);
margin: 0 auto;
}
.menus {
display: flex;
justify-self: end;
align-items: center;
gap: var(--Spacing-x2);
}
.logoLink {
display: inline-flex;
width: auto;
}
.logo {
height: 1.375rem;
}
@media screen and (min-width: 768px) {
.mainMenu {
padding: var(--Spacing-x2) 0;
}
.nav {
display: flex;
justify-content: space-between;
gap: var(--Spacing-x3);
}
.menus {
display: contents;
}
}