feat(tip): add selectable coffee gift tiers

This commit is contained in:
2026-07-15 17:00:25 +08:00
parent e1a43a641b
commit c277b3e6ca
16 changed files with 480 additions and 98 deletions
+2 -10
View File
@@ -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)
### 私密空间
+12 -16
View File
@@ -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://<APP_HOST>/external-entry?target=chat&psid=<PSID>
```
进入打赏页面(默认 Small Coffee
进入打赏页面:
```text
https://<APP_HOST>/external-entry?target=tip
```
入口会跳转到规范地址:
入口会跳转到规范地址,并在页面内默认选择 Medium 档位
```text
https://<APP_HOST>/tip?coffee_type=small
https://<APP_HOST>/tip
```
进入私密空间:
@@ -55,23 +54,21 @@ https://<APP_HOST>/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://<APP_HOST>/external-entry?target=tip&coffee_type=small
https://<APP_HOST>/external-entry?target=tip&coffee_type=medium
https://<APP_HOST>/external-entry?target=tip&coffee_type=large
https://<APP_HOST>/external-entry?target=tip
```
外部入口会将它们规范化为 `/tip?coffee_type=<type>``coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。
咖啡档位不再由外部入口指定。用户选择后,前端会在登录、Stripe 和 Ezpay 回跳期间恢复当前档位。
实际创建订单时,前端通过 `/api/payment/tip-plans` 获取套餐,并只使用接口返回的对应 `planId`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。
@@ -87,6 +84,5 @@ https://<APP_HOST>/external-entry?target=tip&coffee_type=large
- 所有参数值必须使用 URL 编码。
- 不要通过入口传递登录 token、Page Access Token 或 App Secret。
- `coffee_type` 仅在 `target=tip` 时生效;缺失或非法时使用 `small`
- `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。
- 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -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,
+1 -2
View File
@@ -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)}
/>
@@ -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<TipCoffeeType>("medium");
return (
<TipCoffeeTierSelector
disabled={false}
items={tierItems}
onChange={setSelectedType}
selectedType={selectedType}
/>
);
}
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(<Harness />));
expect(container.textContent).toContain("Velvet Espresso");
expect(container.textContent).toContain("Golden Reserve");
expect(container.textContent).toContain("Imperial Grand Cru");
expect(
container.querySelector<HTMLInputElement>('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(<Harness tierItems={tierItems} />));
const small = container.querySelector<HTMLInputElement>(
'input[value="small"]',
);
const large = container.querySelector<HTMLInputElement>(
'input[value="large"]',
);
act(() => small?.click());
expect(small?.checked).toBe(true);
expect(large?.disabled).toBe(true);
expect(container.textContent).toContain("Unavailable");
});
});
+72
View File
@@ -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 (
<fieldset className={styles.tierSelector} disabled={disabled}>
<legend className={styles.tierLegend}>Choose your coffee</legend>
<div className={styles.tierList}>
{items.map((item) => {
const isSelected = item.type === selectedType;
return (
<label
key={item.type}
className={styles.tierOption}
data-selected={isSelected ? "true" : "false"}
data-unavailable={item.unavailable ? "true" : "false"}
>
<input
type="radio"
name="tip-coffee-tier"
value={item.type}
checked={isSelected}
disabled={item.unavailable}
className={styles.tierInput}
data-analytics-key={`tip.select_${item.type}`}
data-analytics-label={`Select ${item.displayName}`}
onChange={() => onChange(item.type)}
/>
<span className={styles.tierDetails}>
<span className={styles.tierLabel}>{item.tierLabel}</span>
<span className={styles.tierName}>{item.displayName}</span>
</span>
<span className={styles.tierPriceBlock}>
<span className={styles.tierPrice}>{item.priceLabel}</span>
{item.unavailable ? (
<span className={styles.tierUnavailable}>Unavailable</span>
) : null}
</span>
<span className={styles.tierCheck} aria-hidden="true">
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
+160
View File
@@ -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 {
+70 -15
View File
@@ -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<TipCoffeeType>(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<readonly TipCoffeeTierItem[]>(
() =>
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({
</section>
<section className={styles.productCard} aria-label="Coffee tip product">
<div className={styles.coffeeStage} aria-hidden="true">
<div className={styles.coffeeStage}>
<Image
src="/images/tip/coffee.jpg"
alt=""
width={736}
height={736}
src={coffeeOption.image.src}
alt={`${coffeeOption.displayName} coffee`}
width={coffeeOption.image.width}
height={coffeeOption.image.height}
sizes="(max-width: 380px) 220px, 211px"
className={styles.coffeeImage}
/>
@@ -224,10 +272,17 @@ export function TipScreen({
Coffee Gift
</span>
<h2 className={styles.productName}>
{coffeePlan?.planName || coffeeOption.fallbackName}
{coffeeOption.displayName}
</h2>
<p className={styles.productPrice}>{priceLabel}</p>
</div>
<TipCoffeeTierSelector
disabled={isTierSelectionDisabled}
items={tierItems}
onChange={handleCoffeeTypeChange}
selectedType={selectedCoffeeType}
/>
</section>
{showMissingPlan ? (
@@ -257,7 +312,7 @@ export function TipScreen({
<div className={styles.checkoutSlot}>
<TipCheckoutButton
coffeeType={coffeeType}
coffeeType={selectedCoffeeType}
disabled={
showMissingPlan ||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
@@ -32,28 +32,8 @@ describe("external entry navigation", () => {
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");
});
});
+2 -17
View File
@@ -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({
@@ -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");
});
});
+17
View File
@@ -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");
+40 -10
View File
@@ -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<TipCoffeeType, TipCoffeeOption> = {
const TIP_COFFEE_OPTION_BY_TYPE: Record<TipCoffeeType, TipCoffeeOption> = {
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 {