diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 761682e83..ac42942e0 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -17,6 +17,7 @@ export default function ClientCurrentRewards({ rewards, pageSize, showRedeem, + memberId, }: CurrentRewardsClientProps) { const containerRef = useRef(null) const [currentPage, setCurrentPage] = useState(1) @@ -55,7 +56,7 @@ export default function ClientCurrentRewards({ {showRedeem && "redeem_description" in reward && (
- +
)} diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx index 5686ad8a0..734ee56f5 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx @@ -1,6 +1,6 @@ "use client" -import { motion } from "framer-motion" +import { AnimatePresence, motion } from "framer-motion" import { useState } from "react" import { Dialog, @@ -11,6 +11,7 @@ import { import { useIntl } from "react-intl" import { trpc } from "@/lib/trpc/client" +import { Reward } from "@/server/routers/contentstack/reward/output" import Countdown from "@/components/Countdown" import { CheckCircleIcon, CloseLargeIcon } from "@/components/Icons" @@ -18,6 +19,7 @@ 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 { isRestaurantOnSiteTierReward } from "@/utils/rewards" import { RewardIcon } from "../RewardIcon" @@ -32,7 +34,7 @@ import type { const MotionOverlay = motion(ModalOverlay) const MotionModal = motion(Modal) -export default function Redeem({ reward }: RedeemProps) { +export default function Redeem({ reward, memberId }: RedeemProps) { const [animation, setAnimation] = useState("unmounted") const intl = useIntl() const update = trpc.contentstack.rewards.redeem.useMutation() @@ -100,17 +102,7 @@ export default function Redeem({ reward }: RedeemProps) {
{redeemStep === "redeemed" && ( -
-
- - - {intl.formatMessage({ - id: "Redeemed & valid through:", - })} - -
- -
+ )} @@ -127,6 +119,18 @@ export default function Redeem({ reward }: RedeemProps) { {reward.redeem_description} </Body> )} + {redeemStep === "redeemed" && + isRestaurantOnSiteTierReward(reward) && ( + <div className={styles.memberIdBadge}> + <Caption + textTransform="uppercase" + textAlign="center" + color="uiTextHighContrast" + > + {intl.formatMessage({ id: "Member ID:" })} {memberId} + </Caption> + </div> + )} </div> {redeemStep === "initial" && ( <footer className={styles.modalFooter}> @@ -189,3 +193,55 @@ const variants = { }, }, } + +function ConfirmationBadge({ reward }: { reward: Reward }) { + return ( + <div className={styles.badge}> + {isRestaurantOnSiteTierReward(reward) ? ( + <ActiveRedeemedBadge /> + ) : ( + <TimedRedeemedBadge /> + )} + </div> + ) +} + +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> + ) +} + +function TimedRedeemedBadge() { + const intl = useIntl() + + return ( + <> + <div className={styles.redeemed}> + <CheckCircleIcon color="uiSemanticSuccess" /> + <Caption> + {intl.formatMessage({ + id: "Redeemed & valid through:", + })} + </Caption> + </div> + <Countdown /> + </> + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css index 5c70c1022..4ce8508e8 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css @@ -36,6 +36,7 @@ flex-direction: column; justify-content: center; align-items: center; + min-height: 48px; } .redeemed { @@ -129,4 +130,17 @@ 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); +} + +.memberIdBadge { + border-radius: var(--Small); + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + background: var(--Base-Surface-Secondary-light-Normal); } \ No newline at end of file diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx index 8dd47b612..b78d940fc 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx @@ -1,4 +1,5 @@ import { env } from "@/env/server" +import { getMembershipLevel } from "@/lib/trpc/memoizedRequests" import { serverClient } from "@/lib/trpc/server" import SectionContainer from "@/components/Section/Container" @@ -14,7 +15,10 @@ export default async function CurrentRewardsBlock({ subtitle, link, }: AccountPageComponentProps) { - const rewardsResponse = await serverClient().contentstack.rewards.current() + const [rewardsResponse, membershipLevel] = await Promise.all([ + serverClient().contentstack.rewards.current(), + getMembershipLevel(), + ]) if (!rewardsResponse?.rewards.length) { return null @@ -27,6 +31,7 @@ export default async function CurrentRewardsBlock({ rewards={rewardsResponse.rewards} pageSize={6} showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL} + memberId={membershipLevel?.membershipNumber} /> <SectionLink link={link} variant="mobile" /> </SectionContainer> diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index ab139c66b..6021dd963 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -13,6 +13,7 @@ "Accept new price": "Accepter ny pris", "Accessibility": "Tilgængelighed", "Accessible Room": "Tilgængelighedsrum", + "Active": "Aktiv", "Activities": "Aktiviteter", "Add Room": "Tilføj værelse", "Add code": "Tilføj kode", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index fad304104..a7d4bec54 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -13,6 +13,7 @@ "Accept new price": "Neuen Preis akzeptieren", "Accessibility": "Zugänglichkeit", "Accessible Room": "Barrierefreies Zimmer", + "Active": "Aktiv", "Activities": "Aktivitäten", "Add Room": "Zimmer hinzufügen", "Add code": "Code hinzufügen", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index b04236e5d..4aa97e93d 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -13,6 +13,7 @@ "Accept new price": "Accept new price", "Accessibility": "Accessibility", "Accessible Room": "Accessibility room", + "Active": "Active", "Activities": "Activities", "Add Room": "Add room", "Add code": "Add code", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 0b5230cf9..e96377e7c 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -13,6 +13,7 @@ "Accept new price": "Hyväksy uusi hinta", "Accessibility": "Saavutettavuus", "Accessible Room": "Esteetön huone", + "Active": "Aktiivinen", "Activities": "Aktiviteetit", "Add Room": "Lisää huone", "Add code": "Lisää koodi", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 2ad4b66b0..0c71eb088 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -13,6 +13,7 @@ "Accept new price": "Aksepterer ny pris", "Accessibility": "Tilgjengelighet", "Accessible Room": "Tilgjengelighetsrom", + "Active": "Aktiv", "Activities": "Aktiviteter", "Add Room": "Legg til rom", "Add code": "Legg til kode", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 540211cdb..6863d28c7 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -13,6 +13,7 @@ "Accept new price": "Accepter ny pris", "Accessibility": "Tillgänglighet", "Accessible Room": "Tillgänglighetsrum", + "Active": "Aktiv", "Activities": "Aktiviteter", "Add Room": "Lägg till rum", "Add code": "Lägg till kod", diff --git a/server/routers/contentstack/reward/output.ts b/server/routers/contentstack/reward/output.ts index 05547ca00..f3a38b11b 100644 --- a/server/routers/contentstack/reward/output.ts +++ b/server/routers/contentstack/reward/output.ts @@ -161,10 +161,14 @@ export type CMSRewardWithRedeem = z.output< export type Reward = CMSReward & { id: string | undefined + rewardType: string | undefined + redeemLocation: string | undefined } export type RewardWithRedeem = CMSRewardWithRedeem & { id: string | undefined + rewardType: string | undefined + redeemLocation: string | undefined } // New endpoint related types and schemas. @@ -172,10 +176,11 @@ export type RewardWithRedeem = CMSRewardWithRedeem & { const BenefitReward = z.object({ title: z.string().optional(), id: z.string().optional(), - status: z.string().optional(), + redeemLocation: z.string().optional(), rewardId: z.string().optional(), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + status: z.string().optional(), }) const CouponState = z.enum(["claimed", "redeemed", "viewed"]) @@ -191,6 +196,7 @@ const CouponReward = z.object({ id: z.string().optional(), rewardId: z.string().optional(), rewardType: z.string().optional(), + redeemLocation: z.string().optional(), status: z.string().optional(), coupon: z.array(CouponData).optional(), }) @@ -224,3 +230,7 @@ export const validateApiAllTiersSchema = z.record( }), z.array(BenefitReward) ) + +export type RedeemLocation = "Non-redeemable" | "On-site" | "Online" + +export type RewardType = "Tier" | "Member-voucher" | "Surprise" | "Campaign" diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index 6f62241e2..9bb1b1daf 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -245,13 +245,17 @@ export const rewardQueryRouter = router({ .map(({ rewardId }) => rewardId) const rewards = cmsRewards - .filter((reward) => !wrappedSurprisesIds.includes(reward.reward_id)) - .map((reward) => { + .filter((cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id)) + .map((cmsReward) => { + const apiReward = validatedApiRewards.data.find( + ({ rewardId }) => rewardId === cmsReward.reward_id + ) + return { - ...reward, - id: validatedApiRewards.data.find( - ({ rewardId }) => rewardId === reward.reward_id - )?.id, + ...cmsReward, + id: apiReward?.id, + rewardType: apiReward?.rewardType, + redeemLocation: apiReward?.redeemLocation, } }) @@ -364,6 +368,8 @@ export const rewardQueryRouter = router({ return { ...reward, id: surprise.id, + rewardType: surprise.rewardType, + redeemLocation: surprise.redeemLocation, coupons: "coupon" in surprise ? surprise.coupon || [] : [], } }) diff --git a/types/components/myPages/myPage/accountPage.ts b/types/components/myPages/myPage/accountPage.ts index 3725d1505..c1992b187 100644 --- a/types/components/myPages/myPage/accountPage.ts +++ b/types/components/myPages/myPage/accountPage.ts @@ -27,10 +27,12 @@ export interface CurrentRewardsClientProps { rewards: (Reward | RewardWithRedeem)[] pageSize: number showRedeem: boolean + memberId?: string | null } export interface RedeemProps { reward: RewardWithRedeem + memberId?: string | null } export type RedeemModalState = "unmounted" | "hidden" | "visible" diff --git a/types/enums/rewards.ts b/types/enums/rewards.ts index 0daf4afed..4de125a3f 100644 --- a/types/enums/rewards.ts +++ b/types/enums/rewards.ts @@ -1,31 +1,33 @@ -export enum RewardId { +export const RewardId = { // Food & Beverage - TenPercentFood = "tier_10_percent_food_tier", - TwoForOneBreakfast = "tier_2_for_one_breakfast", - FifteenPercentFood = "tier_15_percent_food", - FreeKidsDrink = "tier_free_kids_drink", - FreeBreakfast = "tier_free_breakfast", + TenPercentFood: "tier_10_percent_food_tier", + TwoForOneBreakfast: "tier_2_for_one_breakfast", + FifteenPercentFood: "tier_15_percent_food", + FreeKidsDrink: "tier_free_kids_drink", + FreeBreakfast: "tier_free_breakfast", // Monetary Vouchers - Bonus50SEK = "tier_50_SEK_bonus_voucher", - Bonus75SEK = "tier_75_SEK_bonus_voucher", - Bonus100SEK = "tier_100_SEK_bonus_voucher", - Bonus150SEK = "tier_150_SEK_bonus_voucher", - Bonus200SEK = "tier_200_SEK_bonus_voucher", + Bonus50SEK: "tier_50_SEK_bonus_voucher", + Bonus75SEK: "tier_75_SEK_bonus_voucher", + Bonus100SEK: "tier_100_SEK_bonus_voucher", + Bonus150SEK: "tier_150_SEK_bonus_voucher", + Bonus200SEK: "tier_200_SEK_bonus_voucher", // Hotel Perks - EarlyCheckin = "tier_early_checkin_tier", - LateCheckout = "tier_late_checkout", - FreeUpgrade = "tier_free_upgrade", - RoomGuarantee48H = "tier_48_h_room_guarantee", - // GymAccess = "tier_gym_access", + EarlyCheckin: "tier_early_checkin_tier", + LateCheckout: "tier_late_checkout", + FreeUpgrade: "tier_free_upgrade", + RoomGuarantee48H: "tier_48_h_room_guarantee", + // GymAccess: "tier_gym_access", // Earning & Points - EarnRate25Percent = "tier_25_percent_earn_rate", - EarnRate50Percent = "tier_50_percent_earn_rate", - StayBoostForKids = "tier_stay_boost_for_kids", - MemberRate = "tier_member_rate", + EarnRate25Percent: "tier_25_percent_earn_rate", + EarnRate50Percent: "tier_50_percent_earn_rate", + StayBoostForKids: "tier_stay_boost_for_kids", + MemberRate: "tier_member_rate", // Special - YearlyExclusiveGift = "tier_yearly_exclusive_gift", -} + YearlyExclusiveGift: "tier_yearly_exclusive_gift", +} as const + +export type RewardId = (typeof RewardId)[keyof typeof RewardId] \ No newline at end of file diff --git a/utils/rewards.ts b/utils/rewards.ts index 9bf5940a5..0825989f6 100644 --- a/utils/rewards.ts +++ b/utils/rewards.ts @@ -1,5 +1,43 @@ import { RewardId } from "@/types/enums/rewards" +import type { Reward } from "@/server/routers/contentstack/reward/output" export function isValidRewardId(id: string): id is RewardId { return Object.values<string>(RewardId).includes(id) } + +export const RESTAURANT_REWARD_IDS = [ + RewardId.TenPercentFood, + RewardId.TwoForOneBreakfast, + RewardId.FifteenPercentFood, + RewardId.FreeKidsDrink, + RewardId.FreeBreakfast, +] as const + +export type RestaurantRewardId = (typeof RESTAURANT_REWARD_IDS)[number] + +export function isRestaurantReward( + rewardId: string +): rewardId is RestaurantRewardId { + return RESTAURANT_REWARD_IDS.some((id) => id === rewardId) +} + +export function redeemLocationIsOnSite( + location: Reward["redeemLocation"] +): location is "On-site" { + return location === "On-site" +} + +export function isTierType(type: Reward["rewardType"]): type is "Tier" { + return type === "Tier" +} + +export function isOnSiteTierReward(reward: Reward): boolean { + return ( + redeemLocationIsOnSite(reward.redeemLocation) && + isTierType(reward.rewardType) + ) +} + +export function isRestaurantOnSiteTierReward(reward: Reward): boolean { + return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id) +} \ No newline at end of file