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

@@ -0,0 +1,165 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import ArrowFromIcon from "@/components/Icons/ArrowFrom"
import ArrowToIcon from "@/components/Icons/ArrowTo"
import Image from "@/components/Image"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import { getIntl } from "@/i18n"
import { TransferPointsFormClient } from "./TransferPointsFormClient"
import styles from "./transferPoints.module.css"
import type { Lang } from "@/constants/languages"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export async function TransferPointsForm({ lang }: { lang: Lang }) {
const profile = await getProfileSafely()
const scandicPoints = profile?.membership?.currentPoints ?? 0
// TODO get from api
await wait(1_000)
const sasPoints = 250_000
const exchangeRate = 2
return (
<TransferPointsFormContent
sasPoints={sasPoints}
scandicPoints={scandicPoints}
exchangeRate={exchangeRate}
lang={lang}
/>
)
}
export async function TransferPointsFormSkeleton({ lang }: { lang: Lang }) {
return (
<TransferPointsFormContent
sasPoints={null}
scandicPoints={null}
exchangeRate={null}
lang={lang}
/>
)
}
async function TransferPointsFormContent({
sasPoints,
scandicPoints,
exchangeRate,
lang,
}: {
sasPoints: number | null
scandicPoints: number | null
exchangeRate: number | null
lang: Lang
}) {
const intl = await getIntl()
return (
<div className={styles.container}>
<section className={styles.card}>
<div className={styles.highFive}>
<Image
src="/_static/img/scandic-high-five.svg"
alt=""
width="111"
height="139"
sizes="100vw"
/>
</div>
<div className={styles.transferContainer}>
<div className={styles.transferFrom}>
<div>
<div className={styles.labelWithIcon}>
<Typography variant="Tag/sm">
<p>{intl.formatMessage({ id: "Transfer from" })}</p>
</Typography>
<ArrowFromIcon />
</div>
<Typography variant="Title/Subtitle/md">
<p>{intl.formatMessage({ id: "SAS EuroBonus" })}</p>
</Typography>
</div>
<div>
<Typography variant="Tag/sm">
<p className={styles.balanceLabel}>
{intl.formatMessage({ id: "Balance" })}
</p>
</Typography>
{sasPoints === null ? (
<SkeletonShimmer width="10ch" height="20px" />
) : (
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{ id: "{points, number} p" },
{ points: sasPoints }
)}
</p>
</Typography>
)}
</div>
</div>
<div className={styles.noPointsWarning}>
{sasPoints === 0 && (
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "You have no points to transfer.",
})}
</p>
</Typography>
)}
</div>
<div className={styles.transferTo}>
<div>
<div className={styles.labelWithIcon}>
<ArrowToIcon />
<Typography variant="Tag/sm">
<p>{intl.formatMessage({ id: "Transfer to" })}</p>
</Typography>
</div>
<Typography variant="Title/Subtitle/md">
<p>{intl.formatMessage({ id: "Scandic Friends" })}</p>
</Typography>
</div>
<div>
<Typography variant="Tag/sm">
<p className={styles.balanceLabel}>
{intl.formatMessage({ id: "Balance" })}
</p>
</Typography>
{scandicPoints === null ? (
<SkeletonShimmer width="10ch" height="20px" />
) : (
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{ id: "{points, number} p" },
{ points: scandicPoints }
)}
</p>
</Typography>
)}
</div>
</div>
</div>
<TransferPointsFormClient
sasPoints={sasPoints}
exchangeRate={exchangeRate}
lang={lang}
/>
</section>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p style={{ color: "var(--Text-Tertiary)" }}>
{intl.formatMessage({
id: "Transferred points will not be level qualifying",
})}
</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,247 @@
"use client"
import Link from "next/link"
import { useParams } from "next/navigation"
import { useContext, useState } from "react"
import {
I18nProvider,
Slider,
SliderOutput,
SliderStateContext,
SliderThumb,
SliderTrack,
TextField,
} from "react-aria-components"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SAS_TRANSFER_POINT_KEY } from "@/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/sasUtils"
import SwipeIcon from "@/components/Icons/Swipe"
import Image from "@/components/Image"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import styles from "./transferPoints.module.css"
import type { LangParams } from "@/types/params"
import type { Lang } from "@/constants/languages"
type TransferPointsFormClientProps = {
sasPoints: number | null
exchangeRate: number | null
lang: Lang
}
export function TransferPointsFormClient({
sasPoints,
exchangeRate,
lang,
}: TransferPointsFormClientProps) {
const intl = useIntl()
const formMethods = useForm()
const [pointState, setPointState] = useState<number | null>(0)
const selectedPoints = pointState ?? 0
const disabled = !exchangeRate
const parsedPoints = Math.min(selectedPoints, sasPoints ?? 0)
const calculatedPoints = parsedPoints * (exchangeRate ?? 0)
const handleUpdatePoints = (points: number | null) => {
setPointState(points)
}
const hasNoSasPoints = !sasPoints || sasPoints === 0
return (
<FormProvider {...formMethods}>
<I18nProvider locale={lang}>
<Slider
value={parsedPoints}
onChange={handleUpdatePoints}
className={styles.slider}
// Set max value to 1 if sasPoints is 0 since slider requires a range
maxValue={hasNoSasPoints ? 1 : sasPoints}
aria-label={intl.formatMessage({ id: "EB points to transfer" })}
formatOptions={{
useGrouping: true,
maximumFractionDigits: 0,
}}
isDisabled={disabled || hasNoSasPoints}
>
<SliderTrack className={styles.sliderTrack}>
<SliderFill />
<SliderThumb className={styles.sliderThumb}>
<SliderOutput className={styles.sliderOutput} />
<SwipeIcon color="white" />
</SliderThumb>
</SliderTrack>
</Slider>
</I18nProvider>
<div className={styles.inputsWrapper}>
<TextField type="number" isDisabled={disabled}>
<AriaInputWithLabel
label={intl.formatMessage({ id: "EB points to transfer" })}
type="number"
min={0}
value={pointState ?? ""}
className={styles.pointsInput}
disabled={disabled}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
handleUpdatePoints(isNaN(value) ? null : value)
}}
onBlur={() => {
handleUpdatePoints(parsedPoints)
}}
/>
</TextField>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.conversionRate}>
{/* TODO maybe dynamic string based on exchange rate */}
{intl.formatMessage({
id: "1 EuroBonus point = 2 Scandic Friends points",
})}
</p>
</Typography>
<div className={styles.pointsOutput}>
<Typography variant="Label/xsRegular">
<p>{intl.formatMessage({ id: "SF points to receive" })}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{intl.formatNumber(calculatedPoints)}</p>
</Typography>
</div>
</div>
<ConfirmModal
disabled={disabled || calculatedPoints === 0}
sasPoints={parsedPoints}
scandicPoints={calculatedPoints}
/>
</FormProvider>
)
}
function SliderFill() {
const state = useContext(SliderStateContext)!
return (
<div
style={{
width: `${state.getThumbPercent(0) * 100}%`,
}}
className={styles.sliderFill}
/>
)
}
type ConfirmModalProps = {
sasPoints: number
scandicPoints: number
disabled?: boolean
}
function ConfirmModal({
sasPoints,
scandicPoints,
disabled,
}: ConfirmModalProps) {
const { lang } = useParams<LangParams>()
const [isOpen, setIsOpen] = useState(false)
const intl = useIntl()
const handleToggle = (open: boolean) => {
setIsOpen(open)
if (open) {
const expireIn15Minutes = new Date(
Date.now() + 15 * 60 * 1000
).toUTCString()
document.cookie = `${SAS_TRANSFER_POINT_KEY}=${JSON.stringify(sasPoints)};path=/;expires=${expireIn15Minutes}`
} else {
document.cookie = `${SAS_TRANSFER_POINT_KEY}=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT`
}
}
return (
<>
<Button
className={styles.transferButton}
onClick={() => handleToggle(true)}
disabled={disabled}
>
{intl.formatMessage({ id: "Transfer points" })}
</Button>
<Modal isOpen={isOpen} onToggle={handleToggle}>
<div className={styles.modalContainer}>
<Image
src="/_static/img/scandic-money-hand.svg"
alt=""
width="133"
height="119"
/>
<Typography variant="Title/Subtitle/lg">
<h3>
{intl.formatMessage({ id: "Proceed with point transfer?" })}
</h3>
</Typography>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>{intl.formatMessage({ id: "You are about to exchange:" })}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "<bold>{sasPoints, number} EuroBonus points</bold> to <bold>{scandicPoints, number} Scandic Friends points</bold>",
},
{
sasPoints,
scandicPoints,
bold: (text) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{text}</span>
</Typography>
),
}
)}
</p>
</Typography>
</div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.expiryText}>
{intl.formatMessage({
id: "Your exchanged points will retain their original expiry date with a maximum validity of 12 months.",
})}
</p>
</Typography>
<div className={styles.divider} />
<div className={styles.buttonContainer}>
<Button asChild theme="base" fullWidth>
<Link
href={`/${lang}/sas-x-scandic/login?intent=transfer`}
color="none"
>
{intl.formatMessage({
id: "Yes, I want to transfer my points",
})}
</Link>
</Button>
<Button
fullWidth
intent="text"
theme="base"
onClick={() => handleToggle(false)}
>
{intl.formatMessage({ id: "Cancel" })}
</Button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,41 @@
import { Suspense } from "react"
import { env } from "@/env/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import { getLang } from "@/i18n/serverContext"
import {
TransferPointsForm,
TransferPointsFormSkeleton,
} from "./TransferPointsForm"
type Props = {
title?: string
link?: { href: string; text: string }
subtitle?: string
}
export default async function SASTransferPoints({
title,
subtitle,
link,
}: Props) {
if (!env.SAS_ENABLED) {
return null
}
const lang = getLang()
return (
<SectionContainer>
<SectionHeader link={link} preamble={subtitle} title={title} />
<SectionLink link={link} variant="mobile" />
<Suspense fallback={<TransferPointsFormSkeleton lang={lang} />}>
<TransferPointsForm lang={lang} />
</Suspense>
</SectionContainer>
)
}

View File

@@ -0,0 +1,297 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
}
.card {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x3) var(--Spacing-x9);
background-color: var(--Background-Secondary);
border-radius: var(--Corner-radius-Large);
box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.1);
margin-top: var(--Spacing-x9);
}
.highFive {
height: 110px;
width: 110px;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
& > img {
position: absolute;
bottom: -5%;
width: 100%;
height: auto;
}
}
.labelWithIcon {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
color: var(--Text-Tertiary);
margin-bottom: var(--Spacing-x-half);
}
.transferContainer {
display: grid;
grid-template-columns: 1fr auto 1fr;
}
.transferFrom {
display: flex;
flex-direction: column;
}
.transferTo {
display: flex;
flex-direction: column;
text-align: right;
.labelWithIcon {
justify-content: flex-end;
}
}
.noPointsWarning {
align-self: end;
color: var(--Surface-Feedback-Information-Accent);
}
.balanceLabel {
color: var(--Text-Tertiary);
margin-top: var(--Spacing-x3);
}
.formWrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
width: 100%;
padding: var(--Spacing-x4);
padding-bottom: 0;
}
.slider {
color: var(--text-color);
width: 100%;
padding-top: 24px;
.sliderTrack {
position: relative;
height: 30px;
width: 100%;
&:before {
height: 10px;
width: 100%;
top: 50%;
transform: translateY(-50%);
content: "";
display: block;
position: absolute;
background-color: var(--Border-Divider-Accent);
border-radius: var(--Corner-radius-Small);
opacity: 0.3;
transition: background-color 300ms ease;
}
}
.sliderFill {
height: 10px;
background: linear-gradient(90deg, #8f4350 25.5%, #4d001b 100%);
position: relative;
top: 50%;
transform: translateY(-50%);
border-radius: var(--Corner-radius-Small);
}
.sliderOutput {
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%) translateY(4px);
background-color: var(--Surface-Brand-Primary-1-OnSurface-Default);
color: var(--Text-Brand-OnPrimary-2-Accent);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
opacity: 0;
transition:
opacity 0.15s,
transform 0.15s;
transition-delay: 0.1s;
}
.sliderThumb {
position: relative;
top: 50%;
width: 42px;
height: 42px;
border-radius: 50%;
background-color: var(--Surface-Brand-Primary-1-OnSurface-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: center;
align-items: center;
transition: background-color 300ms ease;
& > svg {
width: 22px;
height: 22px;
transition: opacity 300ms ease;
}
&[data-focus-visible] {
outline: 2px solid var(--Surface-Feedback-Neutral);
}
&[data-dragging] .sliderOutput {
opacity: 1;
transform: translateX(-50%) translateY(0px);
}
}
&[data-disabled] {
.sliderTrack:before {
background-color: var(--Border-Interactive-Disabled);
}
.sliderThumb {
background-color: var(--Surface-UI-Fill-Disabled);
box-shadow: none;
& > svg {
opacity: 0;
}
}
}
}
.pointsInput {
width: 100%;
}
.conversionRate {
color: var(--Text-Tertiary);
font-style: italic;
}
.inputsWrapper {
display: grid;
gap: 36px;
width: 100%;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.transferButton {
max-width: 260px;
width: 100%;
margin: 0 auto;
}
.modalContainer {
max-width: 512px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--Spacing-x3);
padding-inline: var(--Spacing-x3);
}
.expiryText {
color: var(--Text-Tertiary);
}
.divider {
background-color: var(--Border-Divider-Subtle);
width: calc(100% + var(--Spacing-x6) + var(--Spacing-x6));
height: 1px;
margin-inline: calc(var(--Spacing-x6) * -1);
}
.buttonContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.pointsOutput {
border-radius: var(--Corner-radius-Medium);
background-color: var(--Surface-Primary-Disabled);
height: 100%;
color: var(--Text-Tertiary);
padding: var(--Spacing-x1) var(--Spacing-x2);
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
@media screen and (max-width: 767px) {
.card {
padding: var(--Spacing-x3);
padding-top: var(--Spacing-x6);
}
.highFive {
height: 75px;
width: 75px;
}
.transferContainer {
grid-template-columns: 1fr;
gap: var(--Spacing-x4);
}
.transferFrom {
flex-direction: row;
justify-content: space-between;
}
.transferTo {
flex-direction: row;
justify-content: space-between;
text-align: left;
.labelWithIcon {
flex-direction: row-reverse;
}
}
.noPointsWarning {
grid-row: 3;
}
.balanceLabel {
margin-top: 0;
text-align: right;
}
.inputsWrapper {
grid-template-columns: 1fr 1fr;
column-gap: var(--Spacing-x1);
}
.conversionRate {
grid-row: 2;
grid-column: span 2;
text-align: center;
}
.slider {
padding-top: 0;
}
.modalContainer {
padding-inline: var(--Spacing-x2);
}
}