diff --git a/apps/scandic-web/.env.local.example b/apps/scandic-web/.env.local.example index fd28543c1..651408b1e 100644 --- a/apps/scandic-web/.env.local.example +++ b/apps/scandic-web/.env.local.example @@ -60,6 +60,5 @@ ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false" SHOW_SITE_WIDE_ALERT="false" SHOW_SIGNUP_FLOW="true" -USE_NEW_REWARDS_ENDPOINT="true" USE_NEW_REWARD_MODEL="true" diff --git a/apps/scandic-web/.env.test b/apps/scandic-web/.env.test index 3a0bf6912..701ac3ae2 100644 --- a/apps/scandic-web/.env.test +++ b/apps/scandic-web/.env.test @@ -43,7 +43,6 @@ GOOGLE_STATIC_MAP_ID="test" GOOGLE_DYNAMIC_MAP_ID="test" NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="true" SALESFORCE_PREFERENCE_BASE_URL="test" -USE_NEW_REWARDS_ENDPOINT="true" USE_NEW_REWARD_MODEL="true" TZ=UTC @@ -54,4 +53,4 @@ SHOW_SITE_WIDE_ALERT="false" NEXT_PUBLIC_SENTRY_ENVIRONMENT="test" NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE="0" -SITEMAP_SYNC_SECRET="test \ No newline at end of file +SITEMAP_SYNC_SECRET="test" diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 3f0ddc359..8128c4cf0 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -19,10 +19,7 @@ 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" +import type { Reward } from "@/types/components/myPages/rewards" export default function ClientCurrentRewards({ rewards: initialData, @@ -34,7 +31,7 @@ export default function ClientCurrentRewards({ const [currentPage, setCurrentPage] = useState(1) const { data } = trpc.contentstack.rewards.current.useQuery<{ - rewards: (Reward | RewardWithRedeem)[] + rewards: Reward[] }>( { lang, @@ -70,21 +67,13 @@ export default function ClientCurrentRewards({
{paginatedRewards.map((reward, idx) => { - const earliestExpirationDate = - "coupons" in reward - ? getEarliestExpirationDate(reward.coupons) - : null + const earliestExpirationDate = getEarliestExpirationDate(reward) return (
- {showRedeem && ( - - )} + {showRedeem && } * { - min-width: 200px; -} - -.filterButton { - position: relative; - display: inline-flex; - align-items: center; - gap: var(--Spacing-x1); - place-self: flex-start; -} - -.filterCount { - display: flex; - align-items: center; - justify-content: center; - color: var(--Base-Text-Inverted); - background-color: var(--Base-Text-Accent); - border-radius: var(--Corner-radius-Rounded); - width: 20px; - height: 20px; - font-size: var(--typography-Footnote-Regular-fontSize); -} - -.customFormCheckbox { - min-width: 200px; -} - -.customCheckbox { - display: flex; - color: var(--text-color); - cursor: pointer; -} - -.customCheckbox[data-selected] .checkbox { - border: none; - background: var(--UI-Input-Controls-Fill-Selected); -} - -.customCheckbox[data-disabled] .checkbox { - border: 1px solid var(--UI-Input-Controls-Border-Disabled); - background: var(--UI-Input-Controls-Surface-Disabled); -} - -.customCheckbox[data-focus-visible="true"] { - outline: 2px solid var(--UI-Input-Controls-Fill-Selected); - outline-offset: 2px; -} - -.checkboxContainer { - display: flex; - align-items: center; - gap: var(--Spacing-x-one-and-half); -} - -.checkbox { - width: 24px; - height: 24px; - min-width: 24px; - background: var(--UI-Input-Controls-Surface-Normal); - border: 1px solid var(--UI-Input-Controls-Border-Normal); - border-radius: 4px; - transition: all 200ms; - display: flex; - align-items: center; - justify-content: center; - forced-color-adjust: none; -} - -@media screen and (min-width: 768px) { - .overlay { - display: flex; - justify-content: center; - align-items: center; - } - - .modal { - left: auto; - bottom: auto; - width: min(933px, 80vw); - max-height: 80vh; - } - - .checkboxGroup { - gap: var(--Spacing-x5); - } -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/index.tsx deleted file mode 100644 index 07658a107..000000000 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/index.tsx +++ /dev/null @@ -1,247 +0,0 @@ -"use client" - -import { motion } from "framer-motion" -import { useState } from "react" -import { - Checkbox, - Dialog, - DialogTrigger, - Modal, - ModalOverlay, -} from "react-aria-components" -import { useIntl } from "react-intl" - -import { - type MembershipLevelEnum, - TIER_TO_FRIEND_MAP, -} from "@/constants/membershipLevels" - -import { CloseLargeIcon, FilterIcon } from "@/components/Icons" -import CheckIcon from "@/components/Icons/Check" -import Button from "@/components/TempDesignSystem/Button" -import Body from "@/components/TempDesignSystem/Text/Body" -import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" - -import styles from "./filterRewardsModal.module.css" - -import type { - FilterRewardsModalProps, - RewardCategory, -} from "@/types/components/myPages/rewards" - -type ModalState = "visible" | "hidden" | "unmounted" - -export default function FilterRewardsModal({ - selectedCategories, - selectedLevels, - onCategoriesChange, - onLevelsChange, - availableTierLevels, - availableCategories, -}: FilterRewardsModalProps) { - const intl = useIntl() - - const [animation, setAnimation] = useState<ModalState>("unmounted") - const [tempCategories, setTempCategories] = - useState<RewardCategory[]>(selectedCategories) - const [tempLevels, setTempLevels] = - useState<MembershipLevelEnum[]>(selectedLevels) - - const categoryTranslations: Record<RewardCategory, string> = { - Restaurants: intl.formatMessage({ id: "Restaurants" }), - Bar: intl.formatMessage({ id: "Bar" }), - Voucher: intl.formatMessage({ id: "Voucher" }), - "Services and rooms": intl.formatMessage({ id: "Services and rooms" }), - "Spa and gym": intl.formatMessage({ id: "Spa and gym" }), - } - - function handleClearAll() { - setTempCategories([]) - setTempLevels([]) - } - - function handleApply(close: () => void) { - onCategoriesChange(tempCategories) - onLevelsChange(tempLevels) - close() - } - - function handleOpenChange(isOpen: boolean) { - setAnimation(isOpen ? "visible" : "hidden") - if (isOpen) { - setTempCategories(selectedCategories) - setTempLevels(selectedLevels) - } - } - - return ( - <DialogTrigger onOpenChange={handleOpenChange}> - <Button intent="text" theme="base" className={styles.filterButton}> - <FilterIcon color="burgundy" /> - {intl.formatMessage({ id: "Filter and sort" })} - {(selectedCategories.length > 0 || selectedLevels.length > 0) && ( - <span className={styles.filterCount}> - {selectedCategories.length + selectedLevels.length} - </span> - )} - </Button> - - <MotionOverlay - className={styles.overlay} - isExiting={animation === "hidden"} - onAnimationComplete={(state) => { - if (state === "hidden") { - setAnimation("unmounted") - } - }} - variants={variants.fade} - initial="hidden" - animate={animation} - > - <MotionModal - className={styles.modal} - variants={variants.slideInOut} - initial="hidden" - animate={animation} - > - <Dialog className={styles.dialog}> - {({ close }) => ( - <> - <header className={styles.modalHeader}> - <Body textTransform="bold" color="black"> - {intl.formatMessage({ id: "Filter and sort" })} - </Body> - <button - onClick={close} - type="button" - className={styles.modalClose} - > - <CloseLargeIcon /> - </button> - </header> - - <div className={styles.modalContent}> - {availableCategories.length > 0 && ( - <div className={styles.filterSection}> - <Subtitle type="two" color="black"> - {intl.formatMessage({ id: "Category" })} - </Subtitle> - <div className={styles.checkboxGroup} role="group"> - {availableCategories.map((category) => ( - <Checkbox - key={category} - value={category} - isSelected={tempCategories.includes(category)} - onChange={(isSelected) => { - setTempCategories( - isSelected - ? [...tempCategories, category] - : tempCategories.filter((c) => c !== category) - ) - }} - className={styles.customCheckbox} - > - {({ isSelected }) => ( - <span className={styles.checkboxContainer}> - <span className={styles.checkbox}> - {isSelected && <CheckIcon color="white" />} - </span> - {categoryTranslations[category]} - </span> - )} - </Checkbox> - ))} - </div> - </div> - )} - - {availableTierLevels.length > 0 && ( - <div className={styles.filterSection}> - <Subtitle type="two" color="black"> - {intl.formatMessage({ id: "Level benefit" })} - </Subtitle> - <div className={styles.checkboxGroup} role="group"> - {availableTierLevels.map((level) => ( - <Checkbox - key={level} - value={level} - isSelected={tempLevels.includes(level)} - onChange={(isSelected) => { - setTempLevels( - isSelected - ? [...tempLevels, level] - : tempLevels.filter((l) => l !== level) - ) - }} - className={styles.customCheckbox} - > - {({ isSelected }) => ( - <span className={styles.checkboxContainer}> - <span className={styles.checkbox}> - {isSelected && <CheckIcon color="white" />} - </span> - {TIER_TO_FRIEND_MAP[level]} - </span> - )} - </Checkbox> - ))} - </div> - </div> - )} - </div> - - <footer className={styles.modalFooter}> - <Button - onClick={handleClearAll} - intent="text" - theme="base" - className={styles.clearButton} - > - {intl.formatMessage({ id: "Clear all filters" })} - </Button> - <Button - onClick={() => handleApply(close)} - intent="secondary" - theme="base" - className={styles.applyButton} - > - {intl.formatMessage({ id: "Apply" })} - </Button> - </footer> - </> - )} - </Dialog> - </MotionModal> - </MotionOverlay> - </DialogTrigger> - ) -} - -const MotionOverlay = motion(ModalOverlay) -const MotionModal = motion(Modal) - -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" }, - }, - }, -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx index 11094f382..aa681effa 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx @@ -31,7 +31,7 @@ export default async function CurrentRewardsBlock({ <SectionHeader title={title} link={link} preamble={subtitle} /> <ClientCurrentRewards rewards={rewardsResponse.rewards} - showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL} + showRedeem={env.USE_NEW_REWARD_MODEL} membershipNumber={membershipLevel?.membershipNumber} /> <SectionLink link={link} variant="mobile" /> diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx index 3d6202841..88bf12c84 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -10,17 +10,13 @@ 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() +import type { Campaign } from "@/types/components/myPages/rewards" - if (!reward) { - return null - } +export default function Campaign({ reward }: { reward: Campaign }) { + const intl = useIntl() return ( <> diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx index 69831bd57..a8ac39b11 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx @@ -16,20 +16,19 @@ import useRedeemFlow from "../useRedeemFlow" import styles from "../redeem.module.css" +import type { Surprise, Tier } from "@/types/components/myPages/rewards" + export default function Tier({ + reward, membershipNumber, }: { + reward: Tier | Surprise membershipNumber: string }) { - const { reward, onRedeem, redeemStep, setRedeemStep, isRedeeming } = - useRedeemFlow() + const { onRedeem, redeemStep, setRedeemStep, isRedeeming } = useRedeemFlow() const intl = useIntl() - if (!reward) { - return null - } - return ( <> <div className={styles.modalContent}> @@ -93,7 +92,7 @@ export default function Tier({ {redeemStep === "confirmation" && ( <footer className={styles.modalFooter}> <Button - onClick={onRedeem} + onClick={() => onRedeem(reward)} disabled={isRedeeming} intent="primary" theme="base" diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx index 5e7e74724..0510df93d 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx @@ -17,8 +17,8 @@ import Button from "@/components/TempDesignSystem/Button" import useLang from "@/hooks/useLang" import { isRestaurantOnSiteTierReward } from "@/utils/rewards" -import Campaign from "./Flows/Campaign" -import Tier from "./Flows/Tier" +import RedeemCampaign from "./Flows/Campaign" +import RedeemTier from "./Flows/Tier" import { ConfirmClose } from "./ConfirmClose" import { RedeemContext } from "./useRedeemFlow" @@ -29,7 +29,7 @@ import type { RedeemProps, RedeemStep, } from "@/types/components/myPages/myPage/accountPage" -import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" +import type { Reward } from "@/types/components/myPages/rewards" const MotionOverlay = motion(ModalOverlay) const MotionModal = motion(Modal) @@ -58,7 +58,6 @@ export default function Redeem({ reward, membershipNumber }: RedeemProps) { return ( <RedeemContext.Provider value={{ - reward, redeemStep, setRedeemStep, defaultTimeRemaining: thirtyMinutesInMs, @@ -164,15 +163,16 @@ const variants = { }, } -function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) { - switch (reward.rewardType) { +function getRedeemFlow(reward: Reward, membershipNumber: string) { + const { rewardType } = reward + switch (rewardType) { case "Campaign": - return <Campaign /> + return <RedeemCampaign reward={reward} /> case "Surprise": case "Tier": - return <Tier membershipNumber={membershipNumber} /> + return <RedeemTier reward={reward} membershipNumber={membershipNumber} /> default: - console.warn("Unsupported reward type for redeem:", reward.rewardType) + console.warn("Unsupported reward type for redeem:", rewardType) return null } } diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts index d3abcdf66..317031586 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts @@ -4,11 +4,12 @@ import { createContext, useCallback, useContext, useEffect } from "react" import { trpc } from "@/lib/trpc/client" +import { getFirstRedeemableCoupon } from "@/utils/rewards" + import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage" -import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" +import type { Reward } from "@/types/components/myPages/rewards" export const RedeemContext = createContext<RedeemFlowContext>({ - reward: null, redeemStep: "initial", setRedeemStep: () => undefined, defaultTimeRemaining: 0, @@ -18,7 +19,6 @@ export const RedeemContext = createContext<RedeemFlowContext>({ export default function useRedeemFlow() { const { - reward, redeemStep, setRedeemStep, defaultTimeRemaining, @@ -27,13 +27,14 @@ export default function useRedeemFlow() { } = useContext(RedeemContext) const update = trpc.contentstack.rewards.redeem.useMutation<{ - rewards: RewardWithRedeem[] + rewards: Reward[] }>() - const onRedeem = useCallback(() => { - if (reward?.id) { + const onRedeem = useCallback( + (reward: Reward) => { + const coupon = getFirstRedeemableCoupon(reward) update.mutate( - { rewardId: reward.id, couponCode: reward.couponCode }, + { rewardId: reward.id, couponCode: coupon.couponCode }, { onSuccess() { setRedeemStep("redeemed") @@ -43,8 +44,9 @@ export default function useRedeemFlow() { }, } ) - } - }, [reward, update, setRedeemStep]) + }, + [update, setRedeemStep] + ) useEffect(() => { if (redeemStep === "initial") { @@ -53,7 +55,6 @@ export default function useRedeemFlow() { }, [redeemStep, setTimeRemaining, defaultTimeRemaining]) return { - reward, onRedeem, redeemStep, setRedeemStep, diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/ScriptedRewardText/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/ScriptedRewardText/index.tsx index b15312a22..abd0d13ca 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/ScriptedRewardText/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/ScriptedRewardText/index.tsx @@ -4,24 +4,22 @@ 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, + reward, }: ScriptedRewardTextProps) { const intl = useIntl() - function getLabel(rewardType?: string, rewardTierLevel?: string) { - const type = getRewardType(rewardType) - - switch (type) { - case "Tier": + function getLabel() { + switch (reward.rewardType) { + case "Tier": { + const { rewardTierLevel } = reward return rewardTierLevel && isMembershipLevel(rewardTierLevel) ? TIER_TO_FRIEND_MAP[rewardTierLevel] : null + } case "Campaign": return intl.formatMessage({ id: "Campaign" }) case "Surprise": @@ -33,7 +31,7 @@ export default function ScriptedRewardText({ } } - const label = getLabel(rewardType, rewardTierLevel) + const label = getLabel() if (!label) return null diff --git a/apps/scandic-web/components/MyPages/Surprises/Client.tsx b/apps/scandic-web/components/MyPages/Surprises/Client.tsx index 1290077a3..430c2f8a0 100644 --- a/apps/scandic-web/components/MyPages/Surprises/Client.tsx +++ b/apps/scandic-web/components/MyPages/Surprises/Client.tsx @@ -28,7 +28,7 @@ import Slide from "./Slide" import styles from "./surprises.module.css" import type { SurprisesProps } from "@/types/components/blocks/surprises" -import type { Surprise } from "@/server/routers/contentstack/reward/output" +import type { Surprise } from "@/types/components/myPages/rewards" const MotionModal = motion(Modal) @@ -125,7 +125,7 @@ export default function SurprisesNotification({ async function viewRewards() { const updates = surprises .map((surprise) => { - const coupons = surprise.coupons + const coupons = surprise.coupon .map((coupon) => { if (coupon.couponCode) { return { diff --git a/apps/scandic-web/components/MyPages/Surprises/Slide.tsx b/apps/scandic-web/components/MyPages/Surprises/Slide.tsx index b4774b732..67f6dcaad 100644 --- a/apps/scandic-web/components/MyPages/Surprises/Slide.tsx +++ b/apps/scandic-web/components/MyPages/Surprises/Slide.tsx @@ -7,7 +7,7 @@ import Card from "./Card" import type { SlideProps } from "@/types/components/blocks/surprises" export default function Slide({ surprise }: SlideProps) { - const earliestExpirationDate = getEarliestExpirationDate(surprise.coupons) + const earliestExpirationDate = getEarliestExpirationDate(surprise) return ( <Card title={surprise.label}> diff --git a/apps/scandic-web/constants/rewards.ts b/apps/scandic-web/constants/rewards.ts index 6148b5e36..1959d9f27 100644 --- a/apps/scandic-web/constants/rewards.ts +++ b/apps/scandic-web/constants/rewards.ts @@ -38,20 +38,4 @@ export const RESTAURANT_REWARD_IDS = [ REWARD_IDS.FreeBreakfast, ] as const -export const COUPON_REWARD_TYPES = [ - "Surprise", - "Campaign", - "Member-voucher", -] as const - -export const REWARD_TYPES = [...COUPON_REWARD_TYPES, "Tier"] as const - export const REWARDS_PER_PAGE = 6 - -export const REWARD_CATEGORIES = [ - "Restaurants", - "Bar", - "Voucher", - "Services and rooms", - "Spa and gym", -] as const diff --git a/apps/scandic-web/env/server.ts b/apps/scandic-web/env/server.ts index a08847ad2..b9bbc298b 100644 --- a/apps/scandic-web/env/server.ts +++ b/apps/scandic-web/env/server.ts @@ -141,13 +141,6 @@ export const env = createEnv({ // transform to boolean .transform((s) => s === "true") .default("false"), - USE_NEW_REWARDS_ENDPOINT: z - .string() - // only allow "true" or "false" - .refine((s) => s === "true" || s === "false") - // transform to boolean - .transform((s) => s === "true") - .default("false"), USE_NEW_REWARD_MODEL: z .string() // only allow "true" or "false" @@ -268,7 +261,6 @@ export const env = createEnv({ GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_MAP_ID, GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID, HIDE_FOR_NEXT_RELEASE: process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE, - USE_NEW_REWARDS_ENDPOINT: process.env.USE_NEW_REWARDS_ENDPOINT, USE_NEW_REWARD_MODEL: process.env.USE_NEW_REWARD_MODEL, ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW, ENABLE_BOOKING_WIDGET: process.env.ENABLE_BOOKING_WIDGET, diff --git a/apps/scandic-web/hooks/rewards/useFilteredRewards.ts b/apps/scandic-web/hooks/rewards/useFilteredRewards.ts deleted file mode 100644 index f0e896755..000000000 --- a/apps/scandic-web/hooks/rewards/useFilteredRewards.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useMemo } from "react" - -import { isMembershipLevel } from "@/utils/membershipLevels" -import { isRewardCategory } from "@/utils/rewards" - -import type { RewardCategory } from "@/types/components/myPages/rewards" -import type { MembershipLevelEnum } from "@/constants/membershipLevels" -import type { - Reward, - RewardWithRedeem, -} from "@/server/routers/contentstack/reward/output" - -export function useFilteredRewards( - rewards: (Reward | RewardWithRedeem)[], - selectedCategories: RewardCategory[] = [], - selectedLevels: MembershipLevelEnum[] = [] -) { - const availableCategories = Array.from( - new Set( - rewards - .flatMap((reward) => reward.categories || []) - .filter((category) => isRewardCategory(category)) - ) - ).sort() - - const availableTierLevels = Array.from( - new Set( - rewards - .map((reward) => reward.rewardTierLevel) - .filter( - (level): level is MembershipLevelEnum => - typeof level === "string" && isMembershipLevel(level) - ) - ) - ) - - const hasFilterableOptions = - availableCategories.length > 0 || availableTierLevels.length > 0 - - const filteredRewards = useMemo(() => { - const hasSelectedCategoryFilter = selectedCategories.length > 0 - const hasSelectedLevelFilter = selectedLevels.length > 0 - - if (!hasSelectedCategoryFilter && !hasSelectedLevelFilter) { - return rewards - } - - const useOrLogic = hasSelectedCategoryFilter && hasSelectedLevelFilter - - return rewards.filter((reward) => { - const matchesCategory = - !hasSelectedCategoryFilter || - (reward.categories?.some( - (category) => - isRewardCategory(category) && selectedCategories.includes(category) - ) ?? - false) - - const matchesLevel = - !hasSelectedLevelFilter || - (reward.rewardTierLevel && - isMembershipLevel(reward.rewardTierLevel) && - selectedLevels.includes(reward.rewardTierLevel)) - - // Apply OR logic if both filters are active, otherwise AND - return useOrLogic - ? matchesCategory || matchesLevel - : matchesCategory && matchesLevel - }) - }, [rewards, selectedCategories, selectedLevels]) - - return { - filteredRewards, - total: filteredRewards.length, - availableTierLevels, - availableCategories, - hasFilterableOptions, - } -} diff --git a/apps/scandic-web/lib/api/endpoints.ts b/apps/scandic-web/lib/api/endpoints.ts index d7270bcc7..e4b01080c 100644 --- a/apps/scandic-web/lib/api/endpoints.ts +++ b/apps/scandic-web/lib/api/endpoints.ts @@ -171,10 +171,6 @@ export namespace endpoints { export const unlink = `${base.path.profile}/${version}/${base.enitity.Profile}/Unlink` export const matchTier = `${base.path.profile}/${version}/${base.enitity.Profile}/MatchTier` - // TODO: Remove once new endpoints are out in production. - export const reward = `${base.path.profile}/${version}/${base.enitity.Profile}/reward` - export const tierRewards = `${base.path.profile}/${version}/${base.enitity.Profile}/tierRewards` - export function deleteProfile(profileId: string) { return `${profile}/${profileId}` } diff --git a/apps/scandic-web/server/routers/contentstack/reward/output.ts b/apps/scandic-web/server/routers/contentstack/reward/output.ts index dafa56b25..80c5c6e35 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/output.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/output.ts @@ -1,7 +1,6 @@ import { z } from "zod" import { MembershipLevelEnum } from "@/constants/membershipLevels" -import { COUPON_REWARD_TYPES, REWARD_CATEGORIES } from "@/constants/rewards" import { linkRefsUnionSchema, @@ -10,125 +9,19 @@ import { } from "../schemas/pageLinks" import { systemSchema } from "../schemas/system" -import type { RewardCategory } from "@/types/components/myPages/rewards" - -const Coupon = z.object({ - code: z.string().optional(), - status: z.string().optional(), - createdAt: z.string().datetime({ offset: true }).optional(), - customer: z.object({ - id: z.string().optional(), - }), - name: z.string().optional(), - claimedAt: z.string().datetime({ offset: true }).optional(), - redeemedAt: z - .date({ coerce: true }) - .optional() - .transform((value) => { - if (value?.getFullYear() === 1) { - return null - } - return value - }), - type: z.string().optional(), - value: z.number().optional(), - pool: z.string().optional(), - cfUnwrapped: z.boolean().default(false), -}) - -const SurpriseReward = z.object({ - title: z.string().optional(), - id: z.string().optional(), - type: z.literal("coupon"), - status: z.string().optional(), - rewardId: z.string().optional(), - redeemLocation: z.string().optional(), - autoApplyReward: z.boolean().default(false), - rewardType: z.string().optional(), - endsAt: z.string().datetime({ offset: true }).optional(), - coupons: z.array(Coupon).optional(), - operaRewardId: z.string().default(""), -}) - -export const validateApiRewardSchema = z - .object({ - data: z.array( - z.discriminatedUnion("type", [ - z.object({ - title: z.string().optional(), - id: z.string().optional(), - type: z.literal("custom"), - status: z.string().optional(), - rewardId: z.string().optional(), - redeemLocation: z.string().optional(), - autoApplyReward: z.boolean().default(false), - rewardType: z.string().optional(), - rewardTierLevel: z.string().optional(), - operaRewardId: z.string().default(""), - }), - SurpriseReward, - ]) - ), - }) - .transform((data) => data.data) - -enum TierKey { - tier1 = MembershipLevelEnum.L1, - tier2 = MembershipLevelEnum.L2, - tier3 = MembershipLevelEnum.L3, - tier4 = MembershipLevelEnum.L4, - tier5 = MembershipLevelEnum.L5, - tier6 = MembershipLevelEnum.L6, - tier7 = MembershipLevelEnum.L7, +export { + BenefitReward, + CouponData, + CouponReward, + REDEEM_LOCATIONS, + REWARD_TYPES, + rewardRefsSchema, + validateApiAllTiersSchema, + validateCategorizedRewardsSchema, + validateCmsRewardsSchema, } -type Key = keyof typeof TierKey - -export const validateApiTierRewardsSchema = z.record( - z.nativeEnum(TierKey).transform((data) => { - return TierKey[data as unknown as Key] - }), - z.array( - z.object({ - title: z.string().optional(), - id: z.string().optional(), - type: z.string().optional(), - status: z.string().optional(), - rewardId: z.string().optional(), - redeemLocation: z.string().optional(), - autoApplyReward: z.boolean().default(false), - rewardType: z.string().optional(), - rewardTierLevel: z.string().optional(), - operaRewardId: z.string().default(""), - }) - ) -) - -export const validateCmsRewardsSchema = z - .object({ - data: z.object({ - all_reward: z.object({ - items: z.array( - z.object({ - taxonomies: z.array( - z.object({ - term_uid: z.string().optional().default(""), - }) - ), - label: z.string().optional(), - reward_id: z.string(), - grouped_label: z.string().optional(), - description: z.string().optional(), - grouped_description: z.string().optional(), - value: z.string().optional(), - }) - ), - }), - }), - }) - .transform((data) => data.data.all_reward.items) - -export const validateCmsRewardsWithRedeemSchema = z +const validateCmsRewardsSchema = z .object({ data: z.object({ all_reward: z.object({ @@ -168,17 +61,7 @@ export const validateCmsRewardsWithRedeemSchema = z }) .transform((data) => data.data.all_reward.items) -export type ApiReward = z.output<typeof validateApiRewardSchema>[number] - -export type SurpriseReward = z.output<typeof SurpriseReward> - -export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema> - -export type CmsRewardsWithRedeemResponse = z.input< - typeof validateCmsRewardsWithRedeemSchema -> - -export const rewardWithRedeemRefsSchema = z.object({ +const rewardRefsSchema = z.object({ data: z.object({ all_reward: z.object({ items: z.array( @@ -199,66 +82,48 @@ export const rewardWithRedeemRefsSchema = z.object({ }), }) -export interface GetRewardWithRedeemRefsSchema - extends z.input<typeof rewardWithRedeemRefsSchema> {} +const REDEEM_LOCATIONS = ["Non-redeemable", "On-site", "Online"] as const +const REWARD_CATEGORIES = [ + "Restaurants", + "Bar", + "Voucher", + "Services and rooms", + "Spa and gym", +] as const -export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0] - -export type CMSRewardWithRedeem = z.output< - typeof validateCmsRewardsWithRedeemSchema ->[0] - -export type Reward = CMSReward & { - id: string | undefined - rewardType: string | undefined - redeemLocation: string | undefined - rewardTierLevel: string | undefined - operaRewardId: string - categories: RewardCategory[] - couponCode: string | undefined - coupons: Coupon[] -} - -export type RewardWithRedeem = CMSRewardWithRedeem & { - id: string | undefined - rewardType: string | undefined - redeemLocation: string | undefined - rewardTierLevel: string | undefined - operaRewardId: string - categories: RewardCategory[] - couponCode: string | undefined - coupons: Coupon[] -} - -export interface Coupon { - couponCode?: string - expiresAt?: string - unwrapped: boolean - state: "claimed" | "redeemed" | "viewed" -} - -export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> { - coupons: Coupon[] -} - -// New endpoint related types and schemas. const BaseReward = z.object({ title: z.string().optional(), - id: z.string().optional(), - rewardId: z.string().optional(), - redeemLocation: z.string().optional(), - status: z.string().optional(), + id: z.string(), + categories: z + .array(z.enum(REWARD_CATEGORIES).or(z.literal(""))) + .optional() + // we sometimes receive empty categories, this filters them out + .transform((categories = []) => + categories.filter( + (c): c is (typeof REWARD_CATEGORIES)[number] => c !== "" + ) + ), + rewardId: z.string(), + redeemLocation: z.enum(REDEEM_LOCATIONS), + status: z.enum(["active", "expired"]), }) +const REWARD_TYPES = { + Surprise: "Surprise", + Campaign: "Campaign", + MemberVoucher: "Member-voucher", + Tier: "Tier", +} as const + const BenefitReward = BaseReward.merge( z.object({ - rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility + rewardType: z.enum([REWARD_TYPES.Tier]), rewardTierLevel: z.string().optional(), }) ) const CouponData = z.object({ - couponCode: z.string().optional(), + couponCode: z.string(), unwrapped: z.boolean().default(false), state: z.enum(["claimed", "redeemed", "viewed"]), expiresAt: z.string().datetime({ offset: true }).optional(), @@ -266,9 +131,12 @@ const CouponData = z.object({ const CouponReward = BaseReward.merge( z.object({ - rewardType: z.enum(COUPON_REWARD_TYPES), + rewardType: z.enum([ + REWARD_TYPES.Surprise, + REWARD_TYPES.Campaign, + REWARD_TYPES.MemberVoucher, + ]), operaRewardId: z.string().default(""), - categories: z.array(z.enum(REWARD_CATEGORIES)).optional(), coupon: z .array(CouponData) .optional() @@ -276,38 +144,24 @@ const CouponReward = BaseReward.merge( }) ) -/** - * Schema for the new /profile/v1/Reward endpoint. - * - * TODO: Once we fully migrate to the new endpoint: - * 1. Remove the data transform and use the categorized structure directly. - * 2. Simplify surprise filtering in the query. - */ -export const validateCategorizedRewardsSchema = z - .object({ - benefits: z.array(BenefitReward), - coupons: z.array(CouponReward), - }) - .transform((data) => [ - ...data.benefits.map((benefit) => ({ - ...benefit, - type: "custom" as const, // Added for legacy compatibility. - })), - ...data.coupons.map((coupon) => ({ - ...coupon, - type: "coupon" as const, // Added for legacy compatibility. - })), - ]) +const validateCategorizedRewardsSchema = z.object({ + benefits: z.array(BenefitReward), + coupons: z.array(CouponReward), +}) -export type CategorizedApiReward = z.output< - typeof validateCategorizedRewardsSchema ->[number] +const TierKeyMapping = { + tier1: MembershipLevelEnum.L1, + tier2: MembershipLevelEnum.L2, + tier3: MembershipLevelEnum.L3, + tier4: MembershipLevelEnum.L4, + tier5: MembershipLevelEnum.L5, + tier6: MembershipLevelEnum.L6, + tier7: MembershipLevelEnum.L7, +} as const -export const validateApiAllTiersSchema = z.record( - z.nativeEnum(TierKey).transform((data) => { - return TierKey[data as unknown as Key] - }), +const TierKeys = Object.keys(TierKeyMapping) as [keyof typeof TierKeyMapping] + +const validateApiAllTiersSchema = z.record( + z.enum(TierKeys).transform((data) => TierKeyMapping[data]), z.array(BenefitReward) ) - -export type RedeemLocation = "Non-redeemable" | "On-site" | "Online" diff --git a/apps/scandic-web/server/routers/contentstack/reward/query.ts b/apps/scandic-web/server/routers/contentstack/reward/query.ts index 5566db92c..aa65ae2ba 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/query.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/query.ts @@ -1,6 +1,4 @@ -import { env } from "@/env/server" import * as api from "@/lib/api" -import { dt } from "@/lib/dt" import { notFound } from "@/server/errors/trpc" import { contentStackBaseWithProtectedProcedure, @@ -10,6 +8,11 @@ import { } from "@/server/trpc" import { langInput } from "@/server/utils" +import { + getRedeemableRewards, + getUnwrappedSurpriseRewards, +} from "@/utils/rewards" + import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query" import { rewardsAllInput, @@ -17,14 +20,8 @@ import { rewardsRedeemInput, rewardsUpdateInput, } from "./input" +import { validateCategorizedRewardsSchema } from "./output" import { - type Reward, - type Surprise, - validateApiRewardSchema, - validateCategorizedRewardsSchema, -} from "./output" -import { - getAllCachedApiRewards, getAllRewardCounter, getAllRewardFailCounter, getAllRewardSuccessCounter, @@ -36,7 +33,6 @@ import { getCurrentRewardCounter, getCurrentRewardFailCounter, getCurrentRewardSuccessCounter, - getNonRedeemedRewardIds, getRedeemCounter, getRedeemFailCounter, getRedeemSuccessCounter, @@ -46,15 +42,17 @@ import { getUnwrapSurpriseSuccessCounter, } from "./utils" +import type { BaseReward, Surprise } from "@/types/components/myPages/rewards" +import type { LevelWithRewards } from "@/types/components/overviewTable" +import type { CMSReward } from "@/types/trpc/routers/contentstack/reward" + export const rewardQueryRouter = router({ all: contentStackBaseWithServiceProcedure .input(rewardsAllInput) .query(async function ({ input, ctx }) { getAllRewardCounter.add(1) - const allApiRewards = env.USE_NEW_REWARDS_ENDPOINT - ? await getCachedAllTierRewards(ctx.serviceToken) - : await getAllCachedApiRewards(ctx.serviceToken) + const allApiRewards = await getCachedAllTierRewards(ctx.serviceToken) if (!allApiRewards) { return [] @@ -89,7 +87,7 @@ export const rewardQueryRouter = router({ console.error("No contentStackReward found", reward?.rewardId) } }) - .filter((reward): reward is Reward => Boolean(reward)) + .filter((reward): reward is CMSReward => Boolean(reward)) const levelConfig = loyaltyLevelsConfig.find( (l) => l.level_id === level @@ -101,7 +99,11 @@ export const rewardQueryRouter = router({ console.error("contentstack.loyaltyLevels level not found") throw notFound() } - return { ...levelConfig, rewards: combinedRewards } + const result: LevelWithRewards = { + ...levelConfig, + rewards: combinedRewards, + } + return result } ) @@ -114,9 +116,9 @@ export const rewardQueryRouter = router({ getByLevelRewardCounter.add(1) const { level_id } = input - const allUpcomingApiRewards = env.USE_NEW_REWARDS_ENDPOINT - ? await getCachedAllTierRewards(ctx.serviceToken) - : await getAllCachedApiRewards(ctx.serviceToken) + const allUpcomingApiRewards = await getCachedAllTierRewards( + ctx.serviceToken + ) if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) { getByLevelRewardFailCounter.add(1) @@ -157,7 +159,7 @@ export const rewardQueryRouter = router({ console.info("No contentStackReward found", reward?.rewardId) } }) - .filter((reward): reward is Reward => Boolean(reward)) + .filter((reward): reward is CMSReward => Boolean(reward)) getByLevelRewardSuccessCounter.add(1) return { level: loyaltyLevelsConfig, rewards: levelsWithRewards } @@ -167,16 +169,14 @@ export const rewardQueryRouter = router({ .query(async function ({ ctx }) { getCurrentRewardCounter.add(1) - const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT - const endpoint = isNewEndpoint - ? api.endpoints.v1.Profile.Reward.reward - : api.endpoints.v1.Profile.reward - - const apiResponse = await api.get(endpoint, { - headers: { - Authorization: `Bearer ${ctx.session.token.access_token}`, - }, - }) + const apiResponse = await api.get( + api.endpoints.v1.Profile.Reward.reward, + { + headers: { + Authorization: `Bearer ${ctx.session.token.access_token}`, + }, + } + ) if (!apiResponse.ok) { const text = await apiResponse.text() @@ -203,9 +203,8 @@ export const rewardQueryRouter = router({ const data = await apiResponse.json() - const validatedApiRewards = isNewEndpoint - ? validateCategorizedRewardsSchema.safeParse(data) - : validateApiRewardSchema.safeParse(data) + const validatedApiRewards = + validateCategorizedRewardsSchema.safeParse(data) if (!validatedApiRewards.success) { getCurrentRewardFailCounter.add(1, { @@ -224,73 +223,25 @@ export const rewardQueryRouter = router({ return null } - const rewardIds = getNonRedeemedRewardIds(validatedApiRewards.data) - + const { benefits, coupons } = validatedApiRewards.data + const redeemableRewards = getRedeemableRewards([...benefits, ...coupons]) + const rewardIds = redeemableRewards.map(({ rewardId }) => rewardId).sort() const cmsRewards = await getCmsRewards(ctx.lang, rewardIds) - if (!cmsRewards) { return null } - const wrappedSurprisesIds = validatedApiRewards.data - .filter( - (reward) => - reward.type === "coupon" && - reward.rewardType === "Surprise" && - "coupon" in reward && - reward.coupon.some(({ unwrapped }) => !unwrapped) - ) - .map(({ rewardId }) => rewardId) + const rewards: BaseReward[] = cmsRewards.map((cmsReward) => { + // Non-null assertion is used here because we know our reward exist + const apiReward = redeemableRewards.find( + ({ rewardId }) => rewardId === cmsReward.reward_id + )! - const rewards = cmsRewards - .filter( - (cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id) - ) - .map((cmsReward) => { - const apiReward = validatedApiRewards.data.find( - ({ rewardId }) => rewardId === cmsReward.reward_id - ) - - const redeemableCoupons = - (apiReward && - "coupon" in apiReward && - apiReward.coupon.filter( - (coupon) => coupon.state !== "redeemed" && coupon.unwrapped - )) || - [] - - const firstRedeemableCouponToExpire = redeemableCoupons.reduce( - (earliest, coupon) => { - if (dt(coupon.expiresAt).isBefore(dt(earliest.expiresAt))) { - return coupon - } - return earliest - }, - redeemableCoupons[0] - )?.couponCode - - return { - ...cmsReward, - id: apiReward?.id, - rewardType: apiReward?.rewardType, - redeemLocation: apiReward?.redeemLocation, - rewardTierLevel: - apiReward && "rewardTierLevel" in apiReward - ? apiReward.rewardTierLevel - : undefined, - operaRewardId: - apiReward && "operaRewardId" in apiReward - ? apiReward.operaRewardId - : "", - categories: - apiReward && "categories" in apiReward - ? apiReward.categories || [] - : [], - couponCode: firstRedeemableCouponToExpire, - coupons: - apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [], - } - }) + return { + ...apiReward, + ...cmsReward, + } + }) getCurrentRewardSuccessCounter.add(1) @@ -301,10 +252,7 @@ export const rewardQueryRouter = router({ .query(async ({ ctx }) => { getCurrentRewardCounter.add(1) - const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT - const endpoint = isNewEndpoint - ? api.endpoints.v1.Profile.Reward.reward - : api.endpoints.v1.Profile.reward + const endpoint = api.endpoints.v1.Profile.Reward.reward const apiResponse = await api.get(endpoint, { cache: undefined, @@ -337,9 +285,8 @@ export const rewardQueryRouter = router({ } const data = await apiResponse.json() - const validatedApiRewards = isNewEndpoint - ? validateCategorizedRewardsSchema.safeParse(data) - : validateApiRewardSchema.safeParse(data) + const validatedApiRewards = + validateCategorizedRewardsSchema.safeParse(data) if (!validatedApiRewards.success) { getCurrentRewardFailCounter.add(1, { @@ -358,56 +305,33 @@ export const rewardQueryRouter = router({ return null } - const rewardIds = validatedApiRewards.data - .map((reward) => reward?.rewardId) - .filter((rewardId): rewardId is string => !!rewardId) + const unwrappedSurpriseRewards = getUnwrappedSurpriseRewards( + validatedApiRewards.data.coupons + ) + const rewardIds = unwrappedSurpriseRewards + .map(({ rewardId }) => rewardId) .sort() - const cmsRewards = await getCmsRewards(ctx.lang, rewardIds) - if (!cmsRewards) { return null } getCurrentRewardSuccessCounter.add(1) - const surprises: Surprise[] = validatedApiRewards.data - // TODO: Add predicates once legacy endpoints are removed - .filter((reward) => { - if (reward?.rewardType !== "Surprise") { - return false - } + const surprises: Surprise[] = cmsRewards + .map((cmsReward) => { + // Non-null assertion is used here because we know our reward exist + const apiReward = unwrappedSurpriseRewards.find( + ({ rewardId }) => rewardId === cmsReward.reward_id + )! - if (!("coupon" in reward)) { - return false - } - - const unwrappedCoupons = - reward.coupon.filter((coupon) => !coupon.unwrapped) || [] - if (unwrappedCoupons.length === 0) { - return false - } - - return true - }) - .map((surprise) => { - const reward = cmsRewards.find( - ({ reward_id }) => surprise.rewardId === reward_id - ) - - if (!reward) { + if (!cmsReward) { return null } return { - ...reward, - id: surprise.id, - rewardType: surprise.rewardType, - rewardTierLevel: undefined, - redeemLocation: surprise.redeemLocation, - coupons: "coupon" in surprise ? surprise.coupon || [] : [], - categories: - "categories" in surprise ? surprise.categories || [] : [], + ...apiReward, + ...cmsReward, } }) .flatMap((surprises) => (surprises ? [surprises] : [])) diff --git a/apps/scandic-web/server/routers/contentstack/reward/utils.ts b/apps/scandic-web/server/routers/contentstack/reward/utils.ts index 81f501cb9..10fafd95a 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/utils.ts @@ -1,11 +1,9 @@ import { metrics } from "@opentelemetry/api" -import { env } from "@/env/server" import * as api from "@/lib/api" -import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql" import { - GetRewards as GetRewardsWithReedem, - GetRewardsRef as GetRewardsWithRedeemRef, + GetRewards as GetRewards, + GetRewardsRef as GetRewardsRef, } from "@/lib/graphql/Query/RewardsWithRedeem.graphql" import { request } from "@/lib/graphql/request" import { notFound } from "@/server/errors/trpc" @@ -17,18 +15,15 @@ import { } from "@/utils/generateTag" import { - type ApiReward, - type CategorizedApiReward, - type CmsRewardsResponse, - type CmsRewardsWithRedeemResponse, - type GetRewardWithRedeemRefsSchema, - rewardWithRedeemRefsSchema, + rewardRefsSchema, validateApiAllTiersSchema, - validateApiTierRewardsSchema, validateCmsRewardsSchema, - validateCmsRewardsWithRedeemSchema, } from "./output" +import type { + CMSRewardsResponse, + GetRewardRefsSchema, +} from "@/types/trpc/routers/contentstack/reward" import type { Lang } from "@/constants/languages" const meter = metrics.getMeter("trpc.reward") @@ -93,71 +88,6 @@ export function getUniqueRewardIds(rewardIds: string[]) { return Array.from(uniqueRewardIds) } -/** - * Uses the legacy profile/v1/Profile/tierRewards endpoint. - * TODO: Delete when the new endpoint is out in production. - */ -export async function getAllCachedApiRewards(token: string) { - const cacheClient = await getCacheClient() - - return await cacheClient.cacheOrGet( - "getAllApiRewards", - async () => { - const apiResponse = await api.get(api.endpoints.v1.Profile.tierRewards, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - getAllRewardFailCounter.add(1, { - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.rewards.tierRewards error ", - JSON.stringify({ - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }, - }) - ) - - throw apiResponse - } - - const data = await apiResponse.json() - const validatedApiTierRewards = - validateApiTierRewardsSchema.safeParse(data) - - if (!validatedApiTierRewards.success) { - getAllRewardFailCounter.add(1, { - error_type: "validation_error", - error: JSON.stringify(validatedApiTierRewards.error), - }) - console.error(validatedApiTierRewards.error) - console.error( - "api.rewards validation error", - JSON.stringify({ - error: validatedApiTierRewards.error, - }) - ) - throw validatedApiTierRewards.error - } - - return validatedApiTierRewards.data - }, - "1h" - ) -} - /** * Cached for 1 hour. */ @@ -230,95 +160,81 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) { generateLoyaltyConfigTag(lang, "reward", id) ) - let cmsRewardsResponse - if (env.USE_NEW_REWARD_MODEL) { - getAllCMSRewardRefsCounter.add(1, { lang, rewardIds }) - console.info( - "contentstack.reward.refs start", + getAllCMSRewardRefsCounter.add(1, { lang, rewardIds }) + console.info( + "contentstack.reward.refs start", + JSON.stringify({ + query: { lang, rewardIds }, + }) + ) + const refsResponse = await request<GetRewardRefsSchema>( + GetRewardsRef, + { + locale: lang, + rewardIds, + }, + { + key: rewardIds.map((rewardId) => generateRefsResponseTag(lang, rewardId)), + ttl: "max", + } + ) + if (!refsResponse.data) { + const notFoundError = notFound(refsResponse) + getAllCMSRewardRefsFailCounter.add(1, { + lang, + rewardIds, + error_type: "not_found", + error: JSON.stringify({ code: notFoundError.code }), + }) + console.error( + "contentstack.reward.refs not found error", JSON.stringify({ query: { lang, rewardIds }, + error: { code: notFoundError.code }, }) ) - const refsResponse = await request<GetRewardWithRedeemRefsSchema>( - GetRewardsWithRedeemRef, - { - locale: lang, - rewardIds, - }, - { - key: rewardIds.map((rewardId) => - generateRefsResponseTag(lang, rewardId) - ), - ttl: "max", - } - ) - if (!refsResponse.data) { - const notFoundError = notFound(refsResponse) - getAllCMSRewardRefsFailCounter.add(1, { - lang, - rewardIds, - error_type: "not_found", - error: JSON.stringify({ code: notFoundError.code }), - }) - console.error( - "contentstack.reward.refs not found error", - JSON.stringify({ - query: { lang, rewardIds }, - error: { code: notFoundError.code }, - }) - ) - throw notFoundError - } - - const validatedRefsData = rewardWithRedeemRefsSchema.safeParse(refsResponse) - - if (!validatedRefsData.success) { - getAllCMSRewardRefsFailCounter.add(1, { - lang, - rewardIds, - error_type: "validation_error", - error: JSON.stringify(validatedRefsData.error), - }) - console.error( - "contentstack.reward.refs validation error", - JSON.stringify({ - query: { lang, rewardIds }, - error: validatedRefsData.error, - }) - ) - return null - } - - getAllCMSRewardRefsSuccessCounter.add(1, { lang, rewardIds }) - console.info( - "contentstack.startPage.refs success", - JSON.stringify({ - query: { lang, rewardIds }, - }) - ) - - cmsRewardsResponse = await request<CmsRewardsWithRedeemResponse>( - GetRewardsWithReedem, - { - locale: lang, - rewardIds, - }, - { - key: tags, - ttl: "max", - } - ) - } else { - cmsRewardsResponse = await request<CmsRewardsResponse>( - GetRewards, - { - locale: lang, - rewardIds, - }, - { key: tags, ttl: "max" } - ) + throw notFoundError } + const validatedRefsData = rewardRefsSchema.safeParse(refsResponse) + + if (!validatedRefsData.success) { + getAllCMSRewardRefsFailCounter.add(1, { + lang, + rewardIds, + error_type: "validation_error", + error: JSON.stringify(validatedRefsData.error), + }) + console.error( + "contentstack.reward.refs validation error", + JSON.stringify({ + query: { lang, rewardIds }, + error: validatedRefsData.error, + }) + ) + return null + } + + getAllCMSRewardRefsSuccessCounter.add(1, { lang, rewardIds }) + console.info( + "contentstack.startPage.refs success", + JSON.stringify({ + query: { lang, rewardIds }, + }) + ) + + const cmsRewardsResponse = await request<CMSRewardsResponse>( + GetRewards, + { + locale: lang, + rewardIds, + }, + { + key: tags, + ttl: "max", + } + ) + if (!cmsRewardsResponse.data) { getAllRewardFailCounter.add(1, { lang, @@ -339,9 +255,8 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) { throw notFoundError } - const validatedCmsRewards = env.USE_NEW_REWARD_MODEL - ? validateCmsRewardsWithRedeemSchema.safeParse(cmsRewardsResponse) - : validateCmsRewardsSchema.safeParse(cmsRewardsResponse) + const validatedCmsRewards = + validateCmsRewardsSchema.safeParse(cmsRewardsResponse) if (!validatedCmsRewards.success) { getAllRewardFailCounter.add(1, { @@ -363,20 +278,3 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) { return validatedCmsRewards.data } - -export function getNonRedeemedRewardIds( - rewards: Array<ApiReward | CategorizedApiReward> -) { - return rewards - .filter((reward) => { - if ("coupon" in reward && reward.coupon.length > 0) { - if (reward.coupon.every((coupon) => coupon.state === "redeemed")) { - return false - } - } - return true - }) - .map((reward) => reward?.rewardId) - .filter((rewardId): rewardId is string => !!rewardId) - .sort() -} diff --git a/apps/scandic-web/types/components/blocks/surprises.ts b/apps/scandic-web/types/components/blocks/surprises.ts index d4bdfdbbf..5bb9e2459 100644 --- a/apps/scandic-web/types/components/blocks/surprises.ts +++ b/apps/scandic-web/types/components/blocks/surprises.ts @@ -1,4 +1,4 @@ -import type { Surprise } from "@/server/routers/contentstack/reward/output" +import type { Surprise } from "../myPages/rewards" export interface SurprisesProps { surprises: Surprise[] diff --git a/apps/scandic-web/types/components/myPages/myPage/accountPage.ts b/apps/scandic-web/types/components/myPages/myPage/accountPage.ts index 565b445ac..dfe2575c1 100644 --- a/apps/scandic-web/types/components/myPages/myPage/accountPage.ts +++ b/apps/scandic-web/types/components/myPages/myPage/accountPage.ts @@ -3,10 +3,7 @@ import type { z } from "zod" import type { DynamicContent } from "@/types/trpc/routers/contentstack/blocks" import type { blocksSchema } from "@/server/routers/contentstack/accountPage/output" -import type { - Reward, - RewardWithRedeem, -} from "@/server/routers/contentstack/reward/output" +import type { Reward } from "../rewards" export interface AccountPageContentProps extends Pick<DynamicContent, "dynamic_content"> {} @@ -24,19 +21,18 @@ export type ContentProps = { } export interface CurrentRewardsClientProps { - rewards: (Reward | RewardWithRedeem)[] + rewards: Reward[] showRedeem: boolean membershipNumber?: string | null } export interface RedeemProps { - reward: RewardWithRedeem + reward: Reward membershipNumber?: string | null } export interface ScriptedRewardTextProps { - rewardType?: string - rewardTierLevel?: string + reward: Reward } export type RedeemModalState = "unmounted" | "hidden" | "visible" @@ -48,7 +44,6 @@ export type RedeemStep = | "confirm-close" export type RedeemFlowContext = { - reward: RewardWithRedeem | null redeemStep: RedeemStep setRedeemStep: Dispatch<SetStateAction<RedeemStep>> defaultTimeRemaining: number diff --git a/apps/scandic-web/types/components/myPages/rewards.ts b/apps/scandic-web/types/components/myPages/rewards.ts index b8b4b4ed5..f27128de2 100644 --- a/apps/scandic-web/types/components/myPages/rewards.ts +++ b/apps/scandic-web/types/components/myPages/rewards.ts @@ -1,12 +1,18 @@ -import type { IconProps } from "@/types/components/icon" -import type { MembershipLevelEnum } from "@/constants/membershipLevels" -import type { - RESTAURANT_REWARD_IDS, - REWARD_CATEGORIES, - REWARD_IDS, - REWARD_TYPES, +import { + type RESTAURANT_REWARD_IDS, + type REWARD_IDS, } from "@/constants/rewards" +import type { IconProps } from "@/types/components/icon" +import type { + ApiReward, + BenefitReward, + CMSReward, + CouponReward, +} from "@/types/trpc/routers/contentstack/reward" + +export type { BaseReward, Campaign, Reward, Surprise, Tier } + export interface RewardIconProps extends IconProps { rewardId: string size?: "small" | "medium" | "large" @@ -16,15 +22,21 @@ export type RewardId = (typeof REWARD_IDS)[keyof typeof REWARD_IDS] export type RestaurantRewardId = (typeof RESTAURANT_REWARD_IDS)[number] -export type RewardType = (typeof REWARD_TYPES)[number] - -export type RewardCategory = (typeof REWARD_CATEGORIES)[number] - -export interface FilterRewardsModalProps { - selectedCategories: RewardCategory[] - selectedLevels: MembershipLevelEnum[] - onCategoriesChange: (categories: RewardCategory[]) => void - onLevelsChange: (levels: MembershipLevelEnum[]) => void - availableTierLevels: MembershipLevelEnum[] - availableCategories: RewardCategory[] -} +type BaseReward = ApiReward & CMSReward +type Campaign = CouponReward & + CMSReward & { + rewardType: "Campaign" + } +type Surprise = CouponReward & + CMSReward & { + rewardType: "Surprise" + } +type Tier = BenefitReward & + CMSReward & { + rewardType: "Tier" + } +type MemberVoucher = CouponReward & + CMSReward & { + rewardType: "Member-voucher" + } +type Reward = Campaign | Surprise | Tier | MemberVoucher diff --git a/apps/scandic-web/types/components/overviewTable.ts b/apps/scandic-web/types/components/overviewTable.ts index 2d9b6bceb..0a242021e 100644 --- a/apps/scandic-web/types/components/overviewTable.ts +++ b/apps/scandic-web/types/components/overviewTable.ts @@ -1,6 +1,6 @@ -import { MembershipLevel } from "@/constants/membershipLevels" -import { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output" -import { Reward } from "@/server/routers/contentstack/reward/output" +import type { MembershipLevel } from "@/constants/membershipLevels" +import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output" +import type { CMSReward } from "../trpc/routers/contentstack/reward" export type OverviewTableClientProps = { activeMembership: MembershipLevel | null @@ -11,7 +11,7 @@ export type LevelCardProps = { level: LevelWithRewards } -export type LevelWithRewards = LoyaltyLevel & { rewards: Reward[] } +export type LevelWithRewards = LoyaltyLevel & { rewards: CMSReward[] } export type ComparisonLevel = LevelWithRewards @@ -21,13 +21,13 @@ export type LevelSummaryProps = { } export type RewardCardProps = { - comparedValues: (Reward | undefined)[] + comparedValues: (CMSReward | undefined)[] title: string description: string } export type RewardValueProps = { - reward?: Reward + reward?: CMSReward } export type RewardListProps = { diff --git a/apps/scandic-web/types/trpc/routers/contentstack/reward.ts b/apps/scandic-web/types/trpc/routers/contentstack/reward.ts new file mode 100644 index 000000000..fdf4a272f --- /dev/null +++ b/apps/scandic-web/types/trpc/routers/contentstack/reward.ts @@ -0,0 +1,48 @@ +import type { z } from "zod" + +import type { + BenefitReward, + CouponData, + CouponReward, + REDEEM_LOCATIONS, + REWARD_TYPES, + rewardRefsSchema, + validateCmsRewardsSchema, +} from "@/server/routers/contentstack/reward/output" + +export type { + ApiReward, + BenefitReward, + CMSReward, + CMSRewardsResponse, + Coupon, + CouponReward, + GetRewardRefsSchema, + RedeemableCoupon, + RedeemLocation, + RewardType, + SurpriseReward, +} + +type CMSRewardsResponse = z.input<typeof validateCmsRewardsSchema> +type CMSReward = z.output<typeof validateCmsRewardsSchema>[number] + +type GetRewardRefsSchema = z.input<typeof rewardRefsSchema> + +type RedeemLocation = (typeof REDEEM_LOCATIONS)[number] + +type RewardType = (typeof REWARD_TYPES)[keyof typeof REWARD_TYPES] + +type Coupon = z.output<typeof CouponData> +type RedeemableCoupon = Coupon & { + state: Exclude<Coupon["state"], "redeemed"> +} + +type BenefitReward = z.output<typeof BenefitReward> + +type CouponReward = z.output<typeof CouponReward> +type SurpriseReward = CouponReward & { + rewardType: "Surprise" +} + +type ApiReward = BenefitReward | CouponReward diff --git a/apps/scandic-web/utils/loyaltyTable.ts b/apps/scandic-web/utils/loyaltyTable.ts index 9f516b03e..bc6f91a44 100644 --- a/apps/scandic-web/utils/loyaltyTable.ts +++ b/apps/scandic-web/utils/loyaltyTable.ts @@ -1,6 +1,5 @@ -import { Reward } from "@/server/routers/contentstack/reward/output" - import type { ComparisonLevel } from "@/types/components/overviewTable" +import type { CMSReward } from "@/types/trpc/routers/contentstack/reward" export function getGroupedRewards(levels: ComparisonLevel[]) { const allRewards = levels @@ -9,7 +8,7 @@ export function getGroupedRewards(levels: ComparisonLevel[]) { }) .flat() - const mappedRewards = allRewards.reduce<Record<string, Reward[]>>( + const mappedRewards = allRewards.reduce<Record<string, CMSReward[]>>( (acc, curr) => { const taxonomiTerm = curr.taxonomies.find((tax) => tax.term_uid)?.term_uid @@ -39,10 +38,8 @@ export function findAvailableRewards( return level.rewards.find((r) => allRewardIds.includes(r.reward_id)) } -export function getGroupedLabelAndDescription(rewards: Reward[]) { - const reward = rewards.find( - (reward) => !!(reward.grouped_label && reward.grouped_label) - ) +export function getGroupedLabelAndDescription(rewards: CMSReward[]) { + const reward = rewards.find((reward) => !!reward.grouped_label) return { label: reward?.grouped_label ?? "", description: reward?.grouped_description ?? "", diff --git a/apps/scandic-web/utils/rewards.ts b/apps/scandic-web/utils/rewards.ts index c3d8dee8f..3bb170d09 100644 --- a/apps/scandic-web/utils/rewards.ts +++ b/apps/scandic-web/utils/rewards.ts @@ -1,31 +1,29 @@ -import { - RESTAURANT_REWARD_IDS, - REWARD_CATEGORIES, - REWARD_IDS, - REWARD_TYPES, -} from "@/constants/rewards" +import { RESTAURANT_REWARD_IDS, REWARD_IDS } from "@/constants/rewards" import { dt } from "@/lib/dt" import type { Dayjs } from "dayjs" import type { RestaurantRewardId, - RewardCategory, RewardId, - RewardType, } from "@/types/components/myPages/rewards" import type { - Coupon, - RewardWithRedeem, -} from "@/server/routers/contentstack/reward/output" + ApiReward, + RedeemableCoupon, + RedeemLocation, + SurpriseReward, +} from "@/types/trpc/routers/contentstack/reward" export { getEarliestExpirationDate, - getRewardType, + getFirstRedeemableCoupon, + getRedeemableRewards, + getReedemableCoupons, + getUnwrappedSurpriseRewards, isOnSiteTierReward, isRestaurantOnSiteTierReward, isRestaurantReward, - isRewardCategory, + isSurpriseReward, isTierType, isValidRewardId, redeemLocationIsOnSite, @@ -39,37 +37,46 @@ function isRestaurantReward(rewardId: string): rewardId is RestaurantRewardId { return RESTAURANT_REWARD_IDS.some((id) => id === rewardId) } -function isRewardCategory(value: string): value is RewardCategory { - return REWARD_CATEGORIES.some((category) => category === value) -} - function redeemLocationIsOnSite( - location: RewardWithRedeem["redeemLocation"] + location: RedeemLocation ): location is "On-site" { return location === "On-site" } -function isTierType(type: RewardWithRedeem["rewardType"]): type is "Tier" { +function isTierType(type: string): type is "Tier" { return type === "Tier" } -function isOnSiteTierReward(reward: RewardWithRedeem): boolean { +function isOnSiteTierReward(reward: ApiReward): boolean { return ( redeemLocationIsOnSite(reward.redeemLocation) && isTierType(reward.rewardType) ) } -function isRestaurantOnSiteTierReward(reward: RewardWithRedeem): boolean { - return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id) +function isRestaurantOnSiteTierReward(reward: ApiReward): boolean { + return isOnSiteTierReward(reward) && isRestaurantReward(reward.rewardId) } -function getRewardType(type?: string): RewardType | null { - return REWARD_TYPES.find((t) => t === type) ?? null +function getReedemableCoupons(reward: ApiReward): RedeemableCoupon[] { + if ("coupon" in reward) { + return reward.coupon.filter( + (coupon): coupon is RedeemableCoupon => coupon.state !== "redeemed" + ) + } + return [] } -function getEarliestExpirationDate(coupons: Coupon[]) { - return coupons +function getFirstRedeemableCoupon(reward: ApiReward): RedeemableCoupon { + const sortedCoupons = getReedemableCoupons(reward).sort((a, b) => { + // null values used instead of undefined, otherwise it will return current time + return dt(a.expiresAt ?? null).valueOf() - dt(b.expiresAt ?? null).valueOf() + }) + return sortedCoupons[0] +} + +function getEarliestExpirationDate(reward: ApiReward) { + return getReedemableCoupons(reward) .map(({ expiresAt }) => expiresAt) .filter((expiresAt): expiresAt is string => !!expiresAt) .reduce((earliestDate: Dayjs | null, expiresAt) => { @@ -81,3 +88,37 @@ function getEarliestExpirationDate(coupons: Coupon[]) { return earliestDate.isBefore(expiresAtDate) ? earliestDate : expiresAtDate }, null) } + +function isSurpriseReward(reward: ApiReward): reward is SurpriseReward { + return reward.rewardType === "Surprise" +} + +function getUnwrappedSurpriseRewards(rewards: ApiReward[]) { + return rewards + .filter(isSurpriseReward) + .filter((reward) => getReedemableCoupons(reward).length) + .filter((reward) => { + const unwrappedCoupons = + reward.coupon.filter((coupon) => !coupon.unwrapped) || [] + + return unwrappedCoupons.length + }) +} + +function getRedeemableRewards(rewards: ApiReward[]) { + return rewards + .filter((reward) => { + if ("coupon" in reward && reward.coupon.length > 0) { + if (reward.coupon.every((coupon) => coupon.state === "redeemed")) { + return false + } + } + return true + }) + .filter((reward) => { + if (isSurpriseReward(reward)) { + return !reward.coupon.some(({ unwrapped }) => !unwrapped) + } + return true + }) +}