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:
@@ -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({})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user