feat(SW-1968): Alternate opening hours for restaurants

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-03-26 08:04:37 +00:00
parent adf81bf5c9
commit 4ff44311a9
11 changed files with 554 additions and 87 deletions

View File

@@ -0,0 +1,56 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getIntl } from "@/i18n"
import { getGroupedOpeningHours } from "../utils"
import styles from "../openingHours.module.css"
import type { RestaurantOpeningHours } from "@/types/hotel"
interface AlternateOpeningHoursProps {
alternateOpeningHours: RestaurantOpeningHours
}
export default async function AlternateOpeningHours({
alternateOpeningHours,
}: AlternateOpeningHoursProps) {
const intl = await getIntl()
const groupedAlternateOpeningHours = alternateOpeningHours
? getGroupedOpeningHours(alternateOpeningHours, intl)
: null
// If there are alternate hours but no grouped hours with length, we return the name of the alternate hours
if (!groupedAlternateOpeningHours?.length) {
return (
<Typography
variant="Body/Supporting text (caption)/smRegular"
className={styles.caption}
>
<p>{alternateOpeningHours.name}</p>
</Typography>
)
}
return (
<>
<Typography variant="Body/Paragraph/mdBold" className={styles.text}>
<h5>
{intl.formatMessage(
{ id: "Alternate opening hours ({name})" },
{ name: alternateOpeningHours.name }
)}
</h5>
</Typography>
{groupedAlternateOpeningHours.map((groupedOpeningHour) => (
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.text}
key={groupedOpeningHour}
>
<p>{groupedOpeningHour}</p>
</Typography>
))}
</>
)
}

View File

@@ -2,6 +2,9 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import AlternateOpeningHours from "./AlternateOpeningHours"
import { getGroupedOpeningHours } from "./utils"
import styles from "./openingHours.module.css" import styles from "./openingHours.module.css"
import type { OpeningHoursProps } from "@/types/components/hotelPage/sidepeek/openingHours" import type { OpeningHoursProps } from "@/types/components/hotelPage/sidepeek/openingHours"
@@ -14,84 +17,7 @@ export default async function OpeningHours({
}: OpeningHoursProps) { }: OpeningHoursProps) {
const intl = await getIntl() const intl = await getIntl()
const closedMsg = intl.formatMessage({ id: "Closed" }) const groupedOpeningHours = getGroupedOpeningHours(openingHours, intl)
const alwaysOpenMsg = intl.formatMessage({ id: "Always open" })
// In order
const weekdayDefinitions = [
{
key: "monday",
label: intl.formatMessage({ id: "Monday" }),
},
{
key: "tuesday",
label: intl.formatMessage({ id: "Tuesday" }),
},
{
key: "wednesday",
label: intl.formatMessage({ id: "Wednesday" }),
},
{
key: "thursday",
label: intl.formatMessage({ id: "Thursday" }),
},
{
key: "friday",
label: intl.formatMessage({ id: "Friday" }),
},
{
key: "saturday",
label: intl.formatMessage({ id: "Saturday" }),
},
{
key: "sunday",
label: intl.formatMessage({ id: "Sunday" }),
},
] as const
const groupedOpeningHours: string[] = []
let rangeWeekdays: string[] = []
let rangeValue = ""
for (let i = 0, n = weekdayDefinitions.length; i < n; ++i) {
const weekdayDefinition = weekdayDefinitions[i]
const weekday = openingHours[weekdayDefinition.key]
const label = weekdayDefinition.label
if (weekday) {
let newValue = null
if (weekday.alwaysOpen) {
newValue = alwaysOpenMsg
} else if (weekday.isClosed) {
newValue = closedMsg
} else if (weekday.openingTime && weekday.closingTime) {
newValue = `${weekday.openingTime}-${weekday.closingTime}`
}
if (newValue !== null) {
if (rangeValue === newValue) {
if (rangeWeekdays.length > 1) {
rangeWeekdays.splice(-1, 1, label) // Replace last element
} else {
rangeWeekdays.push(label)
}
} else {
if (rangeValue) {
groupedOpeningHours.push(
`${rangeWeekdays.join("-")}: ${rangeValue}`
)
}
rangeValue = newValue
rangeWeekdays = [label]
}
}
if (rangeValue && i === n - 1) {
// Flush everything at the end
groupedOpeningHours.push(`${rangeWeekdays.join("-")}: ${rangeValue}`)
}
}
}
return ( return (
<div className={type === "default" ? styles.wrapper : ""}> <div className={type === "default" ? styles.wrapper : ""}>
@@ -109,13 +35,8 @@ export default async function OpeningHours({
</Typography> </Typography>
) )
})} })}
{alternateOpeningHours?.name ? ( {alternateOpeningHours ? (
<Typography <AlternateOpeningHours alternateOpeningHours={alternateOpeningHours} />
variant="Body/Supporting text (caption)/smRegular"
className={styles.caption}
>
<p>{alternateOpeningHours.name}</p>
</Typography>
) : null} ) : null}
</div> </div>
) )

View File

@@ -0,0 +1,394 @@
import { describe, expect, it } from "@jest/globals"
import { getGroupedOpeningHours } from "./utils"
import type { IntlShape } from "react-intl"
import type { RestaurantOpeningHours } from "@/types/hotel"
// Mock IntlShape for testing
const mockIntl = {
formatMessage: ({ id }: { id: string }) => {
const messages: Record<string, string> = {
Monday: "Monday",
Tuesday: "Tuesday",
Wednesday: "Wednesday",
Thursday: "Thursday",
Friday: "Friday",
Saturday: "Saturday",
Sunday: "Sunday",
Closed: "Closed",
"Always open": "Always open",
}
return messages[id] || id
},
} as IntlShape
describe("getGroupedOpeningHours", () => {
it("should group all days as closed", () => {
const allDaysClosed: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 1,
},
tuesday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 2,
},
wednesday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 3,
},
thursday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 4,
},
friday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 5,
},
saturday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 6,
},
sunday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 7,
},
}
const result = getGroupedOpeningHours(allDaysClosed, mockIntl)
expect(result).toEqual(["Monday-Sunday: Closed"])
})
it("should group all days with same opening hours", () => {
const allDaysSameHours: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 1,
},
tuesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 2,
},
wednesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 3,
},
thursday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 4,
},
friday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 5,
},
saturday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 6,
},
sunday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 7,
},
}
const result = getGroupedOpeningHours(allDaysSameHours, mockIntl)
expect(result).toEqual(["Monday-Sunday: 09:00-17:00"])
})
it("should handle mixed opening hours", () => {
const mixedOpeningHours: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 1,
},
tuesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 2,
},
wednesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 3,
},
thursday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 4,
},
friday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 5,
},
saturday: {
openingTime: "10:00",
closingTime: "15:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 6,
},
sunday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 7,
},
}
const result = getGroupedOpeningHours(mixedOpeningHours, mockIntl)
expect(result).toEqual([
"Monday-Friday: 09:00-17:00",
"Saturday: 10:00-15:00",
"Sunday: Closed",
])
})
it("should handle always open days", () => {
const someAlwaysOpen: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
alwaysOpen: true,
isClosed: false,
openingTime: "",
closingTime: "",
sortOrder: 1,
},
tuesday: {
alwaysOpen: true,
isClosed: false,
openingTime: "",
closingTime: "",
sortOrder: 2,
},
wednesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 3,
},
thursday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 4,
},
friday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 5,
},
saturday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 6,
},
sunday: {
isClosed: true,
alwaysOpen: false,
openingTime: "",
closingTime: "",
sortOrder: 7,
},
}
const result = getGroupedOpeningHours(someAlwaysOpen, mockIntl)
expect(result).toEqual([
"Monday-Tuesday: Always open",
"Wednesday-Friday: 09:00-17:00",
"Saturday-Sunday: Closed",
])
})
it("should handle missing days", () => {
const missingDays: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 1,
},
wednesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 3,
},
friday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 5,
},
}
const result = getGroupedOpeningHours(missingDays, mockIntl)
expect(result).toEqual([
"Monday: 09:00-17:00",
"Wednesday: 09:00-17:00",
"Friday: 09:00-17:00",
])
})
it("should not group non-consecutive days with same hours", () => {
const nonConsecutiveSameHours: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 1,
},
tuesday: {
openingTime: "10:00",
closingTime: "18:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 2,
},
wednesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 3,
},
}
const result = getGroupedOpeningHours(nonConsecutiveSameHours, mockIntl)
expect(result).toEqual([
"Monday: 09:00-17:00",
"Tuesday: 10:00-18:00",
"Wednesday: 09:00-17:00",
])
})
it("should handle nullable opening/closing times", () => {
const nullableHours: RestaurantOpeningHours = {
isActive: true,
name: "Opening hours",
monday: {
openingTime: "",
closingTime: "",
isClosed: true,
alwaysOpen: false,
sortOrder: 1,
},
tuesday: {
openingTime: "09:00",
closingTime: "",
isClosed: false,
alwaysOpen: false,
sortOrder: 2,
},
wednesday: {
openingTime: "",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 3,
},
}
const result = getGroupedOpeningHours(nullableHours, mockIntl)
expect(result).toEqual([
"Monday: Closed",
// Tuesday and Wednesday won't appear in the result because they have empty string values
// that don't match any of the conditions in the getGroupedOpeningHours function
])
})
it("should handle inactive restaurant hours", () => {
const inactiveHours: RestaurantOpeningHours = {
isActive: false,
name: "Opening hours",
monday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 1,
},
tuesday: {
openingTime: "09:00",
closingTime: "17:00",
isClosed: false,
alwaysOpen: false,
sortOrder: 2,
},
}
// Even though isActive is false, the function should still process the hours
// as it doesn't check for the isActive flag
const result = getGroupedOpeningHours(inactiveHours, mockIntl)
expect(result).toEqual(["Monday-Tuesday: 09:00-17:00"])
})
})

View File

@@ -0,0 +1,88 @@
import type { IntlShape } from "react-intl"
import type { RestaurantOpeningHours } from "@/types/hotel"
export function getGroupedOpeningHours(
openingHours: RestaurantOpeningHours,
intl: IntlShape
): string[] {
const closedMsg = intl.formatMessage({ id: "Closed" })
const alwaysOpenMsg = intl.formatMessage({ id: "Always open" })
// In order
const weekdayDefinitions = [
{
key: "monday",
label: intl.formatMessage({ id: "Monday" }),
},
{
key: "tuesday",
label: intl.formatMessage({ id: "Tuesday" }),
},
{
key: "wednesday",
label: intl.formatMessage({ id: "Wednesday" }),
},
{
key: "thursday",
label: intl.formatMessage({ id: "Thursday" }),
},
{
key: "friday",
label: intl.formatMessage({ id: "Friday" }),
},
{
key: "saturday",
label: intl.formatMessage({ id: "Saturday" }),
},
{
key: "sunday",
label: intl.formatMessage({ id: "Sunday" }),
},
] as const
const groupedOpeningHours: string[] = []
let rangeWeekdays: string[] = []
let rangeValue = ""
for (let i = 0, n = weekdayDefinitions.length; i < n; ++i) {
const weekdayDefinition = weekdayDefinitions[i]
const weekday = openingHours[weekdayDefinition.key]
const label = weekdayDefinition.label
if (weekday) {
let newValue = null
if (weekday.alwaysOpen) {
newValue = alwaysOpenMsg
} else if (weekday.isClosed) {
newValue = closedMsg
} else if (weekday.openingTime && weekday.closingTime) {
newValue = `${weekday.openingTime}-${weekday.closingTime}`
}
if (newValue !== null) {
if (rangeValue === newValue) {
if (rangeWeekdays.length > 1) {
rangeWeekdays.splice(-1, 1, label) // Replace last element
} else {
rangeWeekdays.push(label)
}
} else {
if (rangeValue) {
groupedOpeningHours.push(
`${rangeWeekdays.join("-")}: ${rangeValue}`
)
}
rangeValue = newValue
rangeWeekdays = [label]
}
}
if (rangeValue && i === n - 1) {
// Flush everything at the end
groupedOpeningHours.push(`${rangeWeekdays.join("-")}: ${rangeValue}`)
}
}
}
return groupedOpeningHours
}

View File

@@ -49,6 +49,7 @@
"All-day breakfast": "Morgenmad hele dagen", "All-day breakfast": "Morgenmad hele dagen",
"Allergy-friendly room": "Allergirum", "Allergy-friendly room": "Allergirum",
"Already a friend?": "Allerede en ven?", "Already a friend?": "Allerede en ven?",
"Alternate opening hours ({name})": "Alternate opening hours ({name})",
"Alternatives for {value}": "Alternatives for {value}", "Alternatives for {value}": "Alternatives for {value}",
"Always open": "Altid åben", "Always open": "Altid åben",
"Amenities": "Faciliteter", "Amenities": "Faciliteter",

View File

@@ -49,6 +49,7 @@
"All-day breakfast": "Ganztag-Frühstück", "All-day breakfast": "Ganztag-Frühstück",
"Allergy-friendly room": "Allergikerzimmer", "Allergy-friendly room": "Allergikerzimmer",
"Already a friend?": "Sind wir schon Freunde?", "Already a friend?": "Sind wir schon Freunde?",
"Alternate opening hours ({name})": "Alternate opening hours ({name})",
"Alternatives for {value}": "Alternatives for {value}", "Alternatives for {value}": "Alternatives for {value}",
"Always open": "Immer geöffnet", "Always open": "Immer geöffnet",
"Amenities": "Annehmlichkeiten", "Amenities": "Annehmlichkeiten",

View File

@@ -49,6 +49,7 @@
"All-day breakfast": "All-day breakfast", "All-day breakfast": "All-day breakfast",
"Allergy-friendly room": "Allergy-friendly room", "Allergy-friendly room": "Allergy-friendly room",
"Already a friend?": "Already a friend?", "Already a friend?": "Already a friend?",
"Alternate opening hours ({name})": "Alternate opening hours ({name})",
"Alternatives for {value}": "Alternatives for {value}", "Alternatives for {value}": "Alternatives for {value}",
"Always open": "Always open", "Always open": "Always open",
"Amenities": "Amenities", "Amenities": "Amenities",

View File

@@ -49,6 +49,7 @@
"All-day breakfast": "Koko päivän aamiainen", "All-day breakfast": "Koko päivän aamiainen",
"Allergy-friendly room": "Allergiahuone", "Allergy-friendly room": "Allergiahuone",
"Already a friend?": "Oletko jo ystävä?", "Already a friend?": "Oletko jo ystävä?",
"Alternate opening hours ({name})": "Vaihtoehtoiset aukioloajat ({name})",
"Alternatives for {value}": "Alternatives for {value}", "Alternatives for {value}": "Alternatives for {value}",
"Always open": "Aina auki", "Always open": "Aina auki",
"Amenities": "Mukavuudet", "Amenities": "Mukavuudet",

View File

@@ -49,6 +49,7 @@
"All-day breakfast": "Frokost hele dagen", "All-day breakfast": "Frokost hele dagen",
"Allergy-friendly room": "Allergirom", "Allergy-friendly room": "Allergirom",
"Already a friend?": "Allerede Friend?", "Already a friend?": "Allerede Friend?",
"Alternate opening hours ({name})": "Alternativ åpningstider ({name})",
"Alternatives for {value}": "Alternatives for {value}", "Alternatives for {value}": "Alternatives for {value}",
"Always open": "Alltid åpen", "Always open": "Alltid åpen",
"Amenities": "Fasiliteter", "Amenities": "Fasiliteter",

View File

@@ -49,6 +49,7 @@
"All-day breakfast": "Frukost hela dagen", "All-day breakfast": "Frukost hela dagen",
"Allergy-friendly room": "Allergirum", "Allergy-friendly room": "Allergirum",
"Already a friend?": "Är du redan en vän?", "Already a friend?": "Är du redan en vän?",
"Alternate opening hours ({name})": "Alternativa öppettider ({name})",
"Alternatives for {value}": "Alternatives for {value}", "Alternatives for {value}": "Alternatives for {value}",
"Always open": "Alltid öppet", "Always open": "Alltid öppet",
"Amenities": "Bekvämligheter", "Amenities": "Bekvämligheter",

View File

@@ -4,7 +4,7 @@ import { variants } from './variants'
import type { TypographyProps } from './types' import type { TypographyProps } from './types'
export function Typography({ variant, children }: TypographyProps) { export function Typography({ variant, className, children }: TypographyProps) {
if (!isValidElement(children)) return null if (!isValidElement(children)) return null
const classNames = variants({ const classNames = variants({
@@ -13,6 +13,8 @@ export function Typography({ variant, children }: TypographyProps) {
return cloneElement(children, { return cloneElement(children, {
...children.props, ...children.props,
className: [children.props.className, classNames].filter(Boolean).join(' '), className: [className, children.props.className, classNames]
.filter(Boolean)
.join(' '),
}) })
} }