diff --git a/docs/external-entry-links.md b/docs/external-entry-links.md index 16b7efe7..36a196f2 100644 --- a/docs/external-entry-links.md +++ b/docs/external-entry-links.md @@ -15,11 +15,7 @@ APP HOST:`frontend-test.banlv-ai.com` ### 咖啡打赏 -| 咖啡档位 | `coffee_type` | 链接 | -| --- | --- | --- | -| Small Coffee (US$4.99) | `small` | [打开 Small Coffee 打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&coffee_type=small) | -| Medium Coffee (US$9.99) | `medium` | [打开 Medium Coffee 打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&coffee_type=medium) | -| Large Coffee (US$19.99) | `large` | [打开 Large Coffee 打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&coffee_type=large) | +[打开咖啡打赏档位选择页](https://frontend-test.banlv-ai.com/external-entry?target=tip) ### 私密空间 @@ -38,11 +34,7 @@ APP HOST:`cozsweet.com` ### 咖啡打赏 -| 咖啡档位 | `coffee_type` | 链接 | -| --- | --- | --- | -| Small Coffee (US$4.99) | `small` | [打开 Small Coffee 打赏](https://cozsweet.com/external-entry?target=tip&coffee_type=small) | -| Medium Coffee (US$9.99) | `medium` | [打开 Medium Coffee 打赏](https://cozsweet.com/external-entry?target=tip&coffee_type=medium) | -| Large Coffee (US$19.99) | `large` | [打开 Large Coffee 打赏](https://cozsweet.com/external-entry?target=tip&coffee_type=large) | +[打开咖啡打赏档位选择页](https://cozsweet.com/external-entry?target=tip) ### 私密空间 diff --git a/docs/external-entry.md b/docs/external-entry.md index fa303915..0d3b8ffc 100644 --- a/docs/external-entry.md +++ b/docs/external-entry.md @@ -9,7 +9,7 @@ 普通模式和促销模式的可点击链接统一维护在 [外部入口链接清单](./external-entry-links.md)。 -入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面。身份参数和入口控制参数会从地址栏中移除;目标页面自身需要的状态参数会保留,例如打赏页的 `coffee_type`。 +入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面。身份参数和入口控制参数会从地址栏中移除。 如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。 @@ -19,7 +19,6 @@ | --- | --- | --- | --- | | `target` | 否 | `chat`、`tip`、`private-room` | 最终页面,缺失或无效时进入 `chat`。 | | `psid` | 否 | string | Facebook Page-scoped User ID。 | -| `coffee_type` | 打赏入口可选 | `small`、`medium`、`large` | `target=tip` 时指定咖啡档位,缺失或非法时默认为 `small`。 | | `mode` | 否 | `promotion` | 设置为 `promotion` 时尝试开启聊天促销模式。 | | `promotion_type` | 促销模式必填 | `voice`、`image`、`private` | 指定促销锁消息类型。 | @@ -35,16 +34,16 @@ https:///external-entry?target=chat&psid= ``` -进入打赏页面(默认 Small Coffee): +进入打赏页面: ```text https:///external-entry?target=tip ``` -入口会跳转到规范地址: +入口会跳转到规范地址,并在页面内默认选择 Medium 档位: ```text -https:///tip?coffee_type=small +https:///tip ``` 进入私密空间: @@ -55,23 +54,21 @@ https:///external-entry?target=private-room ## 咖啡打赏入口 -打赏入口支持三种咖啡档位: +打赏页同时展示三种咖啡档位,由用户进入页面后选择: -| `coffee_type` | 展示名称 | 价格 | 后端套餐 `planId` | +| 档位 | 展示名称 | 价格 | 后端套餐 `planId` | | --- | --- | --- | --- | -| `small` | Small Coffee | US$4.99 | `tip_coffee_usd_4_99` | -| `medium` | Medium Coffee | US$9.99 | `tip_coffee_usd_9_99` | -| `large` | Large Coffee | US$19.99 | `tip_coffee_usd_19_99` | +| Small | Velvet Espresso | US$4.99 | `tip_coffee_usd_4_99` | +| Medium(默认) | Golden Reserve | US$9.99 | `tip_coffee_usd_9_99` | +| Large | Imperial Grand Cru | US$19.99 | `tip_coffee_usd_19_99` | -分别进入三种咖啡打赏页面: +外部应用只需使用统一入口: ```text -https:///external-entry?target=tip&coffee_type=small -https:///external-entry?target=tip&coffee_type=medium -https:///external-entry?target=tip&coffee_type=large +https:///external-entry?target=tip ``` -外部入口会将它们规范化为 `/tip?coffee_type=`。`coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。 +咖啡档位不再由外部入口指定。用户选择后,前端会在登录、Stripe 和 Ezpay 回跳期间恢复当前档位。 实际创建订单时,前端通过 `/api/payment/tip-plans` 获取套餐,并只使用接口返回的对应 `planId`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。 @@ -87,6 +84,5 @@ https:///external-entry?target=tip&coffee_type=large - 所有参数值必须使用 URL 编码。 - 不要通过入口传递登录 token、Page Access Token 或 App Secret。 -- `coffee_type` 仅在 `target=tip` 时生效;缺失或非法时使用 `small`。 - `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。 - 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。 diff --git a/public/images/tip/large.png b/public/images/tip/large.png new file mode 100644 index 00000000..f199adb5 Binary files /dev/null and b/public/images/tip/large.png differ diff --git a/public/images/tip/medium.png b/public/images/tip/medium.png new file mode 100644 index 00000000..c688ea55 Binary files /dev/null and b/public/images/tip/medium.png differ diff --git a/public/images/tip/coffee.jpg b/public/images/tip/small.jpg similarity index 100% rename from public/images/tip/coffee.jpg rename to public/images/tip/small.jpg diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index 6f8ecdb6..1a5bfef4 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -26,7 +26,6 @@ interface ExternalEntryPersistProps { psid: string | null; avatarUrl: string | null; target: string | null; - coffeeType: string | null; mode: string | null; promotionType: string | null; } @@ -37,7 +36,6 @@ export default function ExternalEntryPersist({ psid, avatarUrl, target, - coffeeType, mode, promotionType, }: ExternalEntryPersistProps) { @@ -47,7 +45,7 @@ export default function ExternalEntryPersist({ const [hasPersisted, setHasPersisted] = useState(false); const hasNavigatedRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); - const destination = resolveExternalEntryDestination({ target, coffeeType }); + const destination = resolveExternalEntryDestination({ target }); const resolvedPromotionType = resolveExternalEntryPromotionType({ mode, promotionType, @@ -85,7 +83,6 @@ export default function ExternalEntryPersist({ }, [ avatarUrl, asid, - coffeeType, destination, deviceId, mode, diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 78ad38c6..ff176b58 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -4,7 +4,7 @@ * 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如: * `/external-entry?target=chat&asid=xxx&psid=yyy` * `/external-entry?target=chat&mode=promotion&promotion_type=voice` - * `/external-entry?target=tip&coffee_type=medium` + * `/external-entry?target=tip` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 @@ -30,7 +30,6 @@ export default async function ExternalEntryPage({ psid={pickParam(params.psid)} avatarUrl={pickParam(params.avatar_url)} target={pickParam(params.target)} - coffeeType={pickParam(params.coffee_type)} mode={pickParam(params.mode)} promotionType={pickParam(params.promotion_type)} /> diff --git a/src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx b/src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx new file mode 100644 index 00000000..5da2b02a --- /dev/null +++ b/src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx @@ -0,0 +1,99 @@ +/* @vitest-environment jsdom */ + +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { TipCoffeeType } from "@/lib/tip/tip_coffee"; + +import { + TipCoffeeTierSelector, + type TipCoffeeTierItem, +} from "../tip-coffee-tier-selector"; + +const items: readonly TipCoffeeTierItem[] = [ + { + type: "small", + tierLabel: "Small", + displayName: "Velvet Espresso", + priceLabel: "US$ 4.99", + unavailable: false, + }, + { + type: "medium", + tierLabel: "Medium", + displayName: "Golden Reserve", + priceLabel: "US$ 9.99", + unavailable: false, + }, + { + type: "large", + tierLabel: "Large", + displayName: "Imperial Grand Cru", + priceLabel: "US$ 19.99", + unavailable: false, + }, +]; + +function Harness({ tierItems = items }: { tierItems?: readonly TipCoffeeTierItem[] }) { + const [selectedType, setSelectedType] = + useState("medium"); + + return ( + + ); +} + +describe("TipCoffeeTierSelector", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("renders every luxury tier and selects medium by default", () => { + act(() => root.render()); + + expect(container.textContent).toContain("Velvet Espresso"); + expect(container.textContent).toContain("Golden Reserve"); + expect(container.textContent).toContain("Imperial Grand Cru"); + expect( + container.querySelector('input[value="medium"]') + ?.checked, + ).toBe(true); + }); + + it("switches to an available tier and keeps unavailable tiers disabled", () => { + const tierItems = items.map((item) => + item.type === "large" ? { ...item, unavailable: true } : item, + ); + act(() => root.render()); + + const small = container.querySelector( + 'input[value="small"]', + ); + const large = container.querySelector( + 'input[value="large"]', + ); + act(() => small?.click()); + + expect(small?.checked).toBe(true); + expect(large?.disabled).toBe(true); + expect(container.textContent).toContain("Unavailable"); + }); +}); diff --git a/src/app/tip/tip-coffee-tier-selector.tsx b/src/app/tip/tip-coffee-tier-selector.tsx new file mode 100644 index 00000000..7c5ad0aa --- /dev/null +++ b/src/app/tip/tip-coffee-tier-selector.tsx @@ -0,0 +1,72 @@ +import { Check } from "lucide-react"; + +import type { TipCoffeeType } from "@/lib/tip/tip_coffee"; + +import styles from "./tip-screen.module.css"; + +export interface TipCoffeeTierItem { + type: TipCoffeeType; + tierLabel: string; + displayName: string; + priceLabel: string; + unavailable: boolean; +} + +interface TipCoffeeTierSelectorProps { + disabled: boolean; + items: readonly TipCoffeeTierItem[]; + onChange: (type: TipCoffeeType) => void; + selectedType: TipCoffeeType; +} + +export function TipCoffeeTierSelector({ + disabled, + items, + onChange, + selectedType, +}: TipCoffeeTierSelectorProps) { + return ( +
+ Choose your coffee +
+ {items.map((item) => { + const isSelected = item.type === selectedType; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/app/tip/tip-screen.module.css b/src/app/tip/tip-screen.module.css index 7db757eb..3b7e608d 100644 --- a/src/app/tip/tip-screen.module.css +++ b/src/app/tip/tip-screen.module.css @@ -236,6 +236,156 @@ letter-spacing: -0.04em; } +.tierSelector { + grid-column: 1 / -1; + min-width: 0; + margin: 2px 0 0; + padding: 0; + border: 0; +} + +.tierLegend { + width: 100%; + margin-bottom: 12px; + color: #6f5055; + font-size: clamp(13px, 3.148vw, 15px); + font-weight: 850; + letter-spacing: 0.025em; +} + +.tierList { + display: grid; + gap: 10px; +} + +.tierOption { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 28px; + align-items: center; + gap: 12px; + min-height: 72px; + padding: 12px 14px 12px 16px; + border: 1px solid rgba(112, 71, 65, 0.12); + border-radius: 20px; + background: rgba(255, 255, 255, 0.68); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.tierOption[data-selected="true"] { + border-color: var(--color-accent, #f84d96); + background: + linear-gradient(105deg, rgba(255, 255, 255, 0.94), rgba(255, 235, 243, 0.9)), + #ffffff; + box-shadow: + 0 12px 28px rgba(248, 77, 150, 0.16), + inset 3px 0 0 var(--color-accent, #f84d96); +} + +.tierOption[data-unavailable="true"], +.tierSelector:disabled .tierOption { + cursor: not-allowed; +} + +.tierOption[data-unavailable="true"] { + filter: grayscale(0.25); + opacity: 0.56; +} + +.tierSelector:disabled .tierOption { + opacity: 0.72; +} + +.tierInput { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.tierOption:has(.tierInput:focus-visible) { + outline: 3px solid rgba(248, 77, 150, 0.28); + outline-offset: 3px; +} + +.tierDetails, +.tierPriceBlock { + display: flex; + min-width: 0; + flex-direction: column; +} + +.tierDetails { + gap: 4px; +} + +.tierLabel { + color: #a36d73; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.tierName { + overflow: hidden; + color: #2a1b1e; + font-family: var(--font-athelas), Georgia, serif; + font-size: clamp(17px, 4.259vw, 21px); + font-weight: 750; + letter-spacing: -0.02em; + line-height: 1.05; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tierPriceBlock { + align-items: flex-end; + gap: 3px; + text-align: right; +} + +.tierPrice { + color: #d23f79; + font-size: clamp(14px, 3.519vw, 17px); + font-weight: 950; + white-space: nowrap; +} + +.tierUnavailable { + color: #8f7376; + font-size: 10px; + font-weight: 800; +} + +.tierCheck { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border: 1px solid rgba(111, 76, 73, 0.2); + border-radius: 999px; + color: transparent; +} + +.tierOption[data-selected="true"] .tierCheck { + border-color: var(--color-accent, #f84d96); + background: var(--color-accent, #f84d96); + color: #ffffff; + box-shadow: 0 5px 14px rgba(248, 77, 150, 0.26); +} + .successCard { display: flex; align-items: center; @@ -352,6 +502,10 @@ width: min(100%, 220px); justify-self: center; } + + .tierName { + white-space: normal; + } } @media (hover: hover) { @@ -359,6 +513,12 @@ .checkoutButton:not(:disabled):hover { transform: translateY(-1px); } + + .tierSelector:not(:disabled) + .tierOption:not([data-unavailable="true"]):hover { + border-color: rgba(248, 77, 150, 0.42); + transform: translateY(-1px); + } } @keyframes floatIn { diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 8585b930..57bc7e69 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { ArrowLeft, Heart, Sparkles } from "lucide-react"; @@ -17,6 +17,7 @@ import { buildTipCoffeePath, DEFAULT_TIP_COFFEE_TYPE, getTipCoffeeOption, + TIP_COFFEE_OPTIONS, type TipCoffeeType, } from "@/lib/tip/tip_coffee"; import { useAppNavigator } from "@/router/use-app-navigator"; @@ -27,6 +28,10 @@ import { } from "@/stores/payment/payment-context"; import { TipCheckoutButton } from "./tip-checkout-button"; +import { + TipCoffeeTierSelector, + type TipCoffeeTierItem, +} from "./tip-coffee-tier-selector"; import { findTipCoffeePlan, formatTipPrice, @@ -55,17 +60,44 @@ export function TipScreen({ const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const initialPayChannelAppliedRef = useRef(false); - const coffeeOption = getTipCoffeeOption(coffeeType); - const returnPath = buildTipCoffeePath(coffeeType); + const [selectedCoffeeType, setSelectedCoffeeType] = + useState(coffeeType); + const coffeeOption = getTipCoffeeOption(selectedCoffeeType); + const returnPath = buildTipCoffeePath(selectedCoffeeType); const resolvedInitialPayChannel = initialPayChannel ?? navigator.getDefaultPayChannel(); - const coffeePlan = useMemo( - () => findTipCoffeePlan(payment.plans, coffeeType), - [coffeeType, payment.plans], + const coffeeTiers = useMemo( + () => + TIP_COFFEE_OPTIONS.map((option) => ({ + option, + plan: findTipCoffeePlan(payment.plans, option.type), + })), + [payment.plans], ); - const priceLabel = formatTipPrice(coffeePlan, coffeeType); - usePaymentPlanAnalytics(coffeePlan ? [coffeePlan] : [], TIP_ANALYTICS_CONTEXT); + const coffeePlan = + coffeeTiers.find(({ option }) => option.type === selectedCoffeeType)?.plan ?? + null; + const priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType); + const availableCoffeePlans = useMemo( + () => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])), + [coffeeTiers], + ); + const tierItems = useMemo( + () => + coffeeTiers.map(({ option, plan }) => ({ + type: option.type, + tierLabel: option.tierLabel, + displayName: option.displayName, + priceLabel: formatTipPrice(plan, option.type), + unavailable: + payment.status === "ready" && + !payment.isLoadingPlans && + plan === null, + })), + [coffeeTiers, payment.isLoadingPlans, payment.status], + ); + usePaymentPlanAnalytics(availableCoffeePlans, TIP_ANALYTICS_CONTEXT); const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid; const canCreateOrder = @@ -77,6 +109,8 @@ export function TipScreen({ !isPaymentBusy; const showMissingPlan = payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null; + const isTierSelectionDisabled = + payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; const isAuthLoading = !authState.hasInitialized || authState.isLoading; usePaymentOrderLifecycle({ @@ -159,6 +193,20 @@ export function TipScreen({ paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); }; + const handleCoffeeTypeChange = (type: TipCoffeeType) => { + if (isTierSelectionDisabled) return; + const nextPlan = findTipCoffeePlan(payment.plans, type); + if (!nextPlan) return; + + setSelectedCoffeeType(type); + if (payment.selectedPlanId !== nextPlan.planId) { + paymentDispatch({ + type: "PaymentPlanSelected", + planId: nextPlan.planId, + }); + } + }; + const handleBack = () => { navigator.back(); }; @@ -207,12 +255,12 @@ export function TipScreen({
-
{showMissingPlan ? ( @@ -257,7 +312,7 @@ export function TipScreen({
{ expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat); }); - it("routes tip entries to the requested coffee type", () => { - expect( - resolveExternalEntryDestination({ - target: "tip", - coffeeType: "medium", - }), - ).toBe("/tip?coffee_type=medium"); - expect( - resolveExternalEntryDestination({ - target: "tip", - coffeeType: "large", - }), - ).toBe("/tip?coffee_type=large"); - }); - - it("defaults invalid tip coffee types to small", () => { - expect( - resolveExternalEntryDestination({ - target: "tip", - coffeeType: "unknown", - }), - ).toBe("/tip?coffee_type=small"); + it("routes every tip entry to the tier selection page", () => { + expect(resolveExternalEntryDestination({ target: "tip" })).toBe("/tip"); }); }); diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index 290fc95a..13862b7b 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -5,11 +5,6 @@ import { UserStorage } from "@/data/storage/user/user_storage"; import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { LoginStatus } from "@/data/dto/auth"; import { ROUTES } from "@/router/routes"; -import { - buildTipCoffeePath, - DEFAULT_TIP_COFFEE_TYPE, - resolveTipCoffeeType, -} from "@/lib/tip/tip_coffee"; export type ExternalEntryTarget = | typeof ROUTES.chat @@ -27,10 +22,6 @@ export interface ExternalEntryTargetInput { target?: string | null; } -export interface ExternalEntryDestinationInput extends ExternalEntryTargetInput { - coffeeType?: string | null; -} - export interface ExternalEntryPromotionInput { mode?: string | null; promotionType?: string | null; @@ -88,14 +79,8 @@ export function resolveExternalEntryTarget({ export function resolveExternalEntryDestination({ target, - coffeeType, -}: ExternalEntryDestinationInput): string { - const resolvedTarget = resolveExternalEntryTarget({ target }); - if (resolvedTarget !== ROUTES.tip) return resolvedTarget; - - return buildTipCoffeePath( - resolveTipCoffeeType(coffeeType) ?? DEFAULT_TIP_COFFEE_TYPE, - ); +}: ExternalEntryTargetInput): string { + return resolveExternalEntryTarget({ target }); } export async function persistExternalEntryPayload({ diff --git a/src/lib/payment/__tests__/pending_payment_order.test.ts b/src/lib/payment/__tests__/pending_payment_order.test.ts index 477d7399..7445718d 100644 --- a/src/lib/payment/__tests__/pending_payment_order.test.ts +++ b/src/lib/payment/__tests__/pending_payment_order.test.ts @@ -23,12 +23,12 @@ describe("pending payment order helpers", () => { ).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large"); }); - it("defaults legacy tip payment returns to small coffee", () => { + it("defaults legacy tip payment returns to medium coffee", () => { expect( buildPendingPaymentSubscriptionUrl({ payChannel: "ezpay", subscriptionType: "tip", }), - ).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small"); + ).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium"); }); }); diff --git a/src/lib/tip/__tests__/tip_coffee.test.ts b/src/lib/tip/__tests__/tip_coffee.test.ts index 6c9d00e4..32b2ff31 100644 --- a/src/lib/tip/__tests__/tip_coffee.test.ts +++ b/src/lib/tip/__tests__/tip_coffee.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { buildTipCoffeePath, + DEFAULT_TIP_COFFEE_TYPE, getTipCoffeeOption, resolveTipCoffeeType, + TIP_COFFEE_OPTIONS, } from "../tip_coffee"; describe("tip coffee configuration", () => { @@ -21,18 +23,33 @@ describe("tip coffee configuration", () => { it("provides the configured plan and fallback price for each type", () => { expect(getTipCoffeeOption("small")).toMatchObject({ amountCents: 499, + displayName: "Velvet Espresso", + image: { src: "/images/tip/small.jpg", width: 736, height: 736 }, planId: "tip_coffee_usd_4_99", }); expect(getTipCoffeeOption("medium")).toMatchObject({ amountCents: 999, + displayName: "Golden Reserve", + image: { src: "/images/tip/medium.png", width: 1024, height: 1024 }, planId: "tip_coffee_usd_9_99", }); expect(getTipCoffeeOption("large")).toMatchObject({ amountCents: 1999, + displayName: "Imperial Grand Cru", + image: { src: "/images/tip/large.png", width: 1024, height: 1024 }, planId: "tip_coffee_usd_19_99", }); }); + it("lists all tiers in display order and defaults to medium", () => { + expect(TIP_COFFEE_OPTIONS.map(({ type }) => type)).toEqual([ + "small", + "medium", + "large", + ]); + expect(DEFAULT_TIP_COFFEE_TYPE).toBe("medium"); + }); + it("builds canonical tip paths", () => { expect(buildTipCoffeePath("small")).toBe("/tip?coffee_type=small"); expect(buildTipCoffeePath("medium")).toBe("/tip?coffee_type=medium"); diff --git a/src/lib/tip/tip_coffee.ts b/src/lib/tip/tip_coffee.ts index b4f0a0eb..45b3a3b8 100644 --- a/src/lib/tip/tip_coffee.ts +++ b/src/lib/tip/tip_coffee.ts @@ -1,38 +1,68 @@ import { ROUTES } from "@/router/routes"; export const TIP_COFFEE_TYPE_PARAM = "coffee_type"; -export const DEFAULT_TIP_COFFEE_TYPE = "small"; +export const DEFAULT_TIP_COFFEE_TYPE = "medium"; export type TipCoffeeType = "small" | "medium" | "large"; export interface TipCoffeeOption { - type: TipCoffeeType; - amountCents: number; - fallbackName: string; - planId: string; + readonly type: TipCoffeeType; + readonly tierLabel: string; + readonly amountCents: number; + readonly displayName: string; + readonly image: { + readonly src: string; + readonly width: number; + readonly height: number; + }; + readonly planId: string; } -const TIP_COFFEE_OPTIONS: Record = { +const TIP_COFFEE_OPTION_BY_TYPE: Record = { small: { type: "small", + tierLabel: "Small", amountCents: 499, - fallbackName: "Small Coffee", + displayName: "Velvet Espresso", + image: { + src: "/images/tip/small.jpg", + width: 736, + height: 736, + }, planId: "tip_coffee_usd_4_99", }, medium: { type: "medium", + tierLabel: "Medium", amountCents: 999, - fallbackName: "Medium Coffee", + displayName: "Golden Reserve", + image: { + src: "/images/tip/medium.png", + width: 1024, + height: 1024, + }, planId: "tip_coffee_usd_9_99", }, large: { type: "large", + tierLabel: "Large", amountCents: 1999, - fallbackName: "Large Coffee", + displayName: "Imperial Grand Cru", + image: { + src: "/images/tip/large.png", + width: 1024, + height: 1024, + }, planId: "tip_coffee_usd_19_99", }, }; +export const TIP_COFFEE_OPTIONS: readonly TipCoffeeOption[] = [ + TIP_COFFEE_OPTION_BY_TYPE.small, + TIP_COFFEE_OPTION_BY_TYPE.medium, + TIP_COFFEE_OPTION_BY_TYPE.large, +]; + export function resolveTipCoffeeType( value: string | null | undefined, ): TipCoffeeType | null { @@ -48,7 +78,7 @@ export function resolveTipCoffeeType( } export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption { - return TIP_COFFEE_OPTIONS[type]; + return TIP_COFFEE_OPTION_BY_TYPE[type]; } export function buildTipCoffeePath(type: TipCoffeeType): string {