Feat/BOOK-63 hotel subpages branding

* feat(BOOK-63): Replaced css variables and components to apply hotel branding on subpages
* feat(BOOK-63): Replaced css variables and components to apply hotel branding on hotel page map view

Approved-by: Christel Westerberg
Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-11-05 08:30:55 +00:00
parent 7fc49428c7
commit 3a38e99a71
47 changed files with 524 additions and 393 deletions

View File

@@ -64,7 +64,8 @@
"./utils/promiseWithTimeout": "./utils/promiseWithTimeout.ts",
"./utils/rangeArray": "./utils/rangeArray.ts",
"./utils/safeTry": "./utils/safeTry.ts",
"./utils/theme": "./utils/theme.ts",
"./utils/theme": "./utils/theme/index.ts",
"./utils/theme/serverContext": "./utils/theme/serverContext.ts",
"./utils/toCapitalCase": "./utils/toCapitalCase.ts",
"./utils/url": "./utils/url.ts",
"./utils/zod/*": "./utils/zod/*.ts"

View File

@@ -0,0 +1,31 @@
import "server-only"
import { cache } from "react"
import { DEFAULT_THEME, type Theme } from "."
const getRef = cache(() => ({ current: DEFAULT_THEME as Theme }))
/**
* Set the global theme
*
* It works kind of like React's context,
* but on the server side, per request.
*
* @param newTheme
*/
export function setTheme(newTheme: Theme) {
getRef().current = newTheme
return newTheme
}
/**
* Get the global theme
*
* Note: This must be called after setTheme() has been called in the page/layout.
* If called before setTheme(), it will return DEFAULT_THEME.
*/
export function getTheme(): Theme {
return getRef().current ?? DEFAULT_THEME
}

View File

@@ -27,12 +27,12 @@
}
.Outlined:active {
border-color: var(--Border-Interactive-Selected);
border-color: var(--Border-Interactive-Active);
}
.FilterRounded {
background-color: transparent;
border: 1px solid var(--Border-Interactive-Selected);
border: 1px solid var(--Border-Interactive-Active);
border-radius: var(--Corner-radius-rounded);
padding: var(--Space-x025) var(--Space-x2);
color: var(--Text-Default);

View File

@@ -29,7 +29,7 @@
}
.label:has(:checked) {
border: 2px solid var(--Border-Interactive-Selected);
border: 2px solid var(--Border-Interactive-Active);
}
.label:not(:has(:checked)) .selectedIcon {

View File

@@ -4,7 +4,7 @@ import NextImage, { ImageProps as NextImageProps } from 'next/image'
import ImageFallback from '../ImageFallback'
import type { CSSProperties } from 'react'
import { useState, type CSSProperties, type SyntheticEvent } from 'react'
import { imageLoader } from './imageLoader'
type FocalPoint = {
@@ -22,8 +22,11 @@ export default function Image({
focalPoint,
dimensions,
style,
src,
onError,
...props
}: ImageProps) {
const [imageError, setImageError] = useState(false)
const styles: CSSProperties = focalPoint
? {
objectFit: 'cover',
@@ -32,14 +35,24 @@ export default function Image({
}
: { ...style }
if (!props.src) {
function handleError(error: SyntheticEvent<HTMLImageElement, Event>) {
if (onError) {
onError(error)
} else {
setImageError(true)
}
}
if (!src || imageError) {
return <ImageFallback />
}
return (
<NextImage
{...props}
src={src}
style={styles}
onError={handleError}
loader={imageLoader({ dimensions, focalPoint })}
/>
)

View File

@@ -87,19 +87,26 @@
.li:has(.heart)::before,
.check > .li::before,
.li:has(.check)::before {
content: '';
position: relative;
height: 8px;
top: 3px;
width: 16px;
height: 16px;
display: inline-flex;
flex-shrink: 0;
background-color: var(--Icon-Accent);
mask-size: contain;
mask-repeat: no-repeat;
}
.check > .li::before,
.li:has(.check)::before {
content: url('/_static/icons/check-ring.svg');
mask-image: url('/_static/icons/check_circle.svg');
}
.heart > .li::before,
.li:has(.heart)::before {
content: url('/_static/icons/heart.svg');
mask-image: url('/_static/icons/heart.svg');
}
.li > * {

View File

@@ -4,77 +4,63 @@
height: 100%;
position: relative;
z-index: 0;
}
.mapContainer :global(.gm-style .gm-style-iw-d) {
padding: 0 !important;
overflow: hidden !important;
max-height: none !important;
max-width: none !important;
}
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
background: linear-gradient(
43deg,
rgba(172, 172, 172, 0) 57.66%,
rgba(0, 0, 0, 0.25) 92.45%
);
width: 100%;
height: 100%;
pointer-events: none;
}
.mapContainer :global(.gm-style .gm-style-iw-c) {
padding: 0 !important;
overflow: hidden !important;
max-height: none !important;
max-width: none !important;
}
.mapContainer::after {
content: '';
position: absolute;
top: 0;
right: 0;
background: linear-gradient(
43deg,
rgba(172, 172, 172, 0) 57.66%,
rgba(0, 0, 0, 0.25) 92.45%
);
width: 100%;
height: 100%;
pointer-events: none;
:global(.gm-style .gm-style-iw-d),
:global(.gm-style .gm-style-iw-c) {
padding: 0 !important;
overflow: hidden !important;
max-height: none !important;
max-width: none !important;
}
}
.ctaButtons {
position: absolute;
top: var(--Spacing-x2);
right: var(--Spacing-x2);
z-index: 1;
top: var(--Space-x2);
right: var(--Space-x2);
display: flex;
gap: var(--Space-x7);
flex-direction: column;
gap: var(--Spacing-x7);
align-items: flex-end;
pointer-events: none;
z-index: 1;
}
.zoomButtons {
display: grid;
gap: var(--Spacing-x2);
}
.closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
display: flex;
gap: var(--Space-x2);
}
.zoomButton {
width: var(--Space-x5);
height: var(--Space-x5);
padding: 0;
pointer-events: initial;
box-shadow: var(--button-box-shadow);
}
@media screen and (max-width: 767px) {
.zoomButtons {
flex-direction: column;
}
}
@media screen and (min-width: 768px) {
.ctaButtons {
top: var(--Spacing-x4);
right: var(--Spacing-x5);
bottom: var(--Spacing-x7);
top: var(--Space-x4);
right: var(--Space-x5);
bottom: var(--Space-x7);
justify-content: space-between;
}
.zoomButtons {
display: flex;
}
}

View File

@@ -1,19 +1,14 @@
import { IconByIconName } from '../../../Icons/IconByIconName'
import { getIconByPoiGroupAndCategory } from '../utils'
import {
getIconByPoiGroupAndCategory,
type PointOfInterestGroup,
} from '../utils'
import { poiVariants } from './variants'
import { VariantProps } from 'class-variance-authority'
import type { VariantProps } from 'class-variance-authority'
export type PointOfInterestGroup =
| 'Public transport'
| 'Attractions'
| 'Business'
| 'Location'
| 'Parking'
| 'Shopping & Dining'
export interface PoiMarkerProps extends VariantProps<typeof poiVariants> {
interface PoiMarkerProps extends VariantProps<typeof poiVariants> {
group: PointOfInterestGroup
categoryName?: string
className?: string
@@ -33,7 +28,7 @@ export function PoiMarker({
<span className={classNames}>
<IconByIconName
iconName={iconName}
color={skipBackground ? 'Icon/Feedback/Neutral' : 'Icon/Inverted'}
color={skipBackground ? 'CurrentColor' : 'Icon/Inverted'}
size={size === 'small' ? 16 : size === 'large' ? 24 : 20}
/>
</span>

View File

@@ -1,9 +1,37 @@
.icon {
.poiMarker {
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--Corner-radius-rounded);
background-color: var(--Surface-UI-Fill-Default);
background-color: var(--Surface-Feedback-Neutral);
> span {
display: inline-flex;
}
&.skipBackground {
background-color: transparent;
padding: 0;
}
}
.shoppingDining {
background-color: var(--Surface-Accent-1);
}
.publicTransport {
background-color: var(--Surface-Accent-2);
}
.attractions {
background-color: var(--Surface-Accent-3);
}
.business {
background-color: var(--Surface-Accent-4);
}
.parking {
background-color: var(--Surface-Accent-5);
}
.location {
background-color: var(--Surface-Feedback-Neutral);
}
.small {
@@ -14,27 +42,3 @@
width: var(--Space-x4);
height: var(--Space-x4);
}
.attractions {
background-color: var(--Surface-Accent-3);
}
.business {
background-color: var(--Surface-Accent-4);
}
.location {
background-color: var(--Surface-Feedback-Neutral);
}
.parking {
background-color: var(--Surface-Accent-5);
}
.publicTransport {
background-color: var(--Surface-Accent-2);
}
.shoppingDining {
background-color: var(--Surface-Accent-1);
}
.icon.transparent {
background-color: transparent;
padding: 0;
}

View File

@@ -1,9 +1,9 @@
import { cva } from 'class-variance-authority'
import styles from './poi.module.css'
import { PointOfInterestGroup } from '.'
import type { PointOfInterestGroup } from '../utils'
import styles from './poiMarker.module.css'
export const poiVariants = cva(styles.icon, {
export const poiVariants = cva(styles.poiMarker, {
variants: {
group: {
['Attractions']: styles.attractions,
@@ -14,7 +14,7 @@ export const poiVariants = cva(styles.icon, {
['Shopping & Dining']: styles.shoppingDining,
} satisfies Record<PointOfInterestGroup, string>,
skipBackground: {
true: styles.transparent,
true: styles.skipBackground,
false: '',
},
size: {

View File

@@ -1,5 +1,12 @@
import { IconName } from '../../Icons/iconName'
import { PointOfInterestGroup } from './PoiMarker'
export type PointOfInterestGroup =
| 'Public transport'
| 'Attractions'
| 'Business'
| 'Location'
| 'Parking'
| 'Shopping & Dining'
export function getIconByPoiGroupAndCategory(
group: PointOfInterestGroup,

View File

@@ -37,9 +37,9 @@ export default function ParkingList({
return (
<Typography variant="Body/Paragraph/mdRegular">
<ul className={styles.listStyling}>
<ul className={styles.list}>
{numberOfChargingSpaces ? (
<li>
<li className={styles.listItem}>
{intl.formatMessage(
{
id: 'parkingInformation.numberOfChargingPoints',
@@ -50,13 +50,13 @@ export default function ParkingList({
)}
</li>
) : null}
<li>
<li className={styles.listItem}>
{canMakeReservation
? canMakeReservationYesMsg
: canMakeReservationNoMsg}
</li>
{numberOfParkingSpots ? (
<li>
<li className={styles.listItem}>
{intl.formatMessage(
{
id: 'parkingInformation.numberOfParkingSpots',
@@ -67,7 +67,7 @@ export default function ParkingList({
</li>
) : null}
{distanceToHotel ? (
<li>
<li className={styles.listItem}>
{intl.formatMessage(
{
id: 'parkingInformation.distanceToHotel',
@@ -78,7 +78,7 @@ export default function ParkingList({
</li>
) : null}
{address ? (
<li>
<li className={styles.listItem}>
{intl.formatMessage(
{
id: 'parkingInformation.address',

View File

@@ -1,11 +1,24 @@
.listStyling {
.list {
display: grid;
gap: var(--Space-x1);
list-style-type: none;
}
.listStyling > li::before {
content: url('/_static/icons/heart.svg');
position: relative;
height: 8px;
top: 3px;
margin-right: var(--Spacing-x1);
.listItem {
display: flex;
gap: var(--Space-x1);
&::before {
content: '';
position: relative;
top: 3px;
display: inline-flex;
flex-shrink: 0;
width: 16px;
height: 16px;
background-color: var(--Icon-Accent);
mask-image: url('/_static/icons/heart.svg');
mask-size: contain;
mask-repeat: no-repeat;
}
}

View File

@@ -2,10 +2,10 @@
import { useIntl } from 'react-intl'
import ButtonLink from '../ButtonLink'
import { Divider } from '../Divider'
import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography'
import ButtonLink from '../ButtonLink'
import ParkingList from './ParkingList'
import ParkingPrices from './ParkingPrices'
@@ -88,6 +88,7 @@ export default function ParkingInformation({
{parking.externalParkingUrl && showExternalParkingButton && (
<ButtonLink
typography="Body/Paragraph/mdBold"
size="Medium"
href={parking.externalParkingUrl}
target="_blank"
>

View File

@@ -1,20 +1,20 @@
.parkingInformation {
display: grid;
gap: var(--Spacing-x3);
gap: var(--Space-x3);
}
.list,
.prices {
display: grid;
gap: var(--Spacing-x-one-and-half);
gap: var(--Space-x15);
}
.priceWrapper {
background-color: var(--Base-Surface-Subtle-Normal);
background-color: var(--Surface-Secondary-Default);
border-radius: var(--Corner-radius-md);
padding: var(--Spacing-x2) var(--Spacing-x3);
padding: var(--Space-x2) var(--Space-x3);
display: grid;
gap: var(--Spacing-x1);
gap: var(--Space-x1);
}
.heading {

View File

@@ -19,7 +19,6 @@
.theme-primary:not(.disabled) {
color: var(--Text-Interactive-Secondary);
text-decoration: underline;
&:hover {
color: var(--Text-Interactive-Secondary-Hover);
@@ -28,7 +27,6 @@
.theme-inverted:not(.disabled) {
color: var(--Text-Inverted);
text-decoration: underline;
&:hover {
opacity: 0.7;