From 4ff44311a9520da59c9de921135264af890771f6 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 26 Mar 2025 08:04:37 +0000 Subject: [PATCH] feat(SW-1968): Alternate opening hours for restaurants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved-by: Matilda Landström --- .../AlternateOpeningHours/index.tsx | 56 +++ .../components/OpeningHours/index.tsx | 91 +--- .../OpeningHours/openingHours.test.ts | 394 ++++++++++++++++++ .../components/OpeningHours/utils.ts | 88 ++++ apps/scandic-web/i18n/dictionaries/da.json | 1 + apps/scandic-web/i18n/dictionaries/de.json | 1 + apps/scandic-web/i18n/dictionaries/en.json | 1 + apps/scandic-web/i18n/dictionaries/fi.json | 1 + apps/scandic-web/i18n/dictionaries/no.json | 1 + apps/scandic-web/i18n/dictionaries/sv.json | 1 + .../lib/components/Typography/Typography.tsx | 6 +- 11 files changed, 554 insertions(+), 87 deletions(-) create mode 100644 apps/scandic-web/components/OpeningHours/AlternateOpeningHours/index.tsx create mode 100644 apps/scandic-web/components/OpeningHours/openingHours.test.ts create mode 100644 apps/scandic-web/components/OpeningHours/utils.ts diff --git a/apps/scandic-web/components/OpeningHours/AlternateOpeningHours/index.tsx b/apps/scandic-web/components/OpeningHours/AlternateOpeningHours/index.tsx new file mode 100644 index 000000000..f670e40b0 --- /dev/null +++ b/apps/scandic-web/components/OpeningHours/AlternateOpeningHours/index.tsx @@ -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 ( + +

{alternateOpeningHours.name}

+
+ ) + } + + return ( + <> + +
+ {intl.formatMessage( + { id: "Alternate opening hours ({name})" }, + { name: alternateOpeningHours.name } + )} +
+
+ {groupedAlternateOpeningHours.map((groupedOpeningHour) => ( + +

{groupedOpeningHour}

+
+ ))} + + ) +} diff --git a/apps/scandic-web/components/OpeningHours/index.tsx b/apps/scandic-web/components/OpeningHours/index.tsx index 20d76b8d3..5a2a162d5 100644 --- a/apps/scandic-web/components/OpeningHours/index.tsx +++ b/apps/scandic-web/components/OpeningHours/index.tsx @@ -2,6 +2,9 @@ import { Typography } from "@scandic-hotels/design-system/Typography" import { getIntl } from "@/i18n" +import AlternateOpeningHours from "./AlternateOpeningHours" +import { getGroupedOpeningHours } from "./utils" + import styles from "./openingHours.module.css" import type { OpeningHoursProps } from "@/types/components/hotelPage/sidepeek/openingHours" @@ -14,84 +17,7 @@ export default async function OpeningHours({ }: OpeningHoursProps) { const intl = await getIntl() - 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}`) - } - } - } + const groupedOpeningHours = getGroupedOpeningHours(openingHours, intl) return (
@@ -109,13 +35,8 @@ export default async function OpeningHours({ ) })} - {alternateOpeningHours?.name ? ( - -

{alternateOpeningHours.name}

-
+ {alternateOpeningHours ? ( + ) : null}
) diff --git a/apps/scandic-web/components/OpeningHours/openingHours.test.ts b/apps/scandic-web/components/OpeningHours/openingHours.test.ts new file mode 100644 index 000000000..ba37c468e --- /dev/null +++ b/apps/scandic-web/components/OpeningHours/openingHours.test.ts @@ -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 = { + 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"]) + }) +}) diff --git a/apps/scandic-web/components/OpeningHours/utils.ts b/apps/scandic-web/components/OpeningHours/utils.ts new file mode 100644 index 000000000..ced787107 --- /dev/null +++ b/apps/scandic-web/components/OpeningHours/utils.ts @@ -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 +} diff --git a/apps/scandic-web/i18n/dictionaries/da.json b/apps/scandic-web/i18n/dictionaries/da.json index d5c2b38dd..66e44e26e 100644 --- a/apps/scandic-web/i18n/dictionaries/da.json +++ b/apps/scandic-web/i18n/dictionaries/da.json @@ -49,6 +49,7 @@ "All-day breakfast": "Morgenmad hele dagen", "Allergy-friendly room": "Allergirum", "Already a friend?": "Allerede en ven?", + "Alternate opening hours ({name})": "Alternate opening hours ({name})", "Alternatives for {value}": "Alternatives for {value}", "Always open": "Altid åben", "Amenities": "Faciliteter", diff --git a/apps/scandic-web/i18n/dictionaries/de.json b/apps/scandic-web/i18n/dictionaries/de.json index 74db95108..613122338 100644 --- a/apps/scandic-web/i18n/dictionaries/de.json +++ b/apps/scandic-web/i18n/dictionaries/de.json @@ -49,6 +49,7 @@ "All-day breakfast": "Ganztag-Frühstück", "Allergy-friendly room": "Allergikerzimmer", "Already a friend?": "Sind wir schon Freunde?", + "Alternate opening hours ({name})": "Alternate opening hours ({name})", "Alternatives for {value}": "Alternatives for {value}", "Always open": "Immer geöffnet", "Amenities": "Annehmlichkeiten", diff --git a/apps/scandic-web/i18n/dictionaries/en.json b/apps/scandic-web/i18n/dictionaries/en.json index 29d884464..5dad001e5 100644 --- a/apps/scandic-web/i18n/dictionaries/en.json +++ b/apps/scandic-web/i18n/dictionaries/en.json @@ -49,6 +49,7 @@ "All-day breakfast": "All-day breakfast", "Allergy-friendly room": "Allergy-friendly room", "Already a friend?": "Already a friend?", + "Alternate opening hours ({name})": "Alternate opening hours ({name})", "Alternatives for {value}": "Alternatives for {value}", "Always open": "Always open", "Amenities": "Amenities", diff --git a/apps/scandic-web/i18n/dictionaries/fi.json b/apps/scandic-web/i18n/dictionaries/fi.json index 08ba0ddd3..7cae938af 100644 --- a/apps/scandic-web/i18n/dictionaries/fi.json +++ b/apps/scandic-web/i18n/dictionaries/fi.json @@ -49,6 +49,7 @@ "All-day breakfast": "Koko päivän aamiainen", "Allergy-friendly room": "Allergiahuone", "Already a friend?": "Oletko jo ystävä?", + "Alternate opening hours ({name})": "Vaihtoehtoiset aukioloajat ({name})", "Alternatives for {value}": "Alternatives for {value}", "Always open": "Aina auki", "Amenities": "Mukavuudet", diff --git a/apps/scandic-web/i18n/dictionaries/no.json b/apps/scandic-web/i18n/dictionaries/no.json index 479d125c0..0538b482e 100644 --- a/apps/scandic-web/i18n/dictionaries/no.json +++ b/apps/scandic-web/i18n/dictionaries/no.json @@ -49,6 +49,7 @@ "All-day breakfast": "Frokost hele dagen", "Allergy-friendly room": "Allergirom", "Already a friend?": "Allerede Friend?", + "Alternate opening hours ({name})": "Alternativ åpningstider ({name})", "Alternatives for {value}": "Alternatives for {value}", "Always open": "Alltid åpen", "Amenities": "Fasiliteter", diff --git a/apps/scandic-web/i18n/dictionaries/sv.json b/apps/scandic-web/i18n/dictionaries/sv.json index ef4093e87..72a4e73f1 100644 --- a/apps/scandic-web/i18n/dictionaries/sv.json +++ b/apps/scandic-web/i18n/dictionaries/sv.json @@ -49,6 +49,7 @@ "All-day breakfast": "Frukost hela dagen", "Allergy-friendly room": "Allergirum", "Already a friend?": "Är du redan en vän?", + "Alternate opening hours ({name})": "Alternativa öppettider ({name})", "Alternatives for {value}": "Alternatives for {value}", "Always open": "Alltid öppet", "Amenities": "Bekvämligheter", diff --git a/packages/design-system/lib/components/Typography/Typography.tsx b/packages/design-system/lib/components/Typography/Typography.tsx index e1db31f5d..ea2d0404b 100644 --- a/packages/design-system/lib/components/Typography/Typography.tsx +++ b/packages/design-system/lib/components/Typography/Typography.tsx @@ -4,7 +4,7 @@ import { variants } from './variants' import type { TypographyProps } from './types' -export function Typography({ variant, children }: TypographyProps) { +export function Typography({ variant, className, children }: TypographyProps) { if (!isValidElement(children)) return null const classNames = variants({ @@ -13,6 +13,8 @@ export function Typography({ variant, children }: TypographyProps) { return cloneElement(children, { ...children.props, - className: [children.props.className, classNames].filter(Boolean).join(' '), + className: [className, children.props.className, classNames] + .filter(Boolean) + .join(' '), }) }