Merged in feat/LOY-421-Next-Stay (pull request #3026)
Feat(LOY-421): Next Stay * feat(LOY-421): Next stay WIP * fix(LOY-421): clean upp css and jsx * chore(LOY-421): css cleanup * fix(LOY-421): fix test * only show button if isWebAppOrigin is true * chore(LOY-421): update section header component * chore(LOY-421): remove redundant test case Approved-by: Matilda Landström
This commit is contained in:
@@ -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 (
|
||||||
|
<article className={styles.nextStayCard}>
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
<Image
|
||||||
|
className={styles.image}
|
||||||
|
alt={
|
||||||
|
hotelInformation.hotelContent.images.altText ||
|
||||||
|
hotelInformation.hotelContent.images.altText_En ||
|
||||||
|
hotelInformation.hotelName
|
||||||
|
}
|
||||||
|
src={hotelInformation.hotelContent.images.src}
|
||||||
|
width={600}
|
||||||
|
height={338}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className={styles.imageOverlay}>
|
||||||
|
<Typography variant="Title/Decorative/lg">
|
||||||
|
<span className={styles.overlayText}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "nextStay.myStayAt",
|
||||||
|
defaultMessage: "My Stay at",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Title/lg">
|
||||||
|
<span className={styles.overlayText}>
|
||||||
|
{hotelInformation.hotelName}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Typography variant="Title/sm">
|
||||||
|
<span className={styles.daysUntil}>
|
||||||
|
<MaterialIcon
|
||||||
|
className={styles.daysUntilIcon}
|
||||||
|
icon="calendar_clock"
|
||||||
|
color="Icon/Interactive/Default"
|
||||||
|
/>
|
||||||
|
{daysUntilText}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="Title/Subtitle/lg">
|
||||||
|
<p className={styles.address}>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="location_on"
|
||||||
|
color="Icon/Interactive/Default"
|
||||||
|
className={styles.addressIcon}
|
||||||
|
/>
|
||||||
|
{hotelInformation.cityName}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.booking}>
|
||||||
|
<div className={styles.bookingInfo}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "common.bookingNumber",
|
||||||
|
defaultMessage: "Booking number",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Title/Subtitle/md">
|
||||||
|
<p>{confirmationNumber}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider variant="horizontal" color="Border/Divider/Default" />
|
||||||
|
|
||||||
|
<div className={styles.bookingInfo}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<span>
|
||||||
|
<MaterialIcon icon="calendar_month" color="Icon/Default" />
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "common.dates",
|
||||||
|
defaultMessage: "Dates",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<span className={styles.fromToDates}>
|
||||||
|
<time>{dt(checkinDate).locale(lang).format("D MMM YYYY")}</time>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
{"→"}
|
||||||
|
<time>
|
||||||
|
{dt(checkoutDate).locale(lang).format("D MMM YYYY")}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isWebAppOrigin ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<ButtonLink
|
||||||
|
variant="Tertiary"
|
||||||
|
color="Inverted"
|
||||||
|
size="Medium"
|
||||||
|
href={bookingUrl}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "nextStay.seeDetailsAndExtras",
|
||||||
|
defaultMessage: "See details & extras",
|
||||||
|
})}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Section className={styles.container}>
|
||||||
|
{title && <SectionHeader heading={title} link={link} />}
|
||||||
|
<NextStayContent nextStay={nextStay} />
|
||||||
|
{link && <SectionLink link={link} variant="mobile" />}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface NextStayProps {
|
||||||
|
title?: string
|
||||||
|
link?: {
|
||||||
|
href: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string | number | boolean | Date>
|
||||||
|
) => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
"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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import SASLinkedAccount from "@/components/Blocks/DynamicContent/SAS/LinkedAccou
|
|||||||
import SASTransferPoints from "@/components/Blocks/DynamicContent/SAS/TransferPoints"
|
import SASTransferPoints from "@/components/Blocks/DynamicContent/SAS/TransferPoints"
|
||||||
import SASTierComparisonBlock from "@/components/Blocks/DynamicContent/SASTierComparison"
|
import SASTierComparisonBlock from "@/components/Blocks/DynamicContent/SASTierComparison"
|
||||||
import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper"
|
import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper"
|
||||||
|
import NextStay from "@/components/Blocks/DynamicContent/Stays/NextStay"
|
||||||
import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous"
|
import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous"
|
||||||
import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming"
|
import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming"
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ function DynamicContentBlocks(props: DynamicContentProps) {
|
|||||||
return <MyPagesOverviewShortcuts />
|
return <MyPagesOverviewShortcuts />
|
||||||
case DynamicContentEnum.Blocks.components.next_benefits:
|
case DynamicContentEnum.Blocks.components.next_benefits:
|
||||||
return <NextLevelRewardsBlock {...dynamic_content} />
|
return <NextLevelRewardsBlock {...dynamic_content} />
|
||||||
|
case DynamicContentEnum.Blocks.components.next_stay:
|
||||||
|
return <NextStay {...dynamic_content} />
|
||||||
case DynamicContentEnum.Blocks.components.overview_table:
|
case DynamicContentEnum.Blocks.components.overview_table:
|
||||||
return <OverviewTable dynamic_content={dynamic_content} />
|
return <OverviewTable dynamic_content={dynamic_content} />
|
||||||
case DynamicContentEnum.Blocks.components.points_overview:
|
case DynamicContentEnum.Blocks.components.points_overview:
|
||||||
|
|||||||
@@ -183,6 +183,26 @@ export const userQueryRouter = router({
|
|||||||
}
|
}
|
||||||
return null
|
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({
|
transaction: router({
|
||||||
friendTransactions: languageProtectedProcedure
|
friendTransactions: languageProtectedProcedure
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export namespace DynamicContentEnum {
|
|||||||
my_pages_overview_shortcuts: "my_pages_overview_shortcuts",
|
my_pages_overview_shortcuts: "my_pages_overview_shortcuts",
|
||||||
my_points: "my_points",
|
my_points: "my_points",
|
||||||
next_benefits: "next_benefits",
|
next_benefits: "next_benefits",
|
||||||
|
next_stay: "next_stay",
|
||||||
overview_table: "overview_table",
|
overview_table: "overview_table",
|
||||||
points_overview: "points_overview",
|
points_overview: "points_overview",
|
||||||
previous_stays: "previous_stays",
|
previous_stays: "previous_stays",
|
||||||
|
|||||||
Reference in New Issue
Block a user