diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Card/card.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Card/card.module.css new file mode 100644 index 000000000..c1a690c48 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Card/card.module.css @@ -0,0 +1,92 @@ +.card { + border-radius: var(--Corner-radius-lg); + border: 1px solid var(--Border-Default); + background: var(--Text-Brand-OnPrimary-3-Default); + display: flex; + padding: var(--Space-x15); + align-items: flex-start; + gap: var(--Space-x15); + align-self: stretch; + height: 100%; +} + +.link { + text-decoration: none; + color: inherit; +} + +.fallback { + min-width: 80px; + min-height: 108px; +} + +.image { + width: 80px; + max-width: 80px; + height: 108px; + max-height: 108px; + border-radius: var(--Corner-radius-md); + object-fit: cover; + flex-shrink: 0; +} + +.content { + display: grid; + gap: var(--Space-x1); +} + +.details { + display: flex; + flex-direction: column; + gap: var(--Space-x05); +} + +.hotelName, +.cityName, +.dates { + color: var(--Text-Default); +} + +.divider { + display: none; +} + +.chip { + display: flex; + padding: var(--Space-x05) var(--Space-x1); + justify-content: center; + align-items: center; + gap: var(--Space-x05); + border-radius: var(--Corner-radius-sm); + background: var(--Surface-Secondary-Default); + width: fit-content; +} + +.chipText { + color: var(--Text-Interactive-Default); +} + +.dateSection { + display: flex; + flex-direction: column; + gap: var(--Space-x05); +} + +.dates { + display: flex; + align-items: center; + gap: var(--Space-x05); +} + +@media screen and (min-width: 1367px) { + .content { + gap: var(--Space-x15); + } + .divider { + display: inline-block; + } + .dateSection { + flex-direction: row; + gap: var(--Space-x2); + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Card/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Card/index.tsx new file mode 100644 index 000000000..82da16409 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Card/index.tsx @@ -0,0 +1,105 @@ +"use client" + +import Link from "next/link" +import { useIntl } from "react-intl" + +import { dt } from "@scandic-hotels/common/dt" +import { Divider } from "@scandic-hotels/design-system/Divider" +import Image from "@scandic-hotels/design-system/Image" +import ImageFallback from "@scandic-hotels/design-system/ImageFallback" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import useLang from "@/hooks/useLang" +import { getRelativePastTime } from "@/utils/getRelativePastTime" + +import styles from "./card.module.css" + +import type { StayCardProps } from "@/types/components/myPages/stays/stayCard" + +export function Card({ stay }: StayCardProps) { + const { bookingUrl, isWebAppOrigin: shouldLinkToMyStay } = stay.attributes + + if (!shouldLinkToMyStay) { + return + } + + return ( + + + + ) +} + +function CardContent({ stay }: StayCardProps) { + const lang = useLang() + const intl = useIntl() + + const { checkinDate, checkoutDate, hotelInformation } = stay.attributes + + const arrival = dt(checkinDate).locale(lang) + const arrivalDate = arrival.format("DD MMM") + const arrivalDateTime = arrival.format("YYYY-MM-DD") + const depart = dt(checkoutDate).locale(lang) + const departDate = depart.format("DD MMM YYYY") + const departDateTime = depart.format("YYYY-MM-DD") + + const relativeTime = getRelativePastTime(checkoutDate, intl) + + return ( +
+ {hotelInformation.hotelContent.images.src ? ( + { + ) : ( + + )} +
+
+ +

{hotelInformation.hotelName}

+
+ + {hotelInformation.cityName && ( + +

{hotelInformation.cityName}

+
+ )} +
+ + + +
+
+ + {relativeTime} + +
+ +
+ + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} + + +
+
+
+
+
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Cards.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Cards.tsx new file mode 100644 index 000000000..e2977b2d5 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Cards.tsx @@ -0,0 +1,64 @@ +"use client" + +import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner" +import { trpc } from "@scandic-hotels/trpc/client" + +import useLang from "@/hooks/useLang" + +import ListContainer from "../ListContainer" +import ShowMoreButton from "../ShowMoreButton" +import { Card } from "./Card" + +import styles from "./cards.module.css" + +import type { + PreviousStaysClientProps, + PreviousStaysNonNullResponseObject, +} from "@/types/components/myPages/stays/previous" + +export function Cards({ initialPreviousStays }: PreviousStaysClientProps) { + const lang = useLang() + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + trpc.user.stays.previous.useInfiniteQuery( + { + limit: 6, + lang, + }, + { + getNextPageParam: (lastPage) => { + return lastPage?.nextCursor + }, + initialData: { + pageParams: [undefined, 1], + pages: [initialPreviousStays], + }, + } + ) + + if (isLoading) { + return + } + + function loadMoreData() { + if (hasNextPage) { + fetchNextPage() + } + } + + const stays = data.pages + .filter((page): page is PreviousStaysNonNullResponseObject => !!page?.data) + .flatMap((page) => page.data) + + return ( + +
+ {stays.map((stay) => ( + + ))} +
+ {hasNextPage ? ( + + ) : null} +
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Client.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Client.tsx index 752c6b4cb..b1e264eb3 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Client.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/Client.tsx @@ -15,7 +15,7 @@ import type { PreviousStaysNonNullResponseObject, } from "@/types/components/myPages/stays/previous" -export default function ClientPreviousStays({ +export function ClientPreviousStays({ initialPreviousStays, }: PreviousStaysClientProps) { const lang = useLang() diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/cards.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/cards.module.css new file mode 100644 index 000000000..476b1f534 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/cards.module.css @@ -0,0 +1,18 @@ +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +@media (min-width: 768px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1367px) { + .grid { + grid-template-columns: repeat(3, 1fr); + align-items: stretch; + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/index.tsx index 7ad8a8100..eb15fd2a4 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/index.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env/server" import { serverClient } from "@/lib/trpc/server" import ClaimPoints from "@/components/Blocks/DynamicContent/Points/ClaimPoints" @@ -5,7 +6,8 @@ import { Section } from "@/components/Section" import SectionHeader from "@/components/Section/Header/Deprecated" import SectionLink from "@/components/Section/Link" -import ClientPreviousStays from "./Client" +import { Cards } from "./Cards" +import { ClientPreviousStays } from "./Client" import styles from "./previous.module.css" @@ -24,14 +26,15 @@ export default async function PreviousStays({ return null } + const StaysComponent = env.NEW_STAYS_ON_MY_PAGES ? Cards : ClientPreviousStays + return (
-
- +
) diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.test.ts b/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.test.ts index 9d474cc25..63538fc8d 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.test.ts +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.test.ts @@ -16,7 +16,8 @@ const mockIntl = { "nextStay.today": "Today", "nextStay.tomorrow": "Tomorrow", "nextStay.inXDays": `In {days} days`, - "nextStay.inXMonths": `In {months} month{months, plural, =1 {} other {s}}`, + "nextStay.inXMonths": + "In {months, plural, one {# month} other {# months}}", } let message: string = @@ -27,15 +28,21 @@ const mockIntl = { "" if (values) { + if (message.includes("{months, plural")) { + const months = Number(values.months) + + if (months === 1) { + message = "In 1 month" + } else { + message = `In ${months} months` + } + + return message + } + 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 diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.ts b/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.ts index 2ee7e4a64..61315c5af 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.ts +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/utils/getDaysUntilText.ts @@ -48,7 +48,7 @@ export function getDaysUntilText( return intl.formatMessage( { id: "nextStay.inXMonths", - defaultMessage: "In {months} month{months, plural, =1 {} other {s}}", + defaultMessage: "In {months, plural, one {# month} other { # months}}", }, { months: monthsUntil, diff --git a/apps/scandic-web/utils/getRelativePastTime.test.ts b/apps/scandic-web/utils/getRelativePastTime.test.ts new file mode 100644 index 000000000..41f99adce --- /dev/null +++ b/apps/scandic-web/utils/getRelativePastTime.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest" + +import { dt } from "@scandic-hotels/common/dt" + +import { getRelativePastTime } from "./getRelativePastTime" + +import type { IntlShape, MessageDescriptor } from "react-intl" + +const mockIntl = { + formatMessage: ( + descriptor: MessageDescriptor, + values?: Record + ) => { + const messages: Record = { + "common.nrDaysAgo": `{count, plural, one {# day ago} other {# days ago}}`, + "common.nrMonthsAgo": `{count, plural, one {# month ago} other {# months ago}}`, + "common.nrYearsAgo": `{count, plural, one {# year ago} other {# years ago}}`, + } + + let message: string = + messages[descriptor.id as string] || + (typeof descriptor.defaultMessage === "string" + ? descriptor.defaultMessage + : "") || + "" + + if (values) { + // Handle plural forms first + if (message.includes("{count, plural")) { + const count = values.count as number + if (count === 1) { + // Extract the singular form (between "one {" and "}") + const singularMatch = message.match(/one {(.*?)}/) + if (singularMatch) { + message = singularMatch[1].replace("#", String(count)) + } + } else { + // Extract the plural form (between "other {" and "}") + const pluralMatch = message.match(/other {(.*?)}/) + if (pluralMatch) { + message = pluralMatch[1].replace("#", String(count)) + } + } + } + + // Replace any remaining placeholders + Object.entries(values).forEach(([key, value]) => { + message = message.replace(`{${key}}`, String(value)) + }) + } + + return message + }, +} as IntlShape + +describe("getRelativePastTime", () => { + describe("days ago (1-30 days)", () => { + it("should return '1 day ago' for yesterday", () => { + const yesterday = dt().subtract(1, "day").format("YYYY-MM-DD") + const result = getRelativePastTime(yesterday, mockIntl) + + expect(result).toBe("1 day ago") + }) + + it("should return '2 days ago' for 2 days ago", () => { + const twoDaysAgo = dt().subtract(2, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(twoDaysAgo, mockIntl) + + expect(result).toBe("2 days ago") + }) + + it("should return '15 days ago' for 15 days ago", () => { + const fifteenDaysAgo = dt().subtract(15, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(fifteenDaysAgo, mockIntl) + + expect(result).toBe("15 days ago") + }) + + it("should return '30 days ago' for exactly 30 days ago (boundary)", () => { + const thirtyDaysAgo = dt().subtract(30, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(thirtyDaysAgo, mockIntl) + + expect(result).toBe("30 days ago") + }) + + it("should handle the full range from 1 to 30 days ago", () => { + for (let days = 1; days <= 30; days++) { + const pastDate = dt().subtract(days, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(pastDate, mockIntl) + + if (days === 1) { + expect(result).toBe("1 day ago") + } else { + expect(result).toBe(`${days} days ago`) + } + } + }) + }) + + describe("months ago (31-364 days)", () => { + it("should return '1 month ago' for 31 days ago", () => { + const thirtyOneDaysAgo = dt().subtract(31, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(thirtyOneDaysAgo, mockIntl) + + expect(result).toBe("1 month ago") + }) + + it("should return '1 month ago' for 45 days ago", () => { + const fortyFiveDaysAgo = dt().subtract(45, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(fortyFiveDaysAgo, mockIntl) + + expect(result).toBe("1 month ago") + }) + + it("should return '2 months ago' for 60 days ago", () => { + const sixtyDaysAgo = dt().subtract(60, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(sixtyDaysAgo, mockIntl) + + expect(result).toBe("2 months ago") + }) + + it("should return '6 months ago' for 180 days ago", () => { + const sixMonthsAgo = dt().subtract(180, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(sixMonthsAgo, mockIntl) + + expect(result).toBe("6 months ago") + }) + + it("should return '11 months ago' for 330 days ago", () => { + const elevenMonthsAgo = dt().subtract(330, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(elevenMonthsAgo, mockIntl) + + expect(result).toBe("11 months ago") + }) + + it("should return '12 months ago' for 364 days ago (boundary)", () => { + const twelveMonthsAgo = dt().subtract(364, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(twelveMonthsAgo, mockIntl) + + expect(result).toBe("12 months ago") + }) + }) + + describe("years ago (365+ days)", () => { + it("should return '1 year ago' for exactly 365 days ago", () => { + const oneYearAgo = dt().subtract(365, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(oneYearAgo, mockIntl) + + expect(result).toBe("1 year ago") + }) + + it("should return '1 year ago' for 400 days ago", () => { + const fourHundredDaysAgo = dt().subtract(400, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(fourHundredDaysAgo, mockIntl) + + expect(result).toBe("1 year ago") + }) + + it("should return '2 years ago' for 730 days ago", () => { + const twoYearsAgo = dt().subtract(730, "days").format("YYYY-MM-DD") + const result = getRelativePastTime(twoYearsAgo, mockIntl) + + expect(result).toBe("2 years ago") + }) + + it("should return '5 years ago' for 5 years ago", () => { + const fiveYearsAgo = dt().subtract(5, "years").format("YYYY-MM-DD") + const result = getRelativePastTime(fiveYearsAgo, mockIntl) + + expect(result).toBe("5 years ago") + }) + }) + + describe("edge cases", () => { + it("should handle dates with different time components consistently", () => { + // The function calculates exact day differences, so time components matter + // Using start of day to ensure consistent results + const dateWithTime1 = dt() + .subtract(5, "days") + .startOf("day") + .format("YYYY-MM-DD HH:mm") + const dateWithTime2 = dt() + .subtract(5, "days") + .startOf("day") + .format("YYYY-MM-DD HH:mm") + + const result1 = getRelativePastTime(dateWithTime1, mockIntl) + const result2 = getRelativePastTime(dateWithTime2, mockIntl) + + expect(result1).toBe("5 days ago") + expect(result2).toBe("5 days ago") + }) + + it("should handle ISO date strings with timezone", () => { + const isoDate = dt().subtract(7, "days").toISOString() + const result = getRelativePastTime(isoDate, mockIntl) + + expect(result).toBe("7 days ago") + }) + + it("should handle future dates (should return 0 days ago for same day)", () => { + // The function doesn't handle negative days specially - it just uses the diff calculation + // For future dates on the same day, diff will be 0 + const futureDate = dt().add(1, "day").startOf("day").format("YYYY-MM-DD") + const result = getRelativePastTime(futureDate, mockIntl) + + expect(result).toBe("0 days ago") + }) + }) + + describe("boundary transitions", () => { + it("should transition correctly from days to months at 31 days", () => { + const date30 = dt().subtract(30, "days").format("YYYY-MM-DD") + const date31 = dt().subtract(31, "days").format("YYYY-MM-DD") + + expect(getRelativePastTime(date30, mockIntl)).toBe("30 days ago") + expect(getRelativePastTime(date31, mockIntl)).toBe("1 month ago") + }) + + it("should transition correctly from months to years at 365 days", () => { + const date364 = dt().subtract(364, "days").format("YYYY-MM-DD") + const date365 = dt().subtract(365, "days").format("YYYY-MM-DD") + + expect(getRelativePastTime(date364, mockIntl)).toBe("12 months ago") + expect(getRelativePastTime(date365, mockIntl)).toBe("1 year ago") + }) + + it("should handle the transition from 1 day to multiple days", () => { + const date1 = dt().subtract(1, "day").format("YYYY-MM-DD") + const date2 = dt().subtract(2, "days").format("YYYY-MM-DD") + + expect(getRelativePastTime(date1, mockIntl)).toBe("1 day ago") + expect(getRelativePastTime(date2, mockIntl)).toBe("2 days ago") + }) + }) +}) diff --git a/apps/scandic-web/utils/getRelativePastTime.ts b/apps/scandic-web/utils/getRelativePastTime.ts new file mode 100644 index 000000000..edffc9129 --- /dev/null +++ b/apps/scandic-web/utils/getRelativePastTime.ts @@ -0,0 +1,54 @@ +import { dt } from "@scandic-hotels/common/dt" + +import type { IntlShape } from "react-intl" + +/** + * Returns relative past time text for a date (e.g., "3 days ago", "2 months ago", "1 year ago") + * + * Examples: + * - 1-30 days: "1 day ago", "15 days ago", "30 days ago" + * - 31-364 days: "1 month ago", "6 months ago", "12 months ago" + * - 365+ days: "1 year ago", "2 years ago", "5 years ago" + */ +export function getRelativePastTime( + checkoutDate: string, + intl: IntlShape +): string { + const now = dt() + const checkout = dt(checkoutDate) + const daysDiff = now.diff(checkout, "days") + + if (daysDiff <= 30) { + // 1-30 days + return intl.formatMessage( + { + id: "common.nrDaysAgo", + defaultMessage: "{count, plural, one {# day ago} other {# days ago}}", + }, + { count: daysDiff } + ) + } + + if (daysDiff <= 364) { + // 31-364 days (show months) + const monthsDiff = Math.floor(daysDiff / 30) + return intl.formatMessage( + { + id: "common.nrMonthsAgo", + defaultMessage: + "{count, plural, one {# month ago} other {# months ago}}", + }, + { count: monthsDiff } + ) + } + + // 365+ days (show years) + const yearsDiff = Math.floor(daysDiff / 365) + return intl.formatMessage( + { + id: "common.nrYearsAgo", + defaultMessage: "{count, plural, one {# year ago} other {# years ago}}", + }, + { count: yearsDiff } + ) +}