diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/NextStayContent.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/NextStayContent.tsx new file mode 100644 index 000000000..6385a9c8b --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/NextStayContent.tsx @@ -0,0 +1,153 @@ +import { dt } from "@scandic-hotels/common/dt" +import ButtonLink from "@scandic-hotels/design-system/ButtonLink" +import { Divider } from "@scandic-hotels/design-system/Divider" +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import Image from "@scandic-hotels/design-system/Image" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" + +import { getDaysUntilText } from "./utils" + +import styles from "./nextStay.module.css" + +import type { Stay } from "@scandic-hotels/trpc/routers/user/output" + +interface NextStayContentProps { + nextStay: Stay +} + +export default async function NextStayContent({ + nextStay, +}: NextStayContentProps) { + const lang = await getLang() + const intl = await getIntl() + + const { attributes } = nextStay + const { + checkinDate, + checkoutDate, + confirmationNumber, + hotelInformation, + isWebAppOrigin, + bookingUrl, + } = attributes + + const daysUntilText = getDaysUntilText(checkinDate, lang, intl) + + return ( +
+
+ { +
+ + + {intl.formatMessage({ + id: "nextStay.myStayAt", + defaultMessage: "My Stay at", + })} + + + + + {hotelInformation.hotelName} + + +
+
+ +
+
+ + + + {daysUntilText} + + + + +

+ + {hotelInformation.cityName} +

+
+
+ +
+
+ +

+ {intl.formatMessage({ + id: "common.bookingNumber", + defaultMessage: "Booking number", + })} +

+
+ +

{confirmationNumber}

+
+
+ + + +
+ + + + {intl.formatMessage({ + id: "common.dates", + defaultMessage: "Dates", + })} + + + + + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} + {"→"} + + + +
+
+ {isWebAppOrigin ? ( +
+ + {intl.formatMessage({ + id: "nextStay.seeDetailsAndExtras", + defaultMessage: "See details & extras", + })} + +
+ ) : null} +
+
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/index.tsx new file mode 100644 index 000000000..43a163e91 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/index.tsx @@ -0,0 +1,28 @@ +import { serverClient } from "@/lib/trpc/server" + +import { Section } from "@/components/Section" +import { SectionHeader } from "@/components/Section/Header" +import SectionLink from "@/components/Section/Link" + +import NextStayContent from "./NextStayContent" + +import styles from "./nextStay.module.css" + +import type { NextStayProps } from "./types" + +export default async function NextStay({ title, link }: NextStayProps) { + const caller = await serverClient() + const nextStay = await caller.user.stays.next() + + if (!nextStay) { + return null + } + + return ( +
+ {title && } + + {link && } +
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/nextStay.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/nextStay.module.css new file mode 100644 index 000000000..45315ad90 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/nextStay.module.css @@ -0,0 +1,167 @@ +.nextStayCard { + border-radius: var(--Corner-radius-lg); + border: 1px solid var(--Border-Default); + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--Base-Surface-Primary-light-Normal); +} + +.imageContainer { + position: relative; + width: 100%; + aspect-ratio: 16/9; + border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0; + background: + linear-gradient(0deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.4) 100%), + lightgray 50% / cover no-repeat, + var(--Neutral-20); + overflow: hidden; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; + position: relative; + z-index: 1; +} + +.imageOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.3) 50%, + rgba(0, 0, 0, 0.6) 100% + ); + display: flex; + padding: 1.5rem; + z-index: 2; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +.overlayText { + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.content { + padding: var(--Space-x3); + display: grid; + grid-template-areas: + "header" + "." + "booking" + "." + "actions"; + grid-template-rows: + auto var(--Space-x3) + auto var(--Space-x2) + auto; +} + +.header { + grid-area: header; + display: flex; + flex-direction: column; + gap: var(--Space-x1); +} + +.daysUntil, +.address { + display: flex; + align-items: center; + gap: var(--Space-x15); +} + +.daysUntil { + color: var(--Text-Heading); +} + +.address { + color: var(--Text-Accent-Primary); +} + +.daysUntilIcon, +.addressIcon { + display: flex; + align-items: center; +} + +.booking { + grid-area: booking; + display: grid; + gap: var(--Space-x1); +} + +.bookingInfo { + display: flex; + justify-content: space-between; +} + +.bookingInfo span:first-child { + display: flex; + align-items: center; + gap: var(--Space-x05); +} + +.fromToDates { + display: flex; + gap: var(--Space-x05); +} + +.actions { + grid-area: actions; +} + +@media (min-width: 768px) { + .nextStayCard { + max-width: 100%; + } + + .imageContainer { + aspect-ratio: 21/9; + } + + .imageOverlay { + padding: 2rem; + } +} + +@media (min-width: 1367px) { + .nextStayCard { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; + } + + .imageContainer { + flex: 1; + aspect-ratio: 16/9; + width: 100%; + border-radius: var(--Corner-radius-lg) 0 0 var(--Corner-radius-lg); + } + + .content { + width: 100%; + padding: var(--Space-x4); + grid-template-areas: + "header" + "." + "booking" + "." + "actions"; + grid-template-rows: + auto var(--Space-x6) + auto var(--Space-x3) + auto; + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/types.ts b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/types.ts new file mode 100644 index 000000000..7fdf278f9 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/types.ts @@ -0,0 +1,7 @@ +export interface NextStayProps { + title?: string + link?: { + href: string + text: string + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/utils.test.ts b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/utils.test.ts new file mode 100644 index 000000000..e7cd5eb11 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/utils.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest" + +import { Lang } from "@scandic-hotels/common/constants/language" +import { dt } from "@scandic-hotels/common/dt" + +import { getDaysUntilText } from "./utils" + +import type { IntlShape, MessageDescriptor } from "react-intl" + +const mockIntl = { + formatMessage: ( + descriptor: MessageDescriptor, + values?: Record + ) => { + const messages: Record = { + "nextStay.past": `{date}`, + "nextStay.today": "Today", + "nextStay.tomorrow": "Tomorrow", + "nextStay.inXDays": `In {days} days`, + "nextStay.inXMonths": `In {months} month{months, plural, =1 {} other {s}}`, + } + + let message: string = + messages[descriptor.id as string] || + (typeof descriptor.defaultMessage === "string" + ? descriptor.defaultMessage + : "") || + "" + + if (values) { + Object.entries(values).forEach(([key, value]) => { + message = message.replace(`{${key}}`, String(value)) + }) + + if (values.months === 1) { + message = message.replace("{months, plural, =1 {} other {s}}", "") + } else { + message = message.replace("{months, plural, =1 {} other {s}}", "s") + } + } + + return message + }, +} as IntlShape + +describe("getDaysUntilText", () => { + const lang = Lang.en + + describe("past dates", () => { + it("should return formatted date for past check-in dates", () => { + const yesterday = dt().subtract(1, "day").format("YYYY-MM-DD") + const result = getDaysUntilText(yesterday, lang, mockIntl) + + expect(result).toContain(dt(yesterday).format("D MMM YYYY")) + }) + + it("should handle dates from several days ago", () => { + const pastDate = dt().subtract(10, "days").format("YYYY-MM-DD") + const result = getDaysUntilText(pastDate, lang, mockIntl) + + expect(result).toContain(dt(pastDate).format("D MMM YYYY")) + }) + + it("should handle dates from months ago", () => { + const pastDate = dt().subtract(2, "months").format("YYYY-MM-DD") + const result = getDaysUntilText(pastDate, lang, mockIntl) + + expect(result).toContain(dt(pastDate).format("D MMM YYYY")) + }) + }) + + describe("same day check-in", () => { + it("should return 'Today' for today's check-in", () => { + const today = dt().format("YYYY-MM-DD") + const result = getDaysUntilText(today, lang, mockIntl) + + expect(result).toBe("Today") + }) + + it("should return 'Today' regardless of time of day", () => { + // Testing with different times but same date + const todayMorning = dt().hour(8).format("YYYY-MM-DD") + const todayEvening = dt().hour(20).format("YYYY-MM-DD") + + expect(getDaysUntilText(todayMorning, lang, mockIntl)).toBe("Today") + expect(getDaysUntilText(todayEvening, lang, mockIntl)).toBe("Today") + }) + }) + + describe("tomorrow check-in", () => { + it("should return 'Tomorrow' for next day check-in", () => { + const tomorrow = dt().add(1, "day").format("YYYY-MM-DD") + const result = getDaysUntilText(tomorrow, lang, mockIntl) + + expect(result).toBe("Tomorrow") + }) + }) + + describe("days until check-in (2-30 days)", () => { + it("should return 'In X days' for 2 days", () => { + const futureDate = dt().add(2, "days").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 2 days") + }) + + it("should return 'In X days' for 15 days", () => { + const futureDate = dt().add(15, "days").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 15 days") + }) + + it("should return 'In X days' for exactly 30 days (boundary)", () => { + const futureDate = dt().add(30, "days").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 30 days") + }) + + it("should handle the full range from 2 to 30 days", () => { + for (let days = 2; days <= 30; days++) { + const futureDate = dt().add(days, "days").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe(`In ${days} days`) + } + }) + }) + + describe("months until check-in (beyond 30 days)", () => { + it("should return 'In 1 month' for 31 days", () => { + const futureDate = dt().add(31, "days").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 1 month") + }) + + it("should return 'In 2 months' for dates 2 months away", () => { + const futureDate = dt().add(2, "months").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 2 months") + }) + + it("should use proper month calculation for 3 months", () => { + const futureDate = dt().add(3, "months").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 3 months") + }) + + it("should handle dates far in the future (6 months)", () => { + const futureDate = dt().add(6, "months").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 6 months") + }) + + it("should handle dates far in the future (1 year)", () => { + const futureDate = dt().add(1, "year").format("YYYY-MM-DD") + const result = getDaysUntilText(futureDate, lang, mockIntl) + + expect(result).toBe("In 12 months") + }) + }) + + describe("edge cases", () => { + it("should handle dates with different time components consistently", () => { + // Dates with times should be normalized to start of day + const dateWithTime1 = dt() + .add(5, "days") + .hour(3) + .minute(30) + .format("YYYY-MM-DD HH:mm") + const dateWithTime2 = dt() + .add(5, "days") + .hour(22) + .minute(45) + .format("YYYY-MM-DD HH:mm") + + const result1 = getDaysUntilText(dateWithTime1, lang, mockIntl) + const result2 = getDaysUntilText(dateWithTime2, lang, mockIntl) + + expect(result1).toBe("In 5 days") + expect(result2).toBe("In 5 days") + }) + + it("should respect locale parameter", () => { + const futureDate = dt().add(5, "days").format("YYYY-MM-DD") + + // Test with different locales + const resultEN = getDaysUntilText(futureDate, Lang.en, mockIntl) + const resultSV = getDaysUntilText(futureDate, Lang.sv, mockIntl) + + // Both should work without errors + expect(resultEN).toBe("In 5 days") + expect(resultSV).toBe("In 5 days") + }) + + it("should handle ISO date strings with timezone", () => { + const isoDate = dt().add(7, "days").toISOString() + const result = getDaysUntilText(isoDate, lang, mockIntl) + + expect(result).toBe("In 7 days") + }) + }) + + describe("boundary transitions", () => { + it("should transition correctly from days to months at 31 days", () => { + const date30 = dt().add(30, "days").format("YYYY-MM-DD") + const date31 = dt().add(31, "days").format("YYYY-MM-DD") + + expect(getDaysUntilText(date30, lang, mockIntl)).toBe("In 30 days") + expect(getDaysUntilText(date31, lang, mockIntl)).toBe("In 1 month") + }) + + it("should transition correctly from tomorrow to 2 days", () => { + const date1 = dt().add(1, "day").format("YYYY-MM-DD") + const date2 = dt().add(2, "days").format("YYYY-MM-DD") + + expect(getDaysUntilText(date1, lang, mockIntl)).toBe("Tomorrow") + expect(getDaysUntilText(date2, lang, mockIntl)).toBe("In 2 days") + }) + + it("should transition correctly from today to tomorrow", () => { + const date0 = dt().format("YYYY-MM-DD") + const date1 = dt().add(1, "day").format("YYYY-MM-DD") + + expect(getDaysUntilText(date0, lang, mockIntl)).toBe("Today") + expect(getDaysUntilText(date1, lang, mockIntl)).toBe("Tomorrow") + }) + }) +}) diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/utils.ts b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/utils.ts new file mode 100644 index 000000000..c352d5f93 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/NextStay/utils.ts @@ -0,0 +1,65 @@ +import { dt } from "@scandic-hotels/common/dt" + +import type { Lang } from "@scandic-hotels/common/constants/language" +import type { IntlShape } from "react-intl" + +export function getDaysUntilText( + checkinDate: string, + lang: Lang, + intl: IntlShape +): string { + const checkInDateTime = dt(checkinDate).locale(lang).startOf("day") + const now = dt().locale(lang).startOf("day") + const daysUntil = checkInDateTime.diff(now, "days") + + // Handle past dates edge case. + if (daysUntil < 0) { + return intl.formatMessage( + { + id: "nextStay.past", + defaultMessage: "{date} ", + }, + { + date: dt(checkinDate).locale(lang).format("D MMM YYYY"), + } + ) + } + + if (daysUntil === 0) { + return intl.formatMessage({ + id: "nextStay.today", + defaultMessage: "Today", + }) + } + + if (daysUntil === 1) { + return intl.formatMessage({ + id: "nextStay.tomorrow", + defaultMessage: "Tomorrow", + }) + } + + if (daysUntil > 1 && daysUntil <= 30) { + return intl.formatMessage( + { + id: "nextStay.inXDays", + defaultMessage: "In {days} days", + }, + { + days: daysUntil, + } + ) + } + + // Use proper month calculation for dates beyond 30 days + const monthsUntil = checkInDateTime.diff(now, "months") + return intl.formatMessage( + { + id: "nextStay.inXMonths", + defaultMessage: "In {months} month{months, plural, =1 {} other {s}}", + }, + { + months: monthsUntil, + } + ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/index.tsx index 1ee0bad5f..456c29133 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/index.tsx @@ -19,6 +19,7 @@ import SASLinkedAccount from "@/components/Blocks/DynamicContent/SAS/LinkedAccou import SASTransferPoints from "@/components/Blocks/DynamicContent/SAS/TransferPoints" import SASTierComparisonBlock from "@/components/Blocks/DynamicContent/SASTierComparison" import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper" +import NextStay from "@/components/Blocks/DynamicContent/Stays/NextStay" import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous" import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming" @@ -57,6 +58,8 @@ function DynamicContentBlocks(props: DynamicContentProps) { return case DynamicContentEnum.Blocks.components.next_benefits: return + case DynamicContentEnum.Blocks.components.next_stay: + return case DynamicContentEnum.Blocks.components.overview_table: return case DynamicContentEnum.Blocks.components.points_overview: diff --git a/packages/trpc/lib/routers/user/query/index.ts b/packages/trpc/lib/routers/user/query/index.ts index 755a7810c..d3755d79b 100644 --- a/packages/trpc/lib/routers/user/query/index.ts +++ b/packages/trpc/lib/routers/user/query/index.ts @@ -183,6 +183,26 @@ export const userQueryRouter = router({ } return null }), + + next: languageProtectedProcedure.query(async ({ ctx }) => { + const data = await getUpcomingStays( + ctx.session.token.access_token, + 1, // Only get the closest stay + ctx.lang + ) + + if (data && data.data.length > 0) { + const updatedData = await updateStaysBookingUrl( + data.data, + ctx.session, + ctx.lang + ) + + // Return only the first (closest) stay + return updatedData[0] + } + return null + }), }), transaction: router({ friendTransactions: languageProtectedProcedure diff --git a/packages/trpc/lib/types/dynamicContent.ts b/packages/trpc/lib/types/dynamicContent.ts index d45ba8265..7ccdd3400 100644 --- a/packages/trpc/lib/types/dynamicContent.ts +++ b/packages/trpc/lib/types/dynamicContent.ts @@ -14,6 +14,7 @@ export namespace DynamicContentEnum { my_pages_overview_shortcuts: "my_pages_overview_shortcuts", my_points: "my_points", next_benefits: "next_benefits", + next_stay: "next_stay", overview_table: "overview_table", points_overview: "points_overview", previous_stays: "previous_stays",