Merged in feat/SW-2782-create-sas-branded-header (pull request #2878)

feat(SW-2782): Updated header as per design, added language switcher and user menu

* feat(SW-2782): Updated header as per design, added language switcher and user menu

* feat(SW-2782): Updated UI as per design

* feat(SW-2782): Optimised code with use of Popover and modal from RAC


Approved-by: Anton Gunnarsson
This commit is contained in:
Hrishikesh Vaipurkar
2025-10-06 08:46:26 +00:00
parent e18bba79c6
commit d3368e9b85
27 changed files with 985 additions and 125 deletions

View File

@@ -1,78 +1,17 @@
"use client"
import { useSession } from "next-auth/react"
import Image from "@scandic-hotels/design-system/Image"
import Link from "@scandic-hotels/design-system/Link"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "@/hooks/useLang"
import { Menu } from "../Menu"
import { PoweredByScandic } from "../PoweredByScandic/PoweredByScandic"
import styles from "./header.module.css"
export function Header() {
const lang = useLang()
const session = useSession()
const {
data: profileData,
isLoading,
isSuccess,
} = trpc.partner.sas.getEuroBonusProfile.useQuery(undefined, {
enabled: session.status === "authenticated",
})
return (
<>
<header className={styles.header}>
<Image
alt="SAS logotype"
className={styles.logo}
src="/_static/img/sas-logotype-white.svg"
height={32}
width={90}
sizes="100vw"
/>
{session.status === "loading" && (
<SkeletonShimmer width={"12ch"} height={"1ch"} />
)}
{session.status === "unauthenticated" && (
/** For some reason it complains about RSC-payload if using <Link /> */
<a href={`/${lang}/login?redirectTo=${window?.location.href}`}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{"Login here"}
</a>
)}
{session.status === "authenticated" && (
<div>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{session.data?.user && <>{session.data.user.email}</>}
</span>
</Typography>
{isLoading && <SkeletonShimmer width={"6ch"} height={"1ch"} />}
{isSuccess && profileData && (
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{profileData.points.total} Points
</span>
</Typography>
)}
<Link color={"white"} href={`/${lang}/logout`} prefetch={false}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{"Logout"}
</Link>
</div>
)}
</header>
<Menu />
<div className={styles.poweredBy}>
<PoweredByScandic />
</div>
</header>
</>
)
}

View File

@@ -1,21 +1,3 @@
.header {
background-color: var(--TEMP-sas-default);
color: white;
display: flex;
align-items: center;
padding: 16px;
justify-content: space-between;
@media screen and (min-width: 768px) {
padding: 20px 40px;
}
}
.logo {
height: auto;
width: 90px;
}
.poweredBy {
padding: 6px 16px;
background-color: var(--Base-Surface-Primary-light-Normal);

View File

@@ -0,0 +1,204 @@
"use client"
import { usePathname } from "next/navigation"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
Popover,
} from "react-aria-components"
import { useIntl } from "react-intl"
import {
type Lang,
languages,
type LanguageSwitcherData,
} from "@scandic-hotels/common/constants/language"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./languageSwitcher.module.css"
type LanguageSwitcherProps = {
currentLanguage: Lang
isMobile?: boolean
}
export function replaceUrlPart(currentPath: string, newPart: string): string {
const pathSegments = currentPath.split("/").filter((segment) => segment)
const newPathSegments = newPart
.replace(/\/$/, "")
.split("/")
.filter((segment) => segment)
const isFullPathReplacement = newPathSegments.length > 1
if (isFullPathReplacement) {
return `/${newPathSegments.join("/")}`
}
const updatedPathSegments = pathSegments.slice(1)
const updatedPath = `/${newPathSegments.concat(updatedPathSegments).join("/")}`
return updatedPath
}
export function LanguageSwitcher({
currentLanguage,
isMobile = false,
}: LanguageSwitcherProps) {
return (
<div className={styles.languageSwitcher}>
<DialogTrigger>
<Button className={styles.triggerButton} variant={"Text"} wrapping>
{isMobile ? null : (
<MaterialIcon
icon="globe"
size={16}
color={"Icon/Inverted"}
className={styles.globeIcon}
/>
)}
<Typography
variant={
isMobile
? "Body/Paragraph/mdRegular"
: "Body/Supporting text (caption)/smRegular"
}
>
<span className={styles.triggerText}>
{languages[currentLanguage]}
<MaterialIcon
className={styles.chevron}
icon="keyboard_arrow_down"
size={isMobile ? 24 : 20}
color={isMobile ? "Icon/Default" : "Icon/Inverted"}
/>
</span>
</Typography>
</Button>
{isMobile ? (
<ModalOverlay isDismissable className={styles.languageModalOverlay}>
<Modal className={styles.languageModal}>
<Dialog>
{({ close }) => (
<LanguageSwitcherContent
closeModal={close}
isMobile={true}
currentLanguage={currentLanguage}
/>
)}
</Dialog>
</Modal>
</ModalOverlay>
) : (
<Popover offset={28} className={styles.languageSwitcherPopover}>
<Dialog>
{({ close }) => (
<LanguageSwitcherContent
closeModal={close}
isMobile={false}
currentLanguage={currentLanguage}
/>
)}
</Dialog>
</Popover>
)}
</DialogTrigger>
</div>
)
}
function LanguageSwitcherContent({
closeModal,
currentLanguage,
isMobile,
}: {
closeModal: () => void
currentLanguage: Lang
isMobile?: boolean
}) {
const intl = useIntl()
const pathname = usePathname()
const urls: LanguageSwitcherData = {
da: { url: "/da/" },
de: { url: "/de/" },
en: { url: "/en/" },
fi: { url: "/fi/" },
no: { url: "/no/" },
sv: { url: "/sv/" },
}
const urlKeys = (Object.keys(urls) as (keyof typeof urls)[]).sort((a, b) => {
return languages[a].localeCompare(languages[b])
})
return (
<div className={styles.languageSwitcherContent}>
{isMobile ? (
<>
<Button
variant={"Text"}
size={"Medium"}
onPress={closeModal}
className={styles.arrowBack}
>
<MaterialIcon
icon="chevron_left"
size={28}
className={styles.arrowBackIcon}
color={"CurrentColor"}
/>
</Button>
<Typography variant={"Title/Subtitle/md"}>
<h3 className={styles.title}>
{intl.formatMessage({
defaultMessage: "Select your language",
})}
</h3>
</Typography>
</>
) : null}
<ul className={styles.languageSwitcherListContainer}>
{urlKeys.map((key) => {
const url = urls[key]?.url
const isActive = currentLanguage === key
if (url) {
return (
<li key={key} className={styles.languageSwitcherListItem}>
<Typography
variant={
isActive
? "Body/Paragraph/mdBold"
: "Body/Paragraph/mdRegular"
}
>
<Link
className={styles.link}
href={replaceUrlPart(pathname, url)}
variant="languageSwitcher"
keepSearchParams
>
{languages[key]}
{isActive ? (
<MaterialIcon
icon="check"
color="Icon/Interactive/Default"
/>
) : null}
</Link>
</Typography>
</li>
)
}
})}
</ul>
</div>
)
}

View File

@@ -0,0 +1,102 @@
.languageSwitcher {
.triggerButton {
gap: var(--Space-x1);
padding: var(--Space-x1);
justify-content: flex-start;
width: 100%;
border: 0 none;
}
.triggerText {
display: flex;
justify-content: space-between;
width: 100%;
color: var(--Text-sas-20);
}
}
.languageSwitcherContent {
background: white;
gap: var(--Space-x3);
padding: 0 var(--Space-x2);
flex-direction: column;
display: flex;
align-items: flex-start;
.arrowBack {
color: var(--TEMP-sas-40);
padding: var(--Space-x2) 0;
width: 100%;
justify-content: flex-start;
}
ul {
list-style: none;
width: 100%;
}
}
.languageSwitcherListItem .link {
padding: var(--Space-x1);
display: flex;
justify-content: space-between;
align-items: center;
border-radius: var(--Space-x1);
}
.languageModalOverlay {
position: fixed;
width: 100%;
height: 100%;
z-index: 1000;
}
.languageModal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
left: 0;
right: 0;
bottom: 0;
background: white;
z-index: 1001;
.closeModal {
position: fixed;
top: var(--Space-x2);
right: var(--Space-x2);
background-color: transparent;
color: transparent;
}
}
@media screen and (min-width: 768px) {
.languageSwitcher {
.triggerText {
color: white;
}
.triggerButton {
color: white;
padding: 0;
}
.triggerButton:hover {
text-decoration: none;
}
.triggerButton[aria-expanded="true"] .chevron {
transform: rotate(180deg);
}
}
.languageSwitcherContent {
min-width: 200px;
border-radius: var(--Space-x15);
padding: var(--Space-x2) var(--Space-x3);
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
}
.chevron {
transition: 0.3s;
}
}

View File

@@ -0,0 +1,49 @@
"use client"
import { useState } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { UserMenu } from "../UserMenu"
import styles from "./mobile-menu.module.css"
export function MobileMenu({ children }: React.PropsWithChildren) {
const intl = useIntl()
const closeMsg = intl.formatMessage({
defaultMessage: "Close menu",
})
const openMsg = intl.formatMessage({
defaultMessage: "Open menu",
})
const [isOpen, setIsOpen] = useState(false)
return (
<div className={styles.mobileMenu}>
<UserMenu isMobile={true} />
<Button
variant={"Text"}
type="button"
className={`${styles.hamburger} ${isOpen ? styles.isExpanded : ""}`}
aria-label={isOpen ? closeMsg : openMsg}
onPress={() => setIsOpen(!isOpen)}
>
<span className={styles.bar} />
</Button>
<Modal className={styles.modal} isOpen={isOpen}>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({
defaultMessage: "Menu",
})}
>
{children}
</Dialog>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,104 @@
.mobileMenu {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.mobileMenu .avatar {
background-color: white;
span {
color: var(--TEMP-sas-20);
}
}
.hamburger {
background-color: transparent;
border: none;
cursor: pointer;
justify-self: flex-start;
padding: 19px 8px 18px;
user-select: none;
}
.bar,
.bar::after,
.bar::before {
background: white;
border-radius: 2.3px;
display: 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));
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);
}
.dialog {
height: 100%;
overflow-y: auto;
display: flex;
align-content: space-between;
padding: var(--Space-x3) var(--Space-x2) var(--Space-x4);
flex-direction: column;
gap: var(--Space-x2);
}
.footer {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x4) var(--Space-x2);
display: grid;
gap: var(--Space-x2);
}
@media screen and (min-width: 768px) {
.avatar,
.hamburger,
.modal {
display: none;
}
}

View File

@@ -0,0 +1,48 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { LanguageSwitcher } from "@/components/LanguageSwitcher"
import useLang from "@/hooks/useLang"
import { UserMenu } from "../UserMenu"
import styles from "./navigation-menu.module.css"
export function NavigationMenu({ isMobile = false }: { isMobile?: boolean }) {
const intl = useIntl()
const lang = useLang()
return (
<div
className={`styles.menuItems ${isMobile ? styles.mobileMenu : styles.desktopMenu}`}
>
<Typography
variant={
isMobile
? "Body/Paragraph/mdRegular"
: "Body/Supporting text (caption)/smRegular"
}
>
<Link
href="#"
color={isMobile ? "none" : "white"}
className={`${styles.menuItem} ${styles.contactLink}`}
>
{isMobile ? null : (
<MaterialIcon icon="call" size={16} color={"CurrentColor"} />
)}
{intl.formatMessage({ defaultMessage: "Contact us" })}
</Link>
</Typography>
<LanguageSwitcher currentLanguage={lang} isMobile={isMobile} />
{!isMobile && <UserMenu isMobile={isMobile} />}
</div>
)
}

View File

@@ -0,0 +1,50 @@
.menuItems,
.menuItem {
display: flex;
direction: column;
align-items: center;
}
.desktopMenu {
display: none;
}
.menuItem {
gap: var(--Space-x1);
padding: var(--Space-x1);
}
.menuDivider {
margin: var(--Space-x2) 0;
}
.contactLink {
color: var(--Text-sas-20);
}
@media screen and (min-width: 768px) {
.menuItems,
.menuItem {
display: flex;
align-items: center;
padding: 0;
}
.menuItems {
gap: var(--Space-x3);
}
.mobileMenu {
display: none;
}
.desktopMenu {
display: flex;
gap: var(--Space-x3);
align-items: center;
}
.contactLink {
color: white;
}
}

View File

@@ -0,0 +1,201 @@
"use client"
import { useSession } from "next-auth/react"
import { useEffect, useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
Popover,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { Avatar } from "@scandic-hotels/design-system/Avatar"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "@/hooks/useLang"
import { getInitials } from "../utils"
import styles from "./user-menu.module.css"
export function UserMenu({ isMobile = false }: { isMobile?: boolean }) {
const intl = useIntl()
const lang = useLang()
const session = useSession()
const [loginLink, setLoginLink] = useState(`/${lang}/login`)
const {
data: profileData,
isLoading,
isSuccess,
isError,
} = trpc.partner.sas.getEuroBonusProfile.useQuery(undefined, {
enabled: session.status === "authenticated",
})
useEffect(() => {
setLoginLink(`/${lang}/login?redirectTo=${window?.location.href}`)
}, [lang, setLoginLink])
const firstName = profileData?.firstName
const lastName = profileData?.lastName
return (
<div className={styles.userMenu}>
{(session.status === "loading" || isLoading) &&
(isMobile ? (
<SkeletonShimmer width={"4ch"} height={"4ch"} />
) : (
<SkeletonShimmer width={"12ch"} height={"1ch"} />
))}
{(session.status === "unauthenticated" || isError) && (
<a href={loginLink} className={styles.loginLink}>
<Avatar className={styles.avatar} />
{isMobile ? null : (
<Typography
variant={
isMobile
? "Body/Paragraph/mdRegular"
: "Body/Supporting text (caption)/smRegular"
}
>
<span>{intl.formatMessage({ defaultMessage: "Login" })}</span>
</Typography>
)}
</a>
)}
{session.status === "authenticated" && isSuccess && profileData && (
<div className={styles.loggedInMenu}>
<DialogTrigger>
<Button className={styles.userName} variant={"Text"}>
<Avatar
className={styles.avatar}
initials={getInitials(
firstName ?? session.data?.user?.name ?? "",
lastName ?? session.data?.user?.name ?? ""
)}
/>
{isMobile ? null : (
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{firstName ?? session.data?.user?.email}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{` ${lastName}`}
</span>
</Typography>
)}
</Button>
{isMobile ? (
<ModalOverlay isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.dialog}>
{({ close }) => (
<>
<IconButton
className={styles.closeModal}
onPress={close}
>
<MaterialIcon
icon={"close"}
size={32}
color={"CurrentColor"}
/>
</IconButton>
<UserMenuContent
firstName={firstName}
lastName={lastName}
points={profileData.points.total}
isMobile={true}
/>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
) : (
<Popover className={styles.userDetailsPopover} offset={20}>
<Dialog className={styles.userDetailsContainer}>
<UserMenuContent
firstName={firstName}
lastName={lastName}
points={profileData.points.total}
/>
</Dialog>
</Popover>
)}
</DialogTrigger>
</div>
)}
</div>
)
}
function UserMenuContent({
firstName,
lastName,
points,
isMobile,
}: {
firstName?: string
lastName?: string
points?: number
isMobile?: boolean
}) {
const intl = useIntl()
const lang = useLang()
return (
<>
<div>
{isMobile && (
<Typography variant={"Title/Subtitle/md"}>
<h3>
{intl.formatMessage(
{ defaultMessage: `Hi {fName} {lName}!` },
{ fName: firstName, lName: lastName }
)}
</h3>
</Typography>
)}
<p className={styles.pointsDetails}>
<Typography variant="Title/Overline/sm">
<span>{intl.formatMessage({ defaultMessage: "EB Points" })}</span>
</Typography>
<Typography variant="Title/Overline/sm">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>{"·"}</span>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage(
{
defaultMessage: "{points} points",
},
{
points: points,
}
)}
</span>
</Typography>
</p>
</div>
<Divider className={styles.menuDivider} />
<Link
href={`/${lang}/logout`}
prefetch={false}
className={styles.logoutLink}
>
{intl.formatMessage({ defaultMessage: "Logout" })}
</Link>
</>
)
}

View File

@@ -0,0 +1,107 @@
.userMenu {
position: relative;
.userName {
display: flex;
align-items: center;
gap: var(--Space-x1);
cursor: pointer;
color: white;
padding: 0;
&:hover {
color: white;
text-decoration: none;
}
}
}
.userMenu .avatar {
background-color: white;
color: var(--TEMP-sas-default);
span {
color: currentColor;
}
}
.userDetailsContainer {
padding: var(--Space-x1);
color: var(--Text-sas-20);
}
.logoutLink,
.loginLink {
color: var(--Text-sas-20);
font-weight: 400;
text-decoration: none;
}
.modal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
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);
}
.dialog {
height: 100%;
overflow-y: auto;
display: flex;
align-content: space-between;
padding: var(--Space-x3) var(--Space-x2) var(--Space-x4);
flex-direction: column;
gap: var(--Space-x2);
.closeModal {
position: fixed;
top: var(--Space-x2);
right: var(--Space-x2);
background-color: var(--TEMP-sas-default);
padding: var(--Space-x05);
color: white;
&:hover,
&:hover:not(:disabled) {
background-color: var(--TEMP-sas-default);
}
}
}
.pointsDetails {
display: flex;
gap: var(--Space-x1);
align-items: center;
}
@media screen and (min-width: 768px) {
.userMenu {
padding: 0;
color: white;
}
.userDetailsContainer {
background-color: white;
border-radius: 12px;
padding: var(--Space-x2) var(--Space-x3);
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.loginLink {
color: white;
display: flex;
align-items: center;
gap: var(--Space-x1);
&:hover {
color: white;
}
}
}

View File

@@ -0,0 +1,45 @@
"use client"
import { useEffect, useState } from "react"
import { useMediaQuery } from "usehooks-ts"
import Image from "@scandic-hotels/design-system/Image"
import Link from "@scandic-hotels/design-system/Link"
import useLang from "@/hooks/useLang"
import { MobileMenu } from "./MobileMenu"
import { NavigationMenu } from "./NavigationMenu"
import styles from "./menu.module.css"
export function Menu() {
const lang = useLang()
const checkIfMobile = useMediaQuery("(max-width: 767px)")
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
setIsMobile(checkIfMobile)
}, [checkIfMobile])
return (
<div className={styles.container}>
<Link href={`/${lang}`}>
<Image
alt="SAS logotype"
className={styles.logo}
src="/_static/img/sas-logotype-white.svg"
height={32}
width={90}
sizes="100vw"
/>
</Link>
{isMobile ? (
<MobileMenu>
<NavigationMenu isMobile={true} />
</MobileMenu>
) : (
<NavigationMenu isMobile={false} />
)}
</div>
)
}

View File

@@ -0,0 +1,18 @@
.container {
background-color: var(--TEMP-sas-default);
color: white;
display: flex;
align-items: center;
padding: var(--Space-x2);
justify-content: space-between;
@media screen and (min-width: 768px) {
padding: 20px 40px;
}
}
.logo {
height: auto;
width: 90px;
display: block;
}

View File

@@ -0,0 +1,6 @@
export function getInitials(firstName: string, lastName: string) {
if (!firstName || !lastName) return null
const firstInitial = firstName.charAt(0).toUpperCase()
const lastInitial = lastName.charAt(0).toUpperCase()
return `${firstInitial}${lastInitial}`
}

View File

@@ -15,7 +15,7 @@
);
--sitewide-alert-height: 0px; /* Will be overridden when a sitewide alert is visible */
--main-menu-mobile-height: 73px;
--main-menu-mobile-height: 103px;
--main-menu-desktop-height: 125px;
--booking-widget-mobile-height: 75px;
--booking-widget-tablet-height: 150px;
@@ -41,6 +41,7 @@
--TEMP-sas-default: #000099;
--TEMP-sas-20: #00175c;
--TEMP-sas-40: #0030c2;
--Text-sas-20: #333;
@supports (interpolate-size: allow-keywords) {
interpolate-size: allow-keywords;

View File

@@ -31,9 +31,11 @@
"next": "15.3.4",
"next-auth": "5.0.0-beta.29",
"react": "^19.0.0",
"react-aria-components": "^1.8.0",
"react-dom": "^19.0.0",
"react-intl": "^7.1.11",
"server-only": "^0.0.1"
"server-only": "^0.0.1",
"usehooks-ts": "3.1.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",

View File

@@ -3,13 +3,12 @@
import { usePathname } from "next/navigation"
import { useIntl } from "react-intl"
import { languages } from "@scandic-hotels/common/constants/language"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { languages } from "@/constants/languages"
import useLang from "@/hooks/useLang"
import { replaceUrlPart } from "./utils"

View File

@@ -5,13 +5,13 @@ import { useRef } from "react"
import FocusLock from "react-focus-lock"
import { useIntl } from "react-intl"
import { languages } from "@scandic-hotels/common/constants/language"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackLanguageSwitchClick } from "@scandic-hotels/tracking/navigation"
import { trpc } from "@scandic-hotels/trpc/client"
import { languages } from "@/constants/languages"
import useDropdownStore from "@/stores/main-menu"
import useClickOutside from "@/hooks/useClickOutside"

View File

@@ -1,4 +1,4 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { Lang, languages } from "@scandic-hotels/common/constants/language"
import { profileEdit } from "@scandic-hotels/common/constants/routes/myPages"
import { isValidLang } from "@scandic-hotels/common/utils/languages"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
@@ -7,7 +7,6 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { countriesMap } from "@scandic-hotels/trpc/constants/countries"
import { languages } from "@/constants/languages"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import CommunicationSlot from "@/components/MyPages/Profile/Communication"

View File

@@ -1,14 +1,6 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { ApiLang } from "@scandic-hotels/trpc/constants/apiLang"
export const languages: Record<Lang, string> = {
[Lang.da]: "Dansk",
[Lang.de]: "Deutsch",
[Lang.en]: "English",
[Lang.fi]: "Suomi",
[Lang.no]: "Norsk",
[Lang.sv]: "Svenska",
}
import type { Lang } from "@scandic-hotels/common/constants/language"
const languageSelect = [
{ label: "Danish", value: ApiLang.Da },

View File

@@ -1,4 +1,4 @@
import type { LanguageSwitcherData } from "@scandic-hotels/trpc/types/languageSwitcher"
import type { LanguageSwitcherData } from "@scandic-hotels/common/constants/language"
import type { ReactElement } from "react"
export enum LanguageSwitcherTypesEnum {

View File

@@ -6,3 +6,26 @@ export enum Lang {
no = "no",
sv = "sv",
}
export const languages: Record<Lang, string> = {
[Lang.da]: "Dansk",
[Lang.de]: "Deutsch",
[Lang.en]: "English",
[Lang.fi]: "Suomi",
[Lang.no]: "Norsk",
[Lang.sv]: "Svenska",
}
type LanguageResult = {
url: string
isExternal?: boolean
}
export type LanguageSwitcherData = {
da?: LanguageResult
de?: LanguageResult
en?: LanguageResult
fi?: LanguageResult
no?: LanguageResult
sv?: LanguageResult
}

View File

@@ -5,7 +5,7 @@ import { getNonContentstackUrls } from "../metadata/output"
import { getLanguageSwitcherInput } from "./input"
import { getUrlsOfAllLanguages } from "./utils"
import type { LanguageSwitcherData } from "../../../types/languageSwitcher"
import type { LanguageSwitcherData } from "@scandic-hotels/common/constants/language"
export const languageSwitcherQueryRouter = router({
get: publicProcedure

View File

@@ -1,4 +1,7 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import {
Lang,
type LanguageSwitcherData,
} from "@scandic-hotels/common/constants/language"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { removeTrailingSlash } from "@scandic-hotels/common/utils/url"
@@ -57,10 +60,7 @@ import {
import { generateTag } from "../../../utils/generateTag"
import { validateLanguageSwitcherData } from "./output"
import type {
LanguageSwitcherData,
LanguageSwitcherQueryDataRaw,
} from "../../../types/languageSwitcher"
import type { LanguageSwitcherQueryDataRaw } from "../../../types/languageSwitcher"
export const languageSwitcherAffix = "languageSwitcher"

View File

@@ -12,11 +12,12 @@ import { RTETypeEnum } from "../../../types/RTEenums"
import { destinationFilterSchema } from "../schemas/destinationFilters"
import { systemSchema } from "../schemas/system"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
Lang,
LanguageSwitcherData,
} from "@scandic-hotels/common/constants/language"
import type { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
import type { LanguageSwitcherData } from "../../../types/languageSwitcher"
const metaDataJsonSchema = z.object({
children: z.array(
z.object({

View File

@@ -26,9 +26,9 @@ import { getMetadataInput } from "./input"
import { getNonContentstackUrls, rawMetadataSchema } from "./output"
import { affix, getCityData, getCountryData } from "./utils"
import type { LanguageSwitcherData } from "@scandic-hotels/common/constants/language"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { LanguageSwitcherData } from "../../../types/languageSwitcher"
import type { RawMetadataSchema } from "./output"
const fetchMetadata = cache(async function fetchMemoizedMetadata<T>(

View File

@@ -1,17 +1,3 @@
type CurrentLanguageResult = {
url: string
isExternal?: boolean
}
export type LanguageSwitcherData = {
da?: CurrentLanguageResult
de?: CurrentLanguageResult
en?: CurrentLanguageResult
fi?: CurrentLanguageResult
no?: CurrentLanguageResult
sv?: CurrentLanguageResult
}
type LanguageResult = {
web?: {
original_url?: string | null

View File

@@ -6054,10 +6054,12 @@ __metadata:
next: "npm:15.3.4"
next-auth: "npm:5.0.0-beta.29"
react: "npm:^19.0.0"
react-aria-components: "npm:^1.8.0"
react-dom: "npm:^19.0.0"
react-intl: "npm:^7.1.11"
server-only: "npm:^0.0.1"
typescript: "npm:5.8.3"
usehooks-ts: "npm:3.1.1"
vitest: "npm:^3.2.4"
languageName: unknown
linkType: soft