Merged in feat/sw-1314-transfer-sas-points (pull request #1508)

SW-1314 Transfer SAS points

Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-03-18 10:07:05 +00:00
parent d4fe8baa49
commit d0b6f3f8b3
32 changed files with 1799 additions and 12 deletions

View File

@@ -80,7 +80,8 @@ export async function GET(
if (
stateResult.data.intent === "link" ||
stateResult.data.intent === "unlink"
stateResult.data.intent === "unlink" ||
stateResult.data.intent === "transfer"
) {
const [data, error] = await safeTry(
serverClient().partner.sas.requestOtp({})

View File

@@ -20,7 +20,7 @@ import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { State } from "../sasUtils"
const searchParamsSchema = z.object({
intent: z.enum(["link", "unlink"]),
intent: z.enum(["link", "unlink", "transfer"]),
})
type Intent = z.infer<typeof searchParamsSchema>["intent"]
@@ -58,6 +58,9 @@ export default async function SASxScandicLoginPage({
unlink: intl.formatMessage({
id: "Log in to your SAS Eurobonus account to confirm account unlinking.",
}),
transfer: intl.formatMessage({
id: "In order to transfer your points we will ask you to sign in to your SAS EuroBonus account.",
}),
}
return (

View File

@@ -8,7 +8,7 @@ import { serverClient } from "@/lib/trpc/server"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { SAS_TOKEN_STORAGE_KEY } from "../sasUtils"
import { SAS_TOKEN_STORAGE_KEY, SAS_TRANSFER_POINT_KEY } from "../sasUtils"
import OneTimePasswordForm, {
type OnSubmitHandler,
} from "./OneTimePasswordForm"
@@ -19,7 +19,7 @@ import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { Lang } from "@/constants/languages"
const otpError = z.enum(["invalidCode", "expiredCode"])
const intent = z.enum(["link", "unlink"])
const intent = z.enum(["link", "unlink", "transfer"])
const searchParamsSchema = z.object({
intent: intent,
to: z.string(),
@@ -95,6 +95,8 @@ export default async function SASxScandicOneTimePasswordPage({
return handleLinkAccount({ lang: params.lang })
case "unlink":
return handleUnlinkAccount({ lang: params.lang })
case "transfer":
return handleTransferPoints({ lang: params.lang })
}
}
@@ -118,6 +120,12 @@ export default async function SASxScandicOneTimePasswordPage({
},
{ maskedContactInfo }
),
transfer: intl.formatMessage(
{
id: "Please enter the code sent to <maskedContactInfo></maskedContactInfo> in order to transfer your points.",
},
{ maskedContactInfo }
),
}
return (
@@ -215,3 +223,44 @@ async function handleUnlinkAccount({
}
}
}
async function handleTransferPoints({
lang,
}: {
lang: Lang
}): ReturnType<OnSubmitHandler> {
const cookieStore = cookies()
const pointsCookie = cookieStore.get(SAS_TRANSFER_POINT_KEY)
const points = Number(pointsCookie?.value)
cookieStore.delete(SAS_TRANSFER_POINT_KEY)
if (!pointsCookie || !points || isNaN(points)) {
return {
url: `/${lang}/sas-x-scandic/error`,
type: "replace",
}
}
const [res, error] = await safeTry(
serverClient().partner.sas.transferPoints({
points,
})
)
if (!res || error) {
console.error("[SAS] transfer points error", error)
return {
// TODO better errors?
url: `/${lang}/sas-x-scandic/error`,
type: "replace",
}
}
console.log("[SAS] transfer points response", res)
return {
url: `/${lang}/sas-x-scandic/transfer/success?p=${points}`,
type: "replace",
}
}

View File

@@ -1,8 +1,9 @@
import { z } from "zod"
export const SAS_TOKEN_STORAGE_KEY = "sas-x-scandic-token"
export const SAS_TRANSFER_POINT_KEY = "sas-x-scandic-eb-points"
export const stateSchema = z.object({
intent: z.enum(["link", "unlink"]),
intent: z.enum(["link", "unlink", "transfer"]),
})
export type State = z.infer<typeof stateSchema>

View File

@@ -0,0 +1,159 @@
import Link from "next/link"
import React, { Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { partnerSas } from "@/constants/routes/myPages"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { CalendarAddIcon } from "@/components/Icons"
import Image from "@/components/Image"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button"
import { getIntl } from "@/i18n"
import { SASModal } from "../../components/SASModal"
import styles from "./transferSuccess.module.css"
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { Lang } from "@/constants/languages"
export default async function SASxScandicTransferSuccessPage({
params,
searchParams,
}: PageArgs<LangParams> & SearchParams<{ p?: string }>) {
const intl = await getIntl()
const { lang } = params
const addedPoints = Number(searchParams.p) || 0
return (
<SASModal>
<Image
src="/_static/img/scandic-high-five.svg"
alt=""
width="111"
height="139"
sizes="100vw"
className={styles.image}
/>
<div className={styles.container}>
<Typography variant="Title/Subtitle/lg">
<h1>{intl.formatMessage({ id: "Point transfer completed!" })}</h1>
</Typography>
<TransactionCard addedPoints={addedPoints} lang={lang} />
<div className={styles.divider} />
<Button
theme="primaryLight"
asChild
fullWidth
className={styles.backButton}
>
<Link href={partnerSas[params.lang]} color="none">
{intl.formatMessage({
id: "Go back to My Pages",
})}
</Link>
</Button>
</div>
</SASModal>
)
}
async function TransactionCard({
addedPoints,
lang,
}: {
addedPoints: number
lang: Lang
}) {
const intl = await getIntl()
const profile = await getProfileSafely()
const transferredPoints = intl.formatNumber(addedPoints)
// TODO is this updated immediately?
const totalPoints = profile?.membership?.currentPoints ?? 0
const showBonusNight = totalPoints > 10_000
return (
<div className={styles.transactionBox}>
<Typography variant="Title/Subtitle/md">
<h2>{intl.formatMessage({ id: "Your transaction" })}</h2>
</Typography>
<div className={styles.transactionDetails}>
<div className={styles.transactionRow}>
<Typography variant="Title/Overline/sm">
<h3>{intl.formatMessage({ id: "Points added" })}</h3>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>+ {transferredPoints}</p>
</Typography>
</div>
<div className={styles.transactionRow}>
<Typography variant="Title/Overline/sm">
<h3>{intl.formatMessage({ id: "Your new total" })}</h3>
</Typography>
<Suspense fallback={<SkeletonShimmer width="15ch" height="24px" />}>
<TotalPoints />
</Suspense>
</div>
</div>
{showBonusNight && (
<div className={styles.bonusNight}>
<div className={styles.bonusNightDetails}>
<Typography variant="Body/Paragraph/mdBold">
<h3>
{intl.formatMessage({
id: "You have enough points for a bonus night!",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "Bonus Nights range from 10 000 - 80 000 points. Book your next stay with us today!",
})}
</p>
</Typography>
</div>
<Button
theme="primaryLight"
intent="secondary"
size="small"
fullWidth
className={styles.bookButton}
asChild
>
{/* TODO correct link */}
<Link href={hotelreservation(lang)} color="none">
{intl.formatMessage({ id: "Book now" })}{" "}
<CalendarAddIcon width={20} height={20} color="currentColor" />
</Link>
</Button>
</div>
)}
</div>
)
}
async function TotalPoints() {
const intl = await getIntl()
const profile = await getProfileSafely()
const points = profile?.membership?.currentPoints ?? 0
return (
<Typography variant="Body/Paragraph/mdBold">
<p>
={" "}
{intl.formatMessage(
{ id: "{points, number} points" },
{
points,
}
)}
</p>
</Typography>
)
}

View File

@@ -0,0 +1,79 @@
.image {
width: 120px;
height: auto;
}
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
align-items: center;
width: 100%;
}
.transactionBox {
box-shadow: 0px 0px 14px 6px #0000001a;
padding: var(--Spacing-x4) var(--Spacing-x3);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
border-radius: var(--Corner-radius-Medium);
align-items: center;
width: 100%;
}
.transactionDetails {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
width: 100%;
}
.transactionRow {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--Text-Secondary);
}
.bonusNight {
border-top: 1px solid var(--Border-Divider-Default);
padding-top: var(--Spacing-x3);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
align-items: center;
}
.bonusNightDetails {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.divider {
--divider-spacing: var(--Spacing-x3);
background: var(--Border-Divider-Subtle);
width: calc(100% + var(--divider-spacing) + var(--divider-spacing));
margin-inline: calc(var(--divider-spacing) * -1);
height: 1px;
}
@media screen and (min-width: 768px) {
.container {
padding-inline: var(--Spacing-x3);
}
.divider {
--divider-spacing: var(--Spacing-x6);
}
.bookButton {
max-width: 325px;
}
.backButton {
max-width: 350px;
}
}