Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
|
||||
import ScriptedRewardText from "@/components/Blocks/DynamicContent/Rewards/ScriptedRewardText"
|
||||
import Pagination from "@/components/MyPages/Pagination"
|
||||
import Grids from "@/components/TempDesignSystem/Grids"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import Redeem from "../Redeem"
|
||||
|
||||
import styles from "./current.module.css"
|
||||
|
||||
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
import type {
|
||||
Reward,
|
||||
RewardWithRedeem,
|
||||
} from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
export default function ClientCurrentRewards({
|
||||
rewards: initialData,
|
||||
pageSize,
|
||||
showRedeem,
|
||||
membershipNumber,
|
||||
}: CurrentRewardsClientProps) {
|
||||
const lang = useLang()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const { data } = trpc.contentstack.rewards.current.useQuery<{
|
||||
rewards: (Reward | RewardWithRedeem)[]
|
||||
}>(
|
||||
{
|
||||
lang,
|
||||
},
|
||||
{
|
||||
initialData: { rewards: initialData },
|
||||
}
|
||||
)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rewards = data.rewards
|
||||
|
||||
const totalPages = Math.ceil(rewards.length / pageSize)
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const currentRewards = rewards.slice(startIndex, endIndex)
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentPage(page)
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.container}>
|
||||
<Grids.Stackable>
|
||||
{currentRewards.map((reward, idx) => (
|
||||
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
|
||||
<div className={styles.content}>
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
{showRedeem && (
|
||||
<ScriptedRewardText
|
||||
rewardType={reward.rewardType}
|
||||
rewardTierLevel={reward.rewardTierLevel}
|
||||
/>
|
||||
)}
|
||||
<Title
|
||||
as="h4"
|
||||
level="h3"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
>
|
||||
{reward.label}
|
||||
</Title>
|
||||
</div>
|
||||
{showRedeem && "redeem_description" in reward && (
|
||||
<div className={styles.btnContainer}>
|
||||
<Redeem reward={reward} membershipNumber={membershipNumber} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
position: relative;
|
||||
scroll-margin-top: calc(var(--current-mobile-site-header-height) * 2);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--UI-Opacity-White-100);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.btnContainer {
|
||||
padding: 0 var(--Spacing-x3) var(--Spacing-x3);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { env } from "@/env/server"
|
||||
import {
|
||||
getCurrentRewards,
|
||||
getMembershipLevel,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import SectionLink from "@/components/Section/Link"
|
||||
|
||||
import ClientCurrentRewards from "./Client"
|
||||
|
||||
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
|
||||
export default async function CurrentRewardsBlock({
|
||||
title,
|
||||
subtitle,
|
||||
link,
|
||||
}: AccountPageComponentProps) {
|
||||
const [rewardsResponse, membershipLevel] = await Promise.all([
|
||||
getCurrentRewards(),
|
||||
getMembershipLevel(),
|
||||
])
|
||||
|
||||
if (!rewardsResponse?.rewards.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeader title={title} link={link} preamble={subtitle} />
|
||||
<ClientCurrentRewards
|
||||
rewards={rewardsResponse.rewards}
|
||||
pageSize={6}
|
||||
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
|
||||
membershipNumber={membershipLevel?.membershipNumber}
|
||||
/>
|
||||
<SectionLink link={link} variant="mobile" />
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Lock } from "react-feather"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import { getMembershipLevel } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import SectionLink from "@/components/Section/Link"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
import Grids from "@/components/TempDesignSystem/Grids"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./next.module.css"
|
||||
|
||||
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
|
||||
export default async function NextLevelRewardsBlock({
|
||||
title,
|
||||
subtitle,
|
||||
link,
|
||||
}: AccountPageComponentProps) {
|
||||
const intl = await getIntl()
|
||||
const membershipLevel = await getMembershipLevel()
|
||||
|
||||
if (!membershipLevel || !membershipLevel?.nextLevel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextLevelRewards = await serverClient().contentstack.rewards.byLevel({
|
||||
level_id: MembershipLevelEnum[membershipLevel?.nextLevel],
|
||||
unique: true,
|
||||
})
|
||||
|
||||
// TODO: handle this case, when missing or when user is top level?
|
||||
if (!nextLevelRewards) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeader title={title} preamble={subtitle} link={link} />
|
||||
<Grids.Stackable columns={2}>
|
||||
{nextLevelRewards.rewards.map((reward) => (
|
||||
<article key={reward.reward_id} className={styles.card}>
|
||||
<Chip>
|
||||
<Lock height={16} />
|
||||
{intl.formatMessage({ id: "Level up to unlock" })}
|
||||
</Chip>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="peach50" textAlign="center">
|
||||
{intl.formatMessage(
|
||||
{ id: "As our {level}" },
|
||||
{ level: nextLevelRewards.level?.name }
|
||||
)}
|
||||
</Body>
|
||||
<Title level="h4" as="h4" color="pale" textAlign="center">
|
||||
{reward.label}
|
||||
</Title>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
<SectionLink link={link} variant="mobile" />
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.card {
|
||||
align-items: center;
|
||||
background-color: var(--Scandic-Brand-Burgundy);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x7);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckCircleIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./redeem.module.css"
|
||||
|
||||
export default function ActiveRedeemedBadge() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.redeemed}>
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: [1, 0.4, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
</motion.div>
|
||||
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import useRedeemFlow from "./useRedeemFlow"
|
||||
|
||||
import styles from "./redeem.module.css"
|
||||
|
||||
export function ConfirmClose({ close }: { close: VoidFunction }) {
|
||||
const intl = useIntl()
|
||||
const { setRedeemStep } = useRedeemFlow()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalContent}>
|
||||
<Title level="h3" textAlign="center" textTransform="regular">
|
||||
{intl.formatMessage({
|
||||
id: "If you close this your benefit will be removed",
|
||||
})}
|
||||
</Title>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Have you showed this benefit to the hotel staff?",
|
||||
})}
|
||||
</Body>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<footer className={styles.modalFooter}>
|
||||
<Button
|
||||
onClick={() => setRedeemStep("redeemed")}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "No, go back" })}
|
||||
</Button>
|
||||
<Button onClick={close} intent="secondary" theme="base">
|
||||
{intl.formatMessage({ id: "Yes, close and remove benefit" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import CopyIcon from "@/components/Icons/Copy"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import { RewardIcon } from "../../RewardIcon"
|
||||
import useRedeemFlow from "../useRedeemFlow"
|
||||
|
||||
import styles from "../redeem.module.css"
|
||||
|
||||
export default function Campaign() {
|
||||
const { reward } = useRedeemFlow()
|
||||
const intl = useIntl()
|
||||
|
||||
if (!reward) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalContent}>
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
<Title level="h3" textAlign="center" textTransform="regular">
|
||||
{reward.label}
|
||||
</Title>
|
||||
<Body textAlign="center">{reward.description}</Body>
|
||||
<div className={styles.rewardBadge}>
|
||||
<Caption textAlign="center" color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "Promo code" })}
|
||||
</Caption>
|
||||
<Caption textAlign="center" color="uiTextHighContrast">
|
||||
{reward.operaRewardId}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={styles.modalFooter}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(reward.operaRewardId)
|
||||
toast.success(intl.formatMessage({ id: "Copied to clipboard" }))
|
||||
}}
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="small"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
>
|
||||
<CopyIcon color="pale" />
|
||||
{intl.formatMessage({ id: "Copy promotion code" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import JsonToHtml from "@/components/JsonToHtml"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { isRestaurantOnSiteTierReward } from "@/utils/rewards"
|
||||
|
||||
import { RewardIcon } from "../../RewardIcon"
|
||||
import ActiveRedeemedBadge from "../ActiveRedeemedBadge"
|
||||
import MembershipNumberBadge from "../MembershipNumberBadge"
|
||||
import TimedRedeemedBadge from "../TimedRedeemedBadge"
|
||||
import useRedeemFlow from "../useRedeemFlow"
|
||||
|
||||
import styles from "../redeem.module.css"
|
||||
|
||||
export default function Tier({
|
||||
membershipNumber,
|
||||
}: {
|
||||
membershipNumber: string
|
||||
}) {
|
||||
const { reward, onRedeem, redeemStep, setRedeemStep, isRedeeming } =
|
||||
useRedeemFlow()
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
if (!reward) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalContent}>
|
||||
{redeemStep === "redeemed" && (
|
||||
<div className={styles.badge}>
|
||||
{isRestaurantOnSiteTierReward(reward) ? (
|
||||
<ActiveRedeemedBadge />
|
||||
) : (
|
||||
<TimedRedeemedBadge />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
<Title level="h3" textAlign="center" textTransform="regular">
|
||||
{reward.label}
|
||||
</Title>
|
||||
|
||||
{reward.redeemLocation !== "Non-redeemable" ? (
|
||||
<>
|
||||
{redeemStep === "initial" && (
|
||||
<Body textAlign="center">{reward.description}</Body>
|
||||
)}
|
||||
|
||||
{redeemStep === "confirmation" && (
|
||||
<JsonToHtml
|
||||
embeds={
|
||||
reward.redeem_description.embedded_itemsConnection.edges
|
||||
}
|
||||
nodes={reward.redeem_description.json.children}
|
||||
/>
|
||||
)}
|
||||
|
||||
{redeemStep === "redeemed" &&
|
||||
isRestaurantOnSiteTierReward(reward) &&
|
||||
membershipNumber && (
|
||||
<MembershipNumberBadge membershipNumber={membershipNumber} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<JsonToHtml
|
||||
embeds={reward.redeem_description.embedded_itemsConnection.edges}
|
||||
nodes={reward.redeem_description.json.children}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reward.redeemLocation !== "Non-redeemable" ? (
|
||||
<>
|
||||
{redeemStep === "initial" && (
|
||||
<footer className={styles.modalFooter}>
|
||||
<Button
|
||||
onClick={() => setRedeemStep("confirmation")}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Redeem benefit" })}
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{redeemStep === "confirmation" && (
|
||||
<footer className={styles.modalFooter}>
|
||||
<Button
|
||||
onClick={onRedeem}
|
||||
disabled={isRedeeming}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Yes, redeem" })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRedeemStep("initial")}
|
||||
intent="secondary"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Go back" })}
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./redeem.module.css"
|
||||
|
||||
export default function MembershipNumberBadge({
|
||||
membershipNumber,
|
||||
}: {
|
||||
membershipNumber: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.rewardBadge}>
|
||||
<Caption textAlign="center" color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Membership ID: {id}" },
|
||||
{ id: membershipNumber }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Countdown from "@/components/Countdown"
|
||||
import { CheckCircleIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import useRedeemFlow from "./useRedeemFlow"
|
||||
|
||||
import styles from "./redeem.module.css"
|
||||
|
||||
export default function TimedRedeemedBadge() {
|
||||
const intl = useIntl()
|
||||
const { timeRemaining, setTimeRemaining } = useRedeemFlow()
|
||||
const duration = dt.duration(timeRemaining)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.redeemed}>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "Redeemed & valid through:",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Countdown
|
||||
minutes={duration.minutes()}
|
||||
seconds={duration.seconds()}
|
||||
onChange={(newTime) => setTimeRemaining(newTime)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import Campaign from "./Flows/Campaign"
|
||||
import Tier from "./Flows/Tier"
|
||||
import { ConfirmClose } from "./ConfirmClose"
|
||||
import { RedeemContext } from "./useRedeemFlow"
|
||||
|
||||
import styles from "./redeem.module.css"
|
||||
|
||||
import type {
|
||||
RedeemModalState,
|
||||
RedeemProps,
|
||||
RedeemStep,
|
||||
} from "@/types/components/myPages/myPage/accountPage"
|
||||
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
const MotionOverlay = motion(ModalOverlay)
|
||||
const MotionModal = motion(Modal)
|
||||
|
||||
const thirtyMinutesInMs = 1000 * 60 * 30
|
||||
|
||||
export default function Redeem({ reward, membershipNumber }: RedeemProps) {
|
||||
const [animation, setAnimation] = useState<RedeemModalState>("unmounted")
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const utils = trpc.useUtils()
|
||||
const [redeemStep, setRedeemStep] = useState<RedeemStep>("initial")
|
||||
const [timeRemaining, setTimeRemaining] = useState(thirtyMinutesInMs)
|
||||
|
||||
function modalStateHandler(newAnimationState: RedeemModalState) {
|
||||
setAnimation((currentAnimationState) =>
|
||||
newAnimationState === "hidden" && currentAnimationState === "hidden"
|
||||
? "unmounted"
|
||||
: currentAnimationState
|
||||
)
|
||||
if (newAnimationState === "unmounted") {
|
||||
setRedeemStep("initial")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RedeemContext.Provider
|
||||
value={{
|
||||
reward,
|
||||
redeemStep,
|
||||
setRedeemStep,
|
||||
defaultTimeRemaining: thirtyMinutesInMs,
|
||||
timeRemaining,
|
||||
setTimeRemaining,
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
|
||||
>
|
||||
<Button intent="primary" fullWidth>
|
||||
{reward.redeemLocation === "Non-redeemable"
|
||||
? intl.formatMessage({ id: "How to use" })
|
||||
: intl.formatMessage({ id: "Open" })}
|
||||
</Button>
|
||||
<MotionOverlay
|
||||
className={styles.overlay}
|
||||
isExiting={animation === "hidden"}
|
||||
onAnimationComplete={modalStateHandler}
|
||||
variants={variants.fade}
|
||||
initial="hidden"
|
||||
animate={animation}
|
||||
>
|
||||
<MotionModal
|
||||
className={styles.modal}
|
||||
variants={variants.slideInOut}
|
||||
initial="hidden"
|
||||
animate={animation}
|
||||
>
|
||||
<Dialog className={styles.dialog} aria-label={reward.label}>
|
||||
{({ close }) => {
|
||||
function closeModal() {
|
||||
if (
|
||||
redeemStep === "redeemed" ||
|
||||
redeemStep === "confirm-close"
|
||||
) {
|
||||
utils.contentstack.rewards.current.invalidate({
|
||||
lang,
|
||||
})
|
||||
}
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<header className={styles.modalHeader}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (redeemStep === "redeemed") {
|
||||
setRedeemStep("confirm-close")
|
||||
} else {
|
||||
closeModal()
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
className={styles.modalClose}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{redeemStep === "confirm-close" ? (
|
||||
<ConfirmClose close={closeModal} />
|
||||
) : (
|
||||
getRedeemFlow(reward, membershipNumber || "")
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</MotionModal>
|
||||
</MotionOverlay>
|
||||
</DialogTrigger>
|
||||
</RedeemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const variants = {
|
||||
fade: {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.4, ease: "easeInOut" },
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.4, ease: "easeInOut" },
|
||||
},
|
||||
},
|
||||
|
||||
slideInOut: {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 32,
|
||||
transition: { duration: 0.4, ease: "easeInOut" },
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: "easeInOut" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) {
|
||||
switch (reward.rewardType) {
|
||||
case "Campaign":
|
||||
return <Campaign />
|
||||
case "Surprise":
|
||||
case "Tier":
|
||||
return <Tier membershipNumber={membershipNumber} />
|
||||
default:
|
||||
console.warn("Unsupported reward type for redeem:", reward.rewardType)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.badge {
|
||||
border-radius: var(--Small, 4px);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
display: flex;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeemed {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
height: var(--visual-viewport-height);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modal {
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
--button-height: 32px;
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--button-height);
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x3) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--Spacing-x3) var(--Spacing-x1);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.modalFooter > button {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: var(--Spacing-x2);
|
||||
width: 32px;
|
||||
height: var(--button-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
color: var(--UI-Semantic-Success);
|
||||
}
|
||||
|
||||
.rewardBadge {
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
background: var(--Base-Surface-Secondary-light-Normal);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect } from "react"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage"
|
||||
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
export const RedeemContext = createContext<RedeemFlowContext>({
|
||||
reward: null,
|
||||
redeemStep: "initial",
|
||||
setRedeemStep: () => undefined,
|
||||
defaultTimeRemaining: 0,
|
||||
timeRemaining: 0,
|
||||
setTimeRemaining: () => undefined,
|
||||
})
|
||||
|
||||
export default function useRedeemFlow() {
|
||||
const {
|
||||
reward,
|
||||
redeemStep,
|
||||
setRedeemStep,
|
||||
defaultTimeRemaining,
|
||||
timeRemaining,
|
||||
setTimeRemaining,
|
||||
} = useContext(RedeemContext)
|
||||
|
||||
const update = trpc.contentstack.rewards.redeem.useMutation<{
|
||||
rewards: RewardWithRedeem[]
|
||||
}>()
|
||||
|
||||
const onRedeem = useCallback(() => {
|
||||
if (reward?.id) {
|
||||
update.mutate(
|
||||
{ rewardId: reward.id, couponCode: reward.couponCode },
|
||||
{
|
||||
onSuccess() {
|
||||
setRedeemStep("redeemed")
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Failed to redeem", error)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [reward, update, setRedeemStep])
|
||||
|
||||
useEffect(() => {
|
||||
if (redeemStep === "initial") {
|
||||
setTimeRemaining(defaultTimeRemaining)
|
||||
}
|
||||
}, [redeemStep, setTimeRemaining, defaultTimeRemaining])
|
||||
|
||||
return {
|
||||
reward,
|
||||
onRedeem,
|
||||
redeemStep,
|
||||
setRedeemStep,
|
||||
isRedeeming: update.isPending,
|
||||
timeRemaining,
|
||||
setTimeRemaining,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { REWARD_IDS } from "@/constants/rewards"
|
||||
|
||||
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
|
||||
import { isValidRewardId } from "@/utils/rewards"
|
||||
|
||||
import type { FC } from "react"
|
||||
|
||||
import { IconName, type IconProps } from "@/types/components/icon"
|
||||
import type { RewardId } from "@/types/components/myPages/rewards"
|
||||
|
||||
function getIconForRewardId(rewardId: RewardId): IconName {
|
||||
switch (rewardId) {
|
||||
// Food & beverage
|
||||
case REWARD_IDS.TenPercentFood:
|
||||
case REWARD_IDS.FifteenPercentFood:
|
||||
return IconName.CroissantCoffeeEgg
|
||||
case REWARD_IDS.TwoForOneBreakfast:
|
||||
return IconName.CutleryTwo
|
||||
case REWARD_IDS.FreeBreakfast:
|
||||
return IconName.CutleryOne
|
||||
case REWARD_IDS.FreeKidsDrink:
|
||||
return IconName.KidsMocktail
|
||||
|
||||
// Monetary vouchers
|
||||
case REWARD_IDS.Bonus50SEK:
|
||||
case REWARD_IDS.Bonus75SEK:
|
||||
case REWARD_IDS.Bonus100SEK:
|
||||
case REWARD_IDS.Bonus150SEK:
|
||||
case REWARD_IDS.Bonus200SEK:
|
||||
return IconName.Voucher
|
||||
|
||||
// Hotel perks
|
||||
case REWARD_IDS.EarlyCheckin:
|
||||
return IconName.HandKey
|
||||
case REWARD_IDS.LateCheckout:
|
||||
return IconName.HotelNight
|
||||
case REWARD_IDS.FreeUpgrade:
|
||||
return IconName.MagicWand
|
||||
case REWARD_IDS.RoomGuarantee48H:
|
||||
return IconName.Bed
|
||||
|
||||
// Earnings
|
||||
case REWARD_IDS.EarnRate25Percent:
|
||||
case REWARD_IDS.EarnRate50Percent:
|
||||
return IconName.MoneyHand
|
||||
case REWARD_IDS.StayBoostForKids:
|
||||
return IconName.Kids
|
||||
case REWARD_IDS.MemberRate:
|
||||
return IconName.Coin
|
||||
|
||||
// Special
|
||||
case REWARD_IDS.YearlyExclusiveGift:
|
||||
return IconName.GiftOpen
|
||||
|
||||
default: {
|
||||
return IconName.GiftOpen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRewardToIcon(rewardId: string): FC<IconProps> | null {
|
||||
if (!isValidRewardId(rewardId)) {
|
||||
// TODO: Update once UX has decided on fallback icon.
|
||||
return getIconByIconName(IconName.GiftOpen)
|
||||
}
|
||||
|
||||
const iconName = getIconForRewardId(rewardId)
|
||||
return getIconByIconName(iconName)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mapRewardToIcon } from "./data"
|
||||
|
||||
import type { RewardIconProps } from "@/types/components/myPages/rewards"
|
||||
|
||||
// Original SVG aspect ratio is 358:202 (≈1.77:1)
|
||||
const sizeMap = {
|
||||
small: { width: 120, height: 68 }, // 40% of card width
|
||||
medium: { width: 180, height: 102 }, // 60% of card width
|
||||
large: { width: 240, height: 135 }, // 80% of card width
|
||||
} as const
|
||||
|
||||
export function RewardIcon({
|
||||
rewardId,
|
||||
size = "medium",
|
||||
...props
|
||||
}: RewardIconProps) {
|
||||
const IconComponent = mapRewardToIcon(rewardId)
|
||||
if (!IconComponent) return null
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
{...props}
|
||||
width={sizeMap[size].width}
|
||||
height={sizeMap[size].height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
|
||||
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import { isMembershipLevel } from "@/utils/membershipLevels"
|
||||
import { getRewardType } from "@/utils/rewards"
|
||||
|
||||
import type { ScriptedRewardTextProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
|
||||
export default function ScriptedRewardText({
|
||||
rewardType,
|
||||
rewardTierLevel,
|
||||
}: ScriptedRewardTextProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
function getLabel(rewardType?: string, rewardTierLevel?: string) {
|
||||
const type = getRewardType(rewardType)
|
||||
|
||||
switch (type) {
|
||||
case "Tier":
|
||||
return rewardTierLevel && isMembershipLevel(rewardTierLevel)
|
||||
? TIER_TO_FRIEND_MAP[rewardTierLevel]
|
||||
: null
|
||||
case "Campaign":
|
||||
return intl.formatMessage({ id: "Campaign" })
|
||||
case "Surprise":
|
||||
return intl.formatMessage({ id: "Surprise!" })
|
||||
case "Member-voucher":
|
||||
return intl.formatMessage({ id: "Voucher" })
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const label = getLabel(rewardType, rewardTierLevel)
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return (
|
||||
<BiroScript type="two" color="red" tilted="small">
|
||||
{label}
|
||||
</BiroScript>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user