From 37ff69020bf0582cf5416b3767da0468a6879d22 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 21 Jul 2026 13:19:45 +0800 Subject: [PATCH] feat(tip): support dynamic gift products --- FRONTEND_GIFT_PRODUCTS_API.md | 403 ++++++++++++++++++ docs/backend/FRONTEND_PAYMENT_API.md | 74 ++-- e2e/fixtures/data/payment.ts | 8 +- ...se-pending-payment-order-lifecycle.test.ts | 12 + src/app/_hooks/use-payment-launch-flow.ts | 16 +- src/app/_hooks/use-payment-route-flow.ts | 47 +- .../use-pending-payment-order-lifecycle.ts | 13 +- .../characters/[characterSlug]/tip/page.tsx | 21 +- .../tip-coffee-tier-selector.test.tsx | 96 ----- .../tip-gift-product-selector.test.tsx | 92 ++++ .../__tests__/tip-screen.checkout.test.tsx | 156 +++---- .../tip/__tests__/tip-screen.helpers.test.ts | 138 ++---- src/app/tip/tip-checkout-button.tsx | 10 +- src/app/tip/tip-coffee-tier-selector.tsx | 70 --- src/app/tip/tip-gift-product-selector.tsx | 66 +++ src/app/tip/tip-product-image.tsx | 38 ++ src/app/tip/tip-screen.helpers.ts | 101 ++--- src/app/tip/tip-screen.module.css | 121 ++++++ src/app/tip/tip-screen.tsx | 290 +++++++------ src/app/tip/tip-success-view.module.css | 32 ++ src/app/tip/tip-success-view.tsx | 71 +-- .../__tests__/payment_repository.test.ts | 81 +++- .../interfaces/ipayment_repository.ts | 9 +- src/data/repositories/payment_repository.ts | 33 +- .../payment/__tests__/payment_plan.test.ts | 89 ++-- src/data/schemas/payment/gift_category.ts | 14 + src/data/schemas/payment/gift_product.ts | 26 ++ src/data/schemas/payment/index.ts | 7 +- src/data/schemas/payment/request/index.ts | 1 + .../payment/request/tip_message_request.ts | 11 + .../response/gift_products_response.ts | 21 + src/data/schemas/payment/response/index.ts | 3 +- .../response/payment_order_status_response.ts | 29 +- .../payment/response/tip_message_response.ts | 17 + .../response/tip_payment_plans_response.ts | 19 - src/data/schemas/payment/tip_payment_plan.ts | 15 - .../api/__tests__/payment_api.test.ts | 57 ++- src/data/services/api/api_contract.json | 3 +- src/data/services/api/api_path.ts | 7 +- src/data/services/api/payment_api.ts | 32 +- .../payment/pending_payment_order_storage.ts | 21 +- .../__tests__/pending_payment_order.test.ts | 16 +- src/lib/payment/payment_launch.ts | 10 +- src/lib/payment/pending_payment_order.ts | 34 +- src/lib/tip/__tests__/tip_coffee.test.ts | 58 --- src/lib/tip/__tests__/tip_gift.test.ts | 24 ++ src/lib/tip/tip_coffee.ts | 86 ---- src/lib/tip/tip_gift.ts | 31 ++ .../__tests__/payment-catalog-flow.test.ts | 71 ++- .../__tests__/payment-machine.test-utils.ts | 117 ++++- .../__tests__/payment-order-flow.test.ts | 106 ++++- src/stores/payment/helper/gift.ts | 88 ++++ src/stores/payment/helper/index.ts | 1 + src/stores/payment/helper/order.ts | 4 +- src/stores/payment/machine/actors/gifts.ts | 30 ++ src/stores/payment/machine/actors/plans.ts | 8 +- src/stores/payment/machine/catalog-flow.ts | 168 ++++++-- src/stores/payment/machine/order-flow.ts | 136 +++++- src/stores/payment/machine/setup.ts | 3 + src/stores/payment/payment-context.tsx | 21 +- src/stores/payment/payment-events.ts | 5 + src/stores/payment/payment-state.ts | 23 +- 62 files changed, 2325 insertions(+), 1085 deletions(-) create mode 100644 FRONTEND_GIFT_PRODUCTS_API.md delete mode 100644 src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx create mode 100644 src/app/tip/__tests__/tip-gift-product-selector.test.tsx delete mode 100644 src/app/tip/tip-coffee-tier-selector.tsx create mode 100644 src/app/tip/tip-gift-product-selector.tsx create mode 100644 src/app/tip/tip-product-image.tsx create mode 100644 src/data/schemas/payment/gift_category.ts create mode 100644 src/data/schemas/payment/gift_product.ts create mode 100644 src/data/schemas/payment/request/tip_message_request.ts create mode 100644 src/data/schemas/payment/response/gift_products_response.ts create mode 100644 src/data/schemas/payment/response/tip_message_response.ts delete mode 100644 src/data/schemas/payment/response/tip_payment_plans_response.ts delete mode 100644 src/data/schemas/payment/tip_payment_plan.ts delete mode 100644 src/lib/tip/__tests__/tip_coffee.test.ts create mode 100644 src/lib/tip/__tests__/tip_gift.test.ts delete mode 100644 src/lib/tip/tip_coffee.ts create mode 100644 src/lib/tip/tip_gift.ts create mode 100644 src/stores/payment/helper/gift.ts create mode 100644 src/stores/payment/machine/actors/gifts.ts diff --git a/FRONTEND_GIFT_PRODUCTS_API.md b/FRONTEND_GIFT_PRODUCTS_API.md new file mode 100644 index 00000000..5a98950d --- /dev/null +++ b/FRONTEND_GIFT_PRODUCTS_API.md @@ -0,0 +1,403 @@ +# 虚拟礼物商品与付款后文案 API + +## 一、用途 + +前端按当前角色一次获取全部启用的礼物品类和商品,在本地完成品类切换与商品筛选。页面不得写死咖啡、鲜花、服装等品类,也不得根据 `planId` 推算名称、图片或价格。付款成功后,前端使用订单号获取稳定的角色感谢文案。 + +## 三、推荐接入流程 + +1. 当前角色确定后,只请求一次 `GET /api/payment/gift-products?characterId=`。 +2. 使用 `data.categories` 渲染品类栏,使用 `data.plans` 保存完整商品目录。 +3. 用户切换品类时只在前端按 `product.category` 本地筛选,不再请求后端。 +4. 使用商品自身的 `planName`、`description`、`imageUrl`、`amountCents` 和 `currency` 渲染通用商品卡片。 +5. 用户选择商品后,使用该商品原始 `planId` 创建订单。 +6. 用户切换商品时清除旧 `orderId`、`clientSecret` 和支付组件状态,再创建新订单。 +7. Stripe 使用本次订单返回的 `payParams.clientSecret` 挂载 Payment Element。 +8. 每 3 至 5 秒轮询订单状态,直到状态变成 `paid`、`failed` 或 `expired`。 +9. 状态为 `paid` 后调用 `POST /api/payment/tip-message`,展示 `data.message`。 + +> `amountCents`、`currency` 和 `planId` 必须来自同一条商品数据。后端下单时会再次按 `planId` 校验真实价格,前端展示值不得作为收费依据。 + +--- + +## 四、一次获取完整礼物目录 + +### 4.1 请求 + +```http +GET /api/payment/gift-products?characterId=elio +``` + +- 完整地址:`https://proapi.banlv-ai.com/api/payment/gift-products` +- 兼容别名:`GET /api/payment/tip-plans` +- 登录鉴权:不需要 +- 请求格式:Query String + +### 4.2 查询参数 + +| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `characterId` | string | 前端必传 | 否 | `elio` | 当前收礼角色 ID。后端仍兼容省略,但多角色前端不得省略。 | +| `category` | string | 否 | 否 | `coffee` | 兼容旧的按品类懒加载方式。新版前端首屏不传此字段。 | + +### 4.3 请求示例 + +```bash +curl 'https://proapi.banlv-ai.com/api/payment/gift-products?characterId=elio' +``` + +### 4.4 成功响应 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "characterId": "elio", + "categories": [ + { + "category": "coffee", + "name": "Coffee", + "productCount": 3, + "imageUrl": null + }, + { + "category": "flowers", + "name": "Flowers", + "productCount": 2, + "imageUrl": "https://example.com/flowers.jpg" + } + ], + "plans": [ + { + "planId": "tip_coffee_usd_4_99", + "planName": "Velvet Espresso", + "orderType": "tip", + "tipType": "coffee_small", + "category": "coffee", + "characterId": "elio", + "description": "Buy Elio a small coffee", + "imageUrl": null, + "amountCents": 499, + "currency": "USD", + "autoRenew": false, + "isFirstRechargeOffer": false, + "firstRechargeDiscountPercent": 0, + "promotionType": null + } + ] + } +} +``` + +### 4.5 响应字段 + +| 字段 | 类型 | 可空 | 说明 | +| --- | --- | --- | --- | +| `data.characterId` | string | 是 | 本次查询的角色;省略请求参数时为 `null`。 | +| `data.categories` | array | 否 | 当前结果中的品类,顺序与 Manager 商品排序一致。 | +| `categories[].category` | string | 否 | 稳定品类值,只用于关联和筛选。 | +| `categories[].name` | string | 否 | 品类展示名称。 | +| `categories[].productCount` | number(整数) | 否 | 当前品类中的启用商品数量。 | +| `categories[].imageUrl` | string | 是 | 该品类第一张可用商品图;没有图片时为 `null`。 | +| `data.plans` | array | 否 | 当前角色全部启用商品;没有商品时为空数组。 | +| `plans[].planId` | string | 否 | 商品唯一标识,创建订单时必须原样传回。 | +| `plans[].planName` | string | 否 | 商品展示名称。 | +| `plans[].orderType` | string enum | 否 | 礼物固定为 `tip`。 | +| `plans[].tipType` | string | 否 | 支付成功事件使用的礼物细分类型。 | +| `plans[].category` | string | 否 | 所属品类,与 `categories[].category` 对应。 | +| `plans[].characterId` | string | 否 | 收礼角色 ID。 | +| `plans[].description` | string | 否 | 商品说明,可能为空字符串。 | +| `plans[].imageUrl` | string | 是 | 完整公开图片 URL;没有图片时为 `null`。 | +| `plans[].amountCents` | number(整数) | 否 | 展示金额的百分之一;USD 下 `499` 表示 `$4.99`。 | +| `plans[].currency` | string | 否 | 三位大写币种代码,例如 `USD`。 | +| `plans[].autoRenew` | boolean | 否 | 礼物固定为 `false`。 | +| `plans[].isFirstRechargeOffer` | boolean | 否 | 礼物固定为 `false`。 | +| `plans[].firstRechargeDiscountPercent` | number(整数) | 否 | 礼物固定为 `0`。 | +| `plans[].promotionType` | string | 是 | 礼物没有促销时为 `null`。 | + +## 五、前端通用品类与商品显示逻辑 + +### 5.1 建议类型 + +```ts +interface GiftCategory { + category: string; + name: string; + productCount: number; + imageUrl: string | null; +} + +interface GiftProduct { + planId: string; + planName: string; + orderType: "tip"; + tipType: string; + category: string; + characterId: string; + description: string; + imageUrl: string | null; + amountCents: number; + currency: string; + autoRenew: false; +} + +interface GiftCatalog { + characterId: string | null; + categories: GiftCategory[]; + plans: GiftProduct[]; +} +``` + +### 5.2 状态和筛选 + +```ts +const [catalog, setCatalog] = useState(null); +const [selectedCategory, setSelectedCategory] = useState(null); +const [selectedPlanId, setSelectedPlanId] = useState(null); + +const visibleProducts = (catalog?.plans ?? []).filter( + product => product.category === selectedCategory, +); +``` + +- 角色变化时重新请求,并以 `characterId` 作为缓存键,不能复用另一个角色的目录。 +- 请求完成后默认选择 `categories[0].category`;深链指定的品类存在时优先选择深链值。 +- 切换品类只更新 `selectedCategory`,不请求第二个接口。 +- 只有一个品类时可以隐藏品类切换栏。 +- 新增未知品类时仍使用同一套通用商品卡片,禁止编写 `if (category === "coffee")` 一类业务分支。 +- 商品图片优先级:`product.imageUrl` → 当前 `category.imageUrl` → 通用礼物占位图。 +- 商品名称和说明使用 `planName`、`description`;不要使用前端本地咖啡常量覆盖。 +- 价格使用 `Intl.NumberFormat`,金额为 `amountCents / 100`。 + +```ts +const displayPrice = new Intl.NumberFormat("en-US", { + style: "currency", + currency: product.currency, +}).format(product.amountCents / 100); +``` + +### 5.3 页面状态 + +| 状态 | 前端行为 | +| --- | --- | +| 加载中 | 显示稳定尺寸的商品骨架,不显示旧角色缓存。 | +| `categories=[]`、`plans=[]` | 显示“该角色暂时没有可用礼物”,不要回退到写死咖啡。 | +| 某品类没有商品 | 自动切换到第一个有商品的品类;全部为空时显示空状态。 | +| `imageUrl=null` | 使用品类图或通用占位图。 | +| 请求失败 | 显示重试操作,不使用可能过期的价格创建订单。 | +| 角色切换 | 取消旧请求,清空品类、商品、选中商品、订单和 Stripe 状态。 | + +### 5.4 兼容接口 + +- `GET /api/payment/gift-categories` 保留给只需要品类的旧客户端,新版前端主流程不调用。 +- `GET /api/payment/gift-products?characterId=elio&category=coffee` 仍支持按品类过滤,适用于未来商品数很大时懒加载。 +- `GET /api/payment/tip-plans` 与 `/gift-products` 返回同一协议,旧前端继续读取 `data.plans` 不受影响。 +- 新增的 `data.characterId` 和 `data.categories` 是向后兼容字段。 + +--- + +## 六、创建礼物支付订单 + +### 6.1 请求 + +```http +POST /api/payment/create-order +``` + +- 完整地址:`https://proapi.banlv-ai.com/api/payment/create-order` +- 登录鉴权:需要 +- Header:`Authorization: Bearer ` +- Content-Type:`application/json` + +### 6.2 请求字段 + +| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `planId` | string | 是 | 否 | `tip_coffee_usd_4_99` | 必须使用本次商品接口返回的原始值。 | +| `payChannel` | enum string | 是 | 否 | `stripe` | 可选值:`stripe`、`ezpay`。 | +| `autoRenew` | boolean | 是 | 否 | `false` | 礼物是一次性付款,固定发送 `false`。 | +| `recipientCharacterId` | string | 否 | 否 | `elio` | 可省略;省略时使用商品所属角色。传入其他角色会被拒绝。 | + +### 6.3 请求示例 + +```bash +curl -X POST 'https://proapi.banlv-ai.com/api/payment/create-order' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "planId": "tip_coffee_usd_4_99", + "payChannel": "stripe", + "autoRenew": false, + "recipientCharacterId": "elio" + }' +``` + +### 6.4 Stripe 成功响应 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "orderId": "pay_xxx", + "payParams": { + "clientSecret": "", + "provider": "stripe", + "automaticRenewal": false, + "firstChargeAmountCents": 499, + "renewalAmountCents": null + }, + "expiresAt": "2026-07-20T10:30:00+00:00", + "expiresInSeconds": 1800 + } +} +``` + +### 6.5 Stripe 前端处理要求 + +- 使用 `data.payParams.clientSecret` 挂载 Stripe Payment Element。 +- `firstChargeAmountCents` 应与所选商品的 `amountCents` 一致,可用于提交支付前的防错校验。 +- 用户切换商品或重新创建订单后,必须销毁旧 Payment Element,并使用新的 `orderId + clientSecret` 重新挂载。 +- 不要复用上一件商品的 `clientSecret`,否则页面商品名称、显示价格和实际 PaymentIntent 可能不属于同一订单。 +- 礼物不会增加积分或 VIP,不参加首充折扣,也不会自动续费。 + +EzPay 响应可能在 `payParams` 中返回 `cashierUrl` 或 `payData`,前端按对应支付渠道处理。 + +--- + +## 七、轮询订单状态 + +### 7.1 请求 + +```http +GET /api/payment/order-status?order_id= +``` + +- 完整地址:`https://proapi.banlv-ai.com/api/payment/order-status` +- 登录鉴权:需要,必须与创建订单的用户一致 +- Header:`Authorization: Bearer ` +- 查询字段兼容性说明:当前字段名是 `order_id`,不是 `orderId` + +### 7.2 请求示例 + +```bash +curl 'https://proapi.banlv-ai.com/api/payment/order-status?order_id=' \ + -H 'Authorization: Bearer ' +``` + +### 7.3 关键响应字段 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "orderId": "pay_xxx", + "status": "paid", + "orderType": "tip", + "planId": "tip_coffee_usd_4_99", + "creditsAdded": 0 + } +} +``` + +| 字段 | 类型 | 可空 | 说明 | +| --- | --- | --- | --- | +| `orderId` | string | 否 | 当前订单号。 | +| `status` | enum string | 否 | `pending`、`paid`、`failed` 或 `expired`。 | +| `orderType` | string | 否 | 礼物订单为 `tip`。 | +| `planId` | string | 是 | 当前订单对应的商品 `planId`。 | +| `creditsAdded` | number(整数) | 否 | 礼物订单固定为 `0`。 | + +建议每 3 至 5 秒轮询一次;出现 `paid`、`failed` 或 `expired` 后立即停止。 + +--- + +## 八、获取付款后感谢文案 + +### 8.1 请求 + +```http +POST /api/payment/tip-message +``` + +仅在订单状态已经变为 `paid` 后调用。 + +- 完整地址:`https://proapi.banlv-ai.com/api/payment/tip-message` +- 登录鉴权:需要,必须是订单所属用户 +- Header:`Authorization: Bearer ` +- Content-Type:`application/json` + +### 8.2 请求字段 + +| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `orderId` | string | 是 | 否 | `pay_xxx` | 当前用户已经支付成功的礼物订单号。 | + +### 8.3 请求示例 + +```bash +curl -X POST 'https://proapi.banlv-ai.com/api/payment/tip-message' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"orderId":""}' +``` + +### 8.4 成功响应 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "orderId": "pay_xxx", + "characterId": "elio", + "planId": "tip_coffee_usd_4_99", + "productName": "Velvet Espresso", + "tipCount": 1, + "poolIndex": 37, + "message": "This is the 1st time you've sent me \"Velvet Espresso\". You have a knack for making me smile." + } +} +``` + +### 8.5 响应字段 + +| 字段 | 类型 | 可空 | 说明 | +| --- | --- | --- | --- | +| `orderId` | string | 否 | 已支付订单号。 | +| `characterId` | string | 否 | 收礼角色 ID。 | +| `planId` | string | 否 | 本次实际支付商品的 `planId`。 | +| `productName` | string | 否 | 本次实际支付商品名称。 | +| `tipCount` | number(整数) | 否 | 当前用户对同一 `planId` 的成功支付次数。 | +| `poolIndex` | number(整数) | 否 | 本次选中的预生成文案下标,范围为 `0` 至 `99`。 | +| `message` | string | 否 | 可直接展示的完整英文文案。 | + +每个角色在 Manager 中预先保存正好 100 条英文感谢语。后端使用 `characterId + orderId` 稳定选择一条,因此同一个 `orderId` 重复请求时,`poolIndex` 和 `message` 保持一致。该接口不会在请求时调用 AI。 + +--- + +## 十、前端验收步骤 + +1. 在预发请求 Elio 的品类,确认页面根据接口返回结果渲染,没有写死咖啡品类。 +2. 请求选中品类的商品,确认商品名称、`planId`、图片、`amountCents` 和币种全部来自接口。 +3. 分别选择 `$4.99`、`$9.99` 商品,确认创建订单请求发送的是对应商品自己的 `planId`。 +4. 创建订单后校验 `payParams.firstChargeAmountCents === product.amountCents`;不一致时阻止支付并重新创建订单。 +5. 切换商品后确认旧 Stripe Payment Element 已销毁,新的组件使用新 `clientSecret`。 +6. 使用预发测试账号完成一笔允许的支付,轮询状态直到 `paid`。 +7. 连续两次调用付款后文案接口,确认同一订单返回内容完全一致。 +8. 再次购买同一商品,确认 `tipCount` 增加 1。 +9. 确认礼物付款后没有增加积分、VIP 或自动续费。 + +## 十一、注意事项 + +- 前端不要缓存并跨商品复用 `orderId`、`clientSecret` 或 `payParams`。 +- `planId` 是商品身份,`amountCents` 是商品价格;二者必须来自同一次商品接口响应。 +- 品类或商品为空时展示空状态,不要回退到写死的旧咖啡商品。 +- `imageUrl=null` 是合法结果,应显示本地占位图。 +- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。 diff --git a/docs/backend/FRONTEND_PAYMENT_API.md b/docs/backend/FRONTEND_PAYMENT_API.md index 0d122f77..1334fdd2 100644 --- a/docs/backend/FRONTEND_PAYMENT_API.md +++ b/docs/backend/FRONTEND_PAYMENT_API.md @@ -23,9 +23,10 @@ | 方法 | 路径 | 用途 | | --- | --- | --- | | `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 | -| `GET` | `/api/payment/tip-plans` | Tip 套餐目录 | +| `GET` | `/api/payment/gift-products?characterId={id}` | 当前角色的完整礼物目录 | | `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 | | `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 | +| `POST` | `/api/payment/tip-message` | 获取已支付礼物订单的稳定感谢文案 | 所有响应先由通用 envelope 解包,再进入 Payment Schema。页面和状态机不直接读取原始 envelope。 @@ -65,29 +66,44 @@ promotionType 支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。 -### 3.2 Tip 套餐 +### 3.2 Gift Products 目录 ```http -GET /api/payment/tip-plans -Authorization: Bearer +GET /api/payment/gift-products?characterId=elio ``` ```json { + "characterId": "elio", + "categories": [ + { + "category": "coffee", + "name": "Coffee", + "productCount": 1, + "imageUrl": null + } + ], "plans": [ { "planId": "tip_coffee_usd_4_99", - "planName": "Small Coffee", + "planName": "Velvet Espresso", + "orderType": "tip", + "tipType": "coffee_small", + "category": "coffee", + "characterId": "elio", + "description": "Buy Elio a small coffee", + "imageUrl": null, "amountCents": 499, - "currency": "USD" + "currency": "USD", + "autoRenew": false } ] } ``` -Tip 目录不写入默认套餐缓存。Repository 会把精简 Tip 套餐归一化为 `PaymentPlan`,其中 `orderType="tip"`、`autoRenew=false`,且不带 VIP、积分或首充权益。 +目录不需要登录,也不写入默认套餐缓存。前端必须传当前角色 ID,一次读取 `categories` 和全部 `plans`;当前 Tip 页面固定使用第一分类,并按商品原始顺序默认选择第一件商品,不展示分类切换栏。 -Tip 页面只允许选择本地 Coffee Tier 能映射到的后端 `planId`。后端未返回对应套餐时,该 Tier 标记为不可用,不能使用本地价格创建订单。 +名称、说明、图片、金额、币种和 `planId` 全部来自同一条商品数据。状态机把当前分类商品映射为 `PaymentPlan` 以复用 Checkout 和埋点,同时保留完整 Gift Product 供 UI 展示。目录为空或请求失败时禁止创建订单,不回退到本地固定商品或价格。 ## 4. 创建订单 @@ -180,21 +196,21 @@ Authorization: Bearer "status": "paid", "orderType": "tip", "planId": "tip_coffee_usd_4_99", - "tipCount": 2, - "thankYouMessage": "You made my day a little sweeter." + "creditsAdded": 0 } ``` | 字段 | 前端规则 | | --- | --- | -| `status` | 只接受 `pending`、`paid`、`failed` | -| `tipCount` | 仅保留正整数,其他值归一化为 `null` | -| `thankYouMessage` | trim 后的非空纯文本,其他值归一化为 `null` | +| `status` | 只接受 `pending`、`paid`、`failed`、`expired` | +| `planId` | 可为 `null` | +| `creditsAdded` | 整数;Tip 固定为 `0` | 状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟: - `paid`:进入最终成功状态并停止轮询; - `failed`:进入失败状态并停止轮询; +- `expired`:进入订单过期状态、销毁旧支付参数并停止轮询; - 超过 5 分钟:本地标记为失败并显示超时错误; - 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。 @@ -206,7 +222,8 @@ Authorization: Bearer orderId payChannel = ezpay subscriptionType = vip | topup | tip -tipCoffeeType = small | medium | large(仅 Tip) +giftCategory(仅 Tip,可空) +giftPlanId(仅 Tip,可空) returnTo = chat | private-room | sidebar(可选) characterSlug(可选) createdAt @@ -216,10 +233,10 @@ createdAt ```text VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1 -Tip -> /characters/{slug}/tip?payChannel=ezpay&paymentReturn=1&coffee_type=... +Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&paymentReturn=1 ``` -恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid 或 failed 后清理持久化记录。 +恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`。 无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。 @@ -237,21 +254,19 @@ Subscription 显示成功 Dialog,关闭后根据 `returnTo` 和原角色 slug ## 9. Tip 成功结果 -paid Tip 订单可以返回: +Tip 订单进入 paid 后调用: -| 字段 | 含义 | -| --- | --- | -| `tipCount` | 包含当前订单在内,当前付款身份向当前收款角色的累计成功次数 | -| `thankYouMessage` | 当前订单对应角色的纯文本感谢语 | +```http +POST /api/payment/tip-message +Content-Type: application/json +Authorization: Bearer -前端展示规则: +{"orderId":"pay_xxx"} +``` -1. `tipCount=1`:使用首次打赏本地文案; -2. `tipCount>1` 且感谢语非空:展示英文序数次数和后端感谢语; -3. 字段缺失、非法或不完整:展示通用成功文案,不阻止订单进入 paid; -4. 感谢语按纯文本展示并保留换行,不渲染 HTML。 +响应包含 `orderId`、`characterId`、`planId`、`productName`、`tipCount`、`poolIndex` 和可直接展示的完整 `message`。前端不再根据次数拼接文案,也不从订单状态读取感谢字段。 -`tipCount` 和 `thankYouMessage` 必须对同一个 `orderId` 保持稳定。pending、failed 和非 Tip 订单应返回 `null`,前端也会将无效值归一化为 `null`。 +Tip 成功页在文案加载期间立即确认支付成功;接口成功后按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单。 ## 10. Provider 与状态边界 @@ -271,6 +286,7 @@ Payment 协议相关变更至少验证: 4. `src/lib/payment/__tests__`; 5. `src/app/_hooks/__tests__` 中的 Payment 流程测试; 6. `src/app/subscription/__tests__` 与 `src/app/tip/__tests__`; -7. Stripe、Ezpay、paid、failed、timeout 和回跳恢复路径; +7. Stripe、Ezpay、paid、failed、expired、timeout 和回跳恢复路径; 8. Tip 创建订单携带当前角色 ID,VIP/Top-up 不携带; -9. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。 +9. Tip Message 成功、失败和重试不重复创建订单; +10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。 diff --git a/e2e/fixtures/data/payment.ts b/e2e/fixtures/data/payment.ts index e2653357..aaea01b3 100644 --- a/e2e/fixtures/data/payment.ts +++ b/e2e/fixtures/data/payment.ts @@ -10,5 +10,11 @@ export const tipPaymentPlansResponse = { plans: [ ] }; export const paymentOrderId = "order_e2e_vip_monthly"; export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } }; -export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" }; +export const paidPaymentOrderStatusResponse = { + orderId: paymentOrderId, + status: "paid", + orderType: "vip_monthly", + planId: "vip_monthly", + creditsAdded: 0, +}; export const vipStatusResponse = { isVip: false, expiresAt: null }; diff --git a/src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts b/src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts index d762c97e..30fec07c 100644 --- a/src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts +++ b/src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts @@ -38,4 +38,16 @@ describe("shouldInspectPendingPaymentOrder", () => { }), ).toBe(false); }); + + it("allows stale expired orders to be cleaned up", () => { + expect( + shouldInspectPendingPaymentOrder({ + currentOrderId: "order-expired", + isPaid: false, + isPollingOrder: false, + shouldResumePendingOrder: false, + status: "expired", + }), + ).toBe(true); + }); }); diff --git a/src/app/_hooks/use-payment-launch-flow.ts b/src/app/_hooks/use-payment-launch-flow.ts index f27d8448..fe6450b8 100644 --- a/src/app/_hooks/use-payment-launch-flow.ts +++ b/src/app/_hooks/use-payment-launch-flow.ts @@ -12,7 +12,6 @@ import { behaviorAnalytics } from "@/lib/analytics"; import type { PendingPaymentReturnTo, PendingPaymentSubscriptionType, - PendingPaymentTipCoffeeType, } from "@/lib/payment/pending_payment_order"; import type { PaymentContextState, @@ -39,7 +38,8 @@ export interface UsePaymentLaunchFlowInput { returnTo?: PendingPaymentReturnTo; characterSlug?: string; subscriptionType: PendingPaymentSubscriptionType; - tipCoffeeType?: PendingPaymentTipCoffeeType; + giftCategory?: string | null; + giftPlanId?: string | null; } export interface PaymentLaunchFlow { @@ -110,7 +110,8 @@ export function usePaymentLaunchFlow({ returnTo, characterSlug, subscriptionType, - tipCoffeeType, + giftCategory, + giftPlanId, }: UsePaymentLaunchFlowInput): PaymentLaunchFlow { const launchedNonceRef = useRef(0); const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< @@ -153,7 +154,8 @@ export function usePaymentLaunchFlow({ orderId: payment.currentOrderId, paymentUrl, subscriptionType, - ...(tipCoffeeType ? { tipCoffeeType } : {}), + giftCategory, + giftPlanId, ...(returnTo ? { returnTo } : {}), ...(characterSlug ? { characterSlug } : {}), onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), @@ -197,7 +199,8 @@ export function usePaymentLaunchFlow({ paymentDispatch, returnTo, subscriptionType, - tipCoffeeType, + giftCategory, + giftPlanId, ]); const shouldShowStripeDialog = @@ -238,7 +241,8 @@ export function usePaymentLaunchFlow({ orderId: payment.currentOrderId, paymentUrl: ezpayPaymentUrl, subscriptionType, - ...(tipCoffeeType ? { tipCoffeeType } : {}), + giftCategory, + giftPlanId, ...(returnTo ? { returnTo } : {}), ...(characterSlug ? { characterSlug } : {}), onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl), diff --git a/src/app/_hooks/use-payment-route-flow.ts b/src/app/_hooks/use-payment-route-flow.ts index 274cc5da..cfa079a0 100644 --- a/src/app/_hooks/use-payment-route-flow.ts +++ b/src/app/_hooks/use-payment-route-flow.ts @@ -19,6 +19,9 @@ export interface UsePaymentRouteFlowInput { initialPayChannel: PayChannel; paymentType: PendingPaymentSubscriptionType; shouldResumePendingOrder: boolean; + characterId?: string; + initialCategory?: string | null; + initialPlanId?: string | null; } export interface PaymentRouteFlow { @@ -35,10 +38,19 @@ export function usePaymentRouteFlow({ initialPayChannel, paymentType, shouldResumePendingOrder, + characterId, + initialCategory = null, + initialPlanId = null, }: UsePaymentRouteFlowInput): PaymentRouteFlow { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); - const initialPayChannelAppliedRef = useRef(false); + const initializedCatalogKeyRef = useRef(null); + const catalogKey = [ + catalog, + characterId ?? "", + initialCategory ?? "", + initialPlanId ?? "", + ].join(":"); usePendingPaymentOrderLifecycle({ payment, @@ -48,34 +60,23 @@ export function usePaymentRouteFlow({ }); useEffect(() => { - if (payment.status === "idle") { - initialPayChannelAppliedRef.current = true; - paymentDispatch({ - type: "PaymentInit", - catalog, - payChannel: initialPayChannel, - }); - return; - } - - if ( - initialPayChannelAppliedRef.current || - payment.status !== "ready" - ) { - return; - } - - initialPayChannelAppliedRef.current = true; - if (payment.payChannel === initialPayChannel) return; + if (initializedCatalogKeyRef.current === catalogKey) return; + initializedCatalogKeyRef.current = catalogKey; paymentDispatch({ - type: "PaymentPayChannelChanged", + type: "PaymentInit", + catalog, payChannel: initialPayChannel, + ...(characterId ? { characterId } : {}), + ...(initialCategory ? { category: initialCategory } : {}), + ...(initialPlanId ? { planId: initialPlanId } : {}), }); }, [ catalog, + catalogKey, + characterId, + initialCategory, + initialPlanId, initialPayChannel, - payment.payChannel, - payment.status, paymentDispatch, ]); diff --git a/src/app/_hooks/use-pending-payment-order-lifecycle.ts b/src/app/_hooks/use-pending-payment-order-lifecycle.ts index 4efde8fd..6a6affe1 100644 --- a/src/app/_hooks/use-pending-payment-order-lifecycle.ts +++ b/src/app/_hooks/use-pending-payment-order-lifecycle.ts @@ -37,7 +37,7 @@ export function shouldInspectPendingPaymentOrder({ return ( status === "ready" || (!shouldResumePendingOrder && - (isPollingOrder || isPaid || status === "failed")) + (isPollingOrder || isPaid || status === "failed" || status === "expired")) ); } @@ -74,7 +74,8 @@ export function usePendingPaymentOrderLifecycle({ payment.currentOrderId === result.data.orderId && (payment.isPollingOrder || payment.isPaid || - payment.status === "failed") + payment.status === "failed" || + payment.status === "expired") ) { paymentDispatch({ type: "PaymentReset" }); } @@ -108,7 +109,13 @@ export function usePendingPaymentOrderLifecycle({ useEffect(() => { if (!payment.currentOrderId) return; - if (!payment.isPaid && payment.status !== "failed") return; + if ( + !payment.isPaid && + payment.status !== "failed" && + payment.status !== "expired" + ) { + return; + } void clearPendingPaymentOrder(); }, [payment.currentOrderId, payment.isPaid, payment.status]); diff --git a/src/app/characters/[characterSlug]/tip/page.tsx b/src/app/characters/[characterSlug]/tip/page.tsx index 54cca1e6..c8901cd0 100644 --- a/src/app/characters/[characterSlug]/tip/page.tsx +++ b/src/app/characters/[characterSlug]/tip/page.tsx @@ -4,10 +4,10 @@ import { type PaymentSearchParams, } from "@/lib/payment/payment_search_params"; import { - DEFAULT_TIP_COFFEE_TYPE, - resolveTipCoffeeType, - TIP_COFFEE_TYPE_PARAM, -} from "@/lib/tip/tip_coffee"; + normalizeTipGiftParam, + TIP_GIFT_CATEGORY_PARAM, + TIP_GIFT_PLAN_ID_PARAM, +} from "@/lib/tip/tip_gift"; import { TipScreen } from "@/app/tip/tip-screen"; export default async function CharacterTipPage({ @@ -17,14 +17,17 @@ export default async function CharacterTipPage({ }) { const query = await searchParams; const paymentReturn = parsePaymentReturnSearchParams(query); - const coffeeType = - resolveTipCoffeeType( - getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]), - ) ?? DEFAULT_TIP_COFFEE_TYPE; + const initialCategory = normalizeTipGiftParam( + getFirstPaymentSearchParam(query[TIP_GIFT_CATEGORY_PARAM]), + ); + const initialPlanId = normalizeTipGiftParam( + getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]), + ); return ( diff --git a/src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx b/src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx deleted file mode 100644 index e3bfa3e4..00000000 --- a/src/app/tip/__tests__/tip-coffee-tier-selector.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* @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", - displayName: "Velvet Espresso", - priceLabel: "US$ 4.99", - unavailable: false, - }, - { - type: "medium", - displayName: "Gilded Heart", - priceLabel: "US$ 9.99", - unavailable: false, - }, - { - type: "large", - displayName: "Crown Blossom", - 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("Gilded Heart"); - expect(container.textContent).toContain("Crown Blossom"); - 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/__tests__/tip-gift-product-selector.test.tsx b/src/app/tip/__tests__/tip-gift-product-selector.test.tsx new file mode 100644 index 00000000..8662f678 --- /dev/null +++ b/src/app/tip/__tests__/tip-gift-product-selector.test.tsx @@ -0,0 +1,92 @@ +/* @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 { + GiftProductSchema, + type GiftProduct, +} from "@/data/schemas/payment"; + +import { TipGiftProductSelector } from "../tip-gift-product-selector"; + +const products: readonly GiftProduct[] = [ + makeProduct("gift_small", "Velvet Espresso", 499), + makeProduct("gift_medium", "Golden Reserve", 999), + makeProduct("gift_large", "Imperial Grand Cru", 1999), +]; + +function Harness() { + const [selectedPlanId, setSelectedPlanId] = useState("gift_small"); + return ( + + ); +} + +describe("TipGiftProductSelector", () => { + 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 backend products and selects the first product", () => { + 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="gift_small"]') + ?.checked, + ).toBe(true); + }); + + it("switches the selected backend plan id", () => { + act(() => root.render()); + const medium = container.querySelector( + 'input[value="gift_medium"]', + ); + act(() => medium?.click()); + expect(medium?.checked).toBe(true); + }); +}); + +function makeProduct( + planId: string, + planName: string, + amountCents: number, +): GiftProduct { + return GiftProductSchema.parse({ + planId, + planName, + orderType: "tip", + tipType: planId, + category: "coffee", + characterId: "elio", + description: `${planName} description`, + imageUrl: null, + amountCents, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }); +} diff --git a/src/app/tip/__tests__/tip-screen.checkout.test.tsx b/src/app/tip/__tests__/tip-screen.checkout.test.tsx index ab46af11..57d2b2dd 100644 --- a/src/app/tip/__tests__/tip-screen.checkout.test.tsx +++ b/src/app/tip/__tests__/tip-screen.checkout.test.tsx @@ -5,7 +5,10 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + GiftCategorySchema, + GiftProductSchema, PaymentPlanSchema, + TipMessageResponseSchema, type PaymentPlan, } from "@/data/schemas/payment"; import type { PaymentContextState } from "@/stores/payment/payment-context"; @@ -44,6 +47,7 @@ vi.mock("@/lib/analytics", () => ({ vi.mock("@/providers/character-provider", () => ({ useActiveCharacter: () => ({ id: "maya-tan", + slug: "maya", displayName: "Maya Tan", assets: { avatar: "/images/avatar/maya.png", @@ -51,7 +55,7 @@ vi.mock("@/providers/character-provider", () => ({ }, copy: { tipHeader: "Tip Maya", - tipTitle: "Buy Maya a coffee", + tipTitle: "Send Maya a gift", }, }), useActiveCharacterRoutes: () => ({ @@ -80,8 +84,11 @@ vi.mock("../tip-checkout-button", () => ({ ), })); -vi.mock("../tip-coffee-tier-selector", () => ({ - TipCoffeeTierSelector: () => null, +vi.mock("../tip-gift-product-selector", () => ({ + TipGiftProductSelector: () => null, +})); +vi.mock("../tip-product-image", () => ({ + TipProductImage: ({ alt }: { alt: string }) => {alt}, })); vi.mock("../use-tip-support-prompt", () => ({ useTipSupportPrompt: () => ({ @@ -92,21 +99,41 @@ vi.mock("../use-tip-support-prompt", () => ({ import { TipScreen } from "../tip-screen"; -const mediumPlan: PaymentPlan = PaymentPlanSchema.parse({ +const giftCategory = GiftCategorySchema.parse({ + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, +}); + +const giftProduct = GiftProductSchema.parse({ planId: "tip_coffee_usd_9_99", - planName: "Gilded Heart", + planName: "Golden Reserve", + orderType: "tip", + tipType: "coffee_medium", + category: "coffee", + characterId: "maya-tan", + description: "A warm reserve coffee", + imageUrl: null, + amountCents: 999, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, +}); + +const giftPlan: PaymentPlan = PaymentPlanSchema.parse({ + planId: giftProduct.planId, + planName: giftProduct.planName, orderType: "tip", vipDays: null, dolAmount: null, creditBalance: 0, - amountCents: 999, + amountCents: giftProduct.amountCents, originalAmountCents: null, dailyPriceCents: null, - currency: "USD", - isFirstRechargeOffer: false, - mostPopular: false, - firstRechargeDiscountPercent: null, - promotionType: null, + currency: giftProduct.currency, }); describe("TipScreen checkout", () => { @@ -129,16 +156,16 @@ describe("TipScreen checkout", () => { container.remove(); }); - it("creates a character-attributed order without an AuthProvider", () => { + it("creates a dynamic character-attributed gift order", () => { renderScreen(); const checkout = getCheckoutButton(); - expect(container.textContent).toContain("A warm coffee prompt."); + expect(container.textContent).toContain("Golden Reserve"); expect(checkout.disabled).toBe(false); act(() => checkout.click()); expect(mocks.planClick).toHaveBeenCalledWith( - mediumPlan, + giftPlan, expect.objectContaining({ entryPoint: "tip_page" }), ); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ @@ -148,84 +175,60 @@ describe("TipScreen checkout", () => { }); it.each([ - ["plans are loading", { isLoadingPlans: true }], - ["the selected plan is missing", { plans: [], selectedPlanId: "" }], + ["catalog is loading", { isLoadingPlans: true }], + [ + "the selected product is missing", + { plans: [], giftProducts: [], selectedPlanId: "" }, + ], ["an order is being created", { isCreatingOrder: true }], ["an order is being polled", { isPollingOrder: true }], ] as const)("disables checkout when %s", (_label, overrides) => { mocks.payment = makePaymentState(overrides); renderScreen(); - expect(getCheckoutButton().disabled).toBe(true); }); - it("replaces checkout with the first Tip success experience", () => { + it("shows the complete backend Tip message after payment", () => { mocks.payment = makePaymentState({ status: "paid", isPaid: true, orderStatus: "paid", - tipCount: 1, - thankYouMessage: "A backend message that is not used for first Tip.", + tipMessage: TipMessageResponseSchema.parse({ + orderId: "pay_xxx", + characterId: "maya-tan", + planId: giftProduct.planId, + productName: giftProduct.planName, + tipCount: 2, + poolIndex: 17, + message: "This complete message came directly from the backend.", + }), }); renderScreen(); expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull(); expect(container.textContent).toContain( - "Did you really just buy me a coffee?", - ); - expect(container.textContent).toContain("That honestly made me smile."); - expect(container.textContent).toContain( - "Thank you. I'll definitely think of you while I enjoy it.", + "This complete message came directly from the backend.", ); expect(document.activeElement?.id).toBe("tip-success-title"); - const sendAgain = getButton("Send another coffee"); - act(() => sendAgain.click()); + act(() => getButton("Send another gift").click()); + expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentReset" }); + }); + + it("keeps paid success visible and retries only a failed message", () => { + mocks.payment = makePaymentState({ + status: "tipMessageFailed", + isPaid: true, + orderStatus: "paid", + tipMessageError: "message unavailable", + }); + renderScreen(); + + expect(container.textContent).toContain("Thank you. Your gift made me smile."); + act(() => getButton("Retry message").click()); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ - type: "PaymentReset", + type: "PaymentTipMessageRetryRequested", }); - - expect( - container - .querySelector( - '[data-analytics-key="tip.success_back_to_splash"]', - ) - ?.getAttribute("href"), - ).toBe("/characters/maya/splash"); - }); - - it("shows the repeat Tip count and backend thank-you message", () => { - mocks.payment = makePaymentState({ - status: "paid", - isPaid: true, - orderStatus: "paid", - tipCount: 22, - thankYouMessage: "You always make my day sweeter.", - }); - renderScreen(); - - expect(container.textContent).toContain( - "This is the 22nd coffee you've treated me to.", - ); - expect(container.textContent).toContain( - "You always make my day sweeter.", - ); - }); - - it("uses generic success copy when Tip metadata is incomplete", () => { - mocks.payment = makePaymentState({ - status: "paid", - isPaid: true, - orderStatus: "paid", - tipCount: 2, - thankYouMessage: null, - }); - renderScreen(); - - expect(container.textContent).toContain( - "Thank you. Your coffee made me smile.", - ); - expect(container.textContent).not.toContain("2nd coffee"); }); function renderScreen(): void { @@ -254,20 +257,25 @@ function makePaymentState( ): PaymentContextState { return { status: "ready", - plans: [mediumPlan], + planCatalog: "tip", + plans: [giftPlan], + giftCategories: [giftCategory], + giftProducts: [giftProduct], + selectedGiftCategory: giftCategory.category, isFirstRecharge: false, - selectedPlanId: mediumPlan.planId, + selectedPlanId: giftPlan.planId, payChannel: "stripe", autoRenew: false, agreed: true, currentOrderId: null, payParams: null, orderStatus: null, - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, errorMessage: null, launchNonce: 0, isLoadingPlans: false, + isLoadingTipMessage: false, isCreatingOrder: false, isPollingOrder: false, isPaid: false, diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts index 72e1b6d7..a0a4bedb 100644 --- a/src/app/tip/__tests__/tip-screen.helpers.test.ts +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -1,108 +1,60 @@ import { describe, expect, it } from "vitest"; -import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment"; -import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan"; +import { + GiftCategorySchema, + GiftProductSchema, +} from "@/data/schemas/payment"; import { - findTipCoffeePlan, - formatEnglishOrdinal, - formatTipPrice, - resolveTipSuccessCopy, + formatGiftPrice, + getGiftImageSources, + TIP_GIFT_PLACEHOLDER_IMAGE, } from "../tip-screen.helpers"; -function makePlan(input: Partial): PaymentPlan { - return PaymentPlanSchema.parse({ - planId: "coins_100", - planName: "Coins", - orderType: "dol", - vipDays: null, - dolAmount: 100, - creditBalance: 100, - amountCents: 990, - originalAmountCents: null, - dailyPriceCents: null, - currency: "USD", - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: null, - promotionType: null, - ...input, - }); -} +const category = GiftCategorySchema.parse({ + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: "https://cdn.example.com/category.jpg", +}); + +const product = GiftProductSchema.parse({ + planId: "gift_1", + planName: "Golden Reserve", + orderType: "tip", + tipType: "coffee_medium", + category: "coffee", + characterId: "elio", + description: "A warm coffee", + imageUrl: "https://cdn.example.com/product.jpg", + amountCents: 999, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, +}); describe("tip screen helpers", () => { - it("matches the official small coffee plan", () => { - const plan = makePlan({ - planId: "tip_coffee_usd_4_99", - }); - - expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan); + it("formats prices from backend amount and currency", () => { + expect(formatGiftPrice(999, "usd")).toBe("$9.99"); + expect(formatGiftPrice(500, "PHP")).toContain("5.00"); }); - it("matches medium and large coffee plans independently", () => { - const medium = makePlan({ - planId: "tip_coffee_usd_9_99", - orderType: "tip", - amountCents: 999, - }); - const large = makePlan({ - planId: "tip_coffee_usd_19_99", - orderType: "tip", - amountCents: 1999, - }); - - expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium); - expect(findTipCoffeePlan([medium, large], "large")).toBe(large); + it("orders product, category, and local image fallbacks", () => { + expect(getGiftImageSources(product, category)).toEqual([ + "https://cdn.example.com/product.jpg", + "https://cdn.example.com/category.jpg", + TIP_GIFT_PLACEHOLDER_IMAGE, + ]); }); - it("does not infer a coffee tier from order type or amount", () => { - const ambiguousPlan = makePlan({ - planId: "legacy_coffee", - orderType: "tip", - amountCents: 999, - }); - - expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull(); - }); - - it("formats USD prices with the product-style label", () => { + it("always retains the local placeholder", () => { expect( - formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }), "small"), - ).toBe("US$ 5"); - }); - - it("uses the selected coffee price when its plan is unavailable", () => { - expect(formatTipPrice(null, "small")).toBe("US$ 4.99"); - expect(formatTipPrice(null, "medium")).toBe("US$ 9.99"); - expect(formatTipPrice(null, "large")).toBe("US$ 19.99"); - }); - - it.each([ - [2, "2nd"], - [3, "3rd"], - [11, "11th"], - [12, "12th"], - [13, "13th"], - [21, "21st"], - [22, "22nd"], - ])("formats %i as the English ordinal %s", (value, expected) => { - expect(formatEnglishOrdinal(value)).toBe(expected); - }); - - it("resolves first, repeat, and fallback success copy", () => { - expect(resolveTipSuccessCopy(1, "Ignored for the first Tip")).toEqual({ - title: "Did you really just buy me a coffee?", - body: [ - "That honestly made me smile.", - "Thank you. I'll definitely think of you while I enjoy it.", - ], - }); - expect(resolveTipSuccessCopy(22, "You made my day.")).toEqual({ - title: "This is the 22nd coffee you've treated me to.", - body: ["You made my day."], - }); - expect(resolveTipSuccessCopy(2, null)).toEqual({ - title: "Thank you. Your coffee made me smile.", - body: [], - }); + getGiftImageSources( + GiftProductSchema.parse({ ...product, imageUrl: null }), + GiftCategorySchema.parse({ ...category, imageUrl: null }), + ), + ).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]); }); }); diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx index 0bd50538..f7bbee1f 100644 --- a/src/app/tip/tip-checkout-button.tsx +++ b/src/app/tip/tip-checkout-button.tsx @@ -2,7 +2,6 @@ import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs"; -import type { TipCoffeeType } from "@/lib/tip/tip_coffee"; import { useActiveCharacter } from "@/providers/character-provider"; import { usePaymentDispatch, @@ -15,14 +14,16 @@ import styles from "./tip-screen.module.css"; const log = new Logger("TipCheckoutButton"); export interface TipCheckoutButtonProps { - coffeeType: TipCoffeeType; + giftCategory: string | null; + giftPlanId: string | null; disabled?: boolean; onOrder: () => void; returnPath: string; } export function TipCheckoutButton({ - coffeeType, + giftCategory, + giftPlanId, disabled = false, onOrder, returnPath, @@ -36,7 +37,8 @@ export function TipCheckoutButton({ payment, paymentDispatch, subscriptionType: "tip", - tipCoffeeType: coffeeType, + giftCategory, + giftPlanId, characterSlug: character.slug, }); diff --git a/src/app/tip/tip-coffee-tier-selector.tsx b/src/app/tip/tip-coffee-tier-selector.tsx deleted file mode 100644 index 43875c00..00000000 --- a/src/app/tip/tip-coffee-tier-selector.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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; - 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-gift-product-selector.tsx b/src/app/tip/tip-gift-product-selector.tsx new file mode 100644 index 00000000..07982328 --- /dev/null +++ b/src/app/tip/tip-gift-product-selector.tsx @@ -0,0 +1,66 @@ +import { Check } from "lucide-react"; + +import type { GiftProduct } from "@/data/schemas/payment"; + +import { formatGiftPrice } from "./tip-screen.helpers"; +import styles from "./tip-screen.module.css"; + +interface TipGiftProductSelectorProps { + disabled: boolean; + products: readonly GiftProduct[]; + onChange: (planId: string) => void; + selectedPlanId: string; +} + +export function TipGiftProductSelector({ + disabled, + products, + onChange, + selectedPlanId, +}: TipGiftProductSelectorProps) { + return ( +
+ Choose a gift +
+ {products.map((product) => { + const isSelected = product.planId === selectedPlanId; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/app/tip/tip-product-image.tsx b/src/app/tip/tip-product-image.tsx new file mode 100644 index 00000000..0a4b56e8 --- /dev/null +++ b/src/app/tip/tip-product-image.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; + +interface TipProductImageProps { + alt: string; + className: string; + priority?: boolean; + sources: readonly string[]; +} + +export function TipProductImage({ + alt, + className, + priority = false, + sources, +}: TipProductImageProps) { + const [sourceIndex, setSourceIndex] = useState(0); + const source = sources[sourceIndex] ?? sources[sources.length - 1]; + if (!source) return null; + + return ( + // Gift hosts are managed by the backend and cannot be safely enumerated in Next remotePatterns. + // eslint-disable-next-line @next/next/no-img-element + {alt} { + if (sourceIndex < sources.length - 1) { + setSourceIndex((index) => index + 1); + } + }} + /> + ); +} diff --git a/src/app/tip/tip-screen.helpers.ts b/src/app/tip/tip-screen.helpers.ts index 1a256bad..049db002 100644 --- a/src/app/tip/tip-screen.helpers.ts +++ b/src/app/tip/tip-screen.helpers.ts @@ -1,79 +1,36 @@ -import type { PaymentPlan } from "@/data/schemas/payment"; -import { - getTipCoffeeOption, - type TipCoffeeType, -} from "@/lib/tip/tip_coffee"; +import type { + GiftCategory, + GiftProduct, +} from "@/data/schemas/payment"; -export interface TipSuccessCopy { - readonly title: string; - readonly body: readonly string[]; -} +export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png"; -export function findTipCoffeePlan( - plans: readonly PaymentPlan[], - coffeeType: TipCoffeeType, -): PaymentPlan | null { - const option = getTipCoffeeOption(coffeeType); - return plans.find((plan) => plan.planId === option.planId) ?? null; -} - -export function formatTipPrice( - plan: PaymentPlan | null, - coffeeType: TipCoffeeType, +export function formatGiftPrice( + amountCents: number, + currency: string, ): string { - const option = getTipCoffeeOption(coffeeType); - const amountCents = plan?.amountCents ?? option.amountCents; - const currency = plan?.currency.trim().toUpperCase() || "USD"; - - const amount = amountCents / 100; - const formattedAmount = Number.isInteger(amount) - ? String(amount) - : amount.toFixed(2).replace(/\.?0+$/, ""); - - if (currency === "USD") return `US$ ${formattedAmount}`; - if (currency.length > 0) return `${currency} ${formattedAmount}`; - return formattedAmount; -} - -export function formatEnglishOrdinal(value: number): string { - const remainder100 = value % 100; - if (remainder100 >= 11 && remainder100 <= 13) return `${value}th`; - - switch (value % 10) { - case 1: - return `${value}st`; - case 2: - return `${value}nd`; - case 3: - return `${value}rd`; - default: - return `${value}th`; + const normalizedCurrency = currency.trim().toUpperCase(); + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: normalizedCurrency, + }).format(amountCents / 100); + } catch { + const amount = (amountCents / 100).toFixed(2); + return normalizedCurrency ? `${normalizedCurrency} ${amount}` : amount; } } -export function resolveTipSuccessCopy( - tipCount: number | null, - thankYouMessage: string | null, -): TipSuccessCopy { - if (tipCount === 1) { - return { - title: "Did you really just buy me a coffee?", - body: [ - "That honestly made me smile.", - "Thank you. I'll definitely think of you while I enjoy it.", - ], - }; - } - - if (tipCount !== null && tipCount > 1 && thankYouMessage) { - return { - title: `This is the ${formatEnglishOrdinal(tipCount)} coffee you've treated me to.`, - body: [thankYouMessage], - }; - } - - return { - title: "Thank you. Your coffee made me smile.", - body: [], - }; +export function getGiftImageSources( + product: GiftProduct | null, + category: GiftCategory | null, +): readonly string[] { + return [ + product?.imageUrl, + category?.imageUrl, + TIP_GIFT_PLACEHOLDER_IMAGE, + ].filter( + (source, index, sources): source is string => + Boolean(source) && sources.indexOf(source) === index, + ); } diff --git a/src/app/tip/tip-screen.module.css b/src/app/tip/tip-screen.module.css index 4f2e73c7..f85ddc05 100644 --- a/src/app/tip/tip-screen.module.css +++ b/src/app/tip/tip-screen.module.css @@ -59,6 +59,7 @@ .productCard, .paymentMethodSlot, .statusMessage, +.catalogStatus, .checkoutSlot { position: relative; z-index: 1; @@ -225,6 +226,26 @@ letter-spacing: -0.04em; } +.productDescription { + margin: 10px 0 0; + color: #7d6264; + font-size: clamp(12px, 3.148vw, 15px); + font-weight: 620; + line-height: 1.45; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .tierSelector { grid-column: 1 / -1; min-width: 0; @@ -331,6 +352,17 @@ white-space: nowrap; } +.tierDescription { + display: -webkit-box; + overflow: hidden; + color: #80676a; + font-size: 11px; + font-weight: 620; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .tierPriceBlock { align-items: flex-end; gap: 3px; @@ -371,6 +403,82 @@ margin: 0; } +.catalogStatus { + display: grid; + justify-items: center; + gap: 12px; + margin-top: 20px; + padding: 22px; + border: 1px solid rgba(112, 71, 65, 0.1); + border-radius: 24px; + background: rgba(255, 255, 255, 0.78); + color: #76575c; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + text-align: center; +} + +.catalogStatus p { + margin: 0; +} + +.catalogStatus button { + min-height: 40px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: #2b1a1e; + color: #ffffff; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 850; +} + +.productSkeleton { + min-height: 260px; +} + +.skeletonImage, +.skeletonCopy span { + background: linear-gradient( + 100deg, + rgba(205, 172, 157, 0.14) 20%, + rgba(255, 255, 255, 0.72) 45%, + rgba(205, 172, 157, 0.14) 70% + ); + background-size: 220% 100%; + animation: skeletonSweep 1.25s linear infinite; +} + +.skeletonImage { + width: 100%; + aspect-ratio: 1; + border-radius: clamp(24px, 6.667vw, 32px); +} + +.skeletonCopy { + display: grid; + gap: 12px; +} + +.skeletonCopy span { + display: block; + height: 18px; + border-radius: 999px; +} + +.skeletonCopy span:nth-child(2) { + width: 78%; + height: 38px; +} + +.skeletonCopy span:nth-child(3) { + width: 56%; + height: 28px; +} + .statusMessage { margin-top: 14px; color: #b2474f; @@ -467,3 +575,16 @@ transform: translateY(0) scale(1); } } + +@keyframes skeletonSweep { + to { + background-position: -120% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeletonImage, + .skeletonCopy span { + animation: none; + } +} diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index af833799..b1e95630 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -1,7 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type CSSProperties } from "react"; -import Image from "next/image"; +import { useEffect, type CSSProperties } from "react"; import { Sparkles } from "lucide-react"; import { BackButton, CharacterAvatar } from "@/app/_components"; @@ -16,13 +15,7 @@ import { type PaymentAnalyticsContext, } from "@/lib/analytics"; import { getPaymentMethodConfig } from "@/lib/payment/payment_method"; -import { - buildTipCoffeePath, - DEFAULT_TIP_COFFEE_TYPE, - getTipCoffeeOption, - TIP_COFFEE_OPTIONS, - type TipCoffeeType, -} from "@/lib/tip/tip_coffee"; +import { buildTipGiftPath } from "@/lib/tip/tip_gift"; import { useActiveCharacter, useActiveCharacterRoutes, @@ -30,13 +23,11 @@ import { import { useUserState } from "@/stores/user/user-context"; import { TipCheckoutButton } from "./tip-checkout-button"; +import { TipGiftProductSelector } from "./tip-gift-product-selector"; +import { TipProductImage } from "./tip-product-image"; import { - TipCoffeeTierSelector, - type TipCoffeeTierItem, -} from "./tip-coffee-tier-selector"; -import { - findTipCoffeePlan, - formatTipPrice, + formatGiftPrice, + getGiftImageSources, } from "./tip-screen.helpers"; import { TipSuccessView } from "./tip-success-view"; import { useTipSupportPrompt } from "./use-tip-support-prompt"; @@ -48,13 +39,15 @@ const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = { }; export interface TipScreenProps { - coffeeType?: TipCoffeeType; + initialCategory?: string | null; + initialPlanId?: string | null; shouldResumePendingOrder?: boolean; initialPayChannel?: PayChannel | null; } export function TipScreen({ - coffeeType = DEFAULT_TIP_COFFEE_TYPE, + initialCategory = null, + initialPlanId = null, shouldResumePendingOrder = false, initialPayChannel = null, }: TipScreenProps) { @@ -66,63 +59,61 @@ export function TipScreen({ countryCode: userState.currentUser?.countryCode, requestedPayChannel: initialPayChannel, }); - const [selectedCoffeeType, setSelectedCoffeeType] = - useState(coffeeType); - const coffeeOption = getTipCoffeeOption(selectedCoffeeType); - const returnPath = buildTipCoffeePath( - selectedCoffeeType, - characterRoutes.tip, - ); const { payment, paymentDispatch } = usePaymentRouteFlow({ catalog: "tip", + characterId: character.id, + initialCategory, + initialPlanId, initialPayChannel: paymentMethodConfig.initialPayChannel, paymentType: "tip", shouldResumePendingOrder, }); - const coffeeTiers = useMemo( - () => - TIP_COFFEE_OPTIONS.map((option) => ({ - option, - plan: findTipCoffeePlan(payment.plans, option.type), - })), - [payment.plans], - ); - const coffeePlan = - coffeeTiers.find(({ option }) => option.type === selectedCoffeeType)?.plan ?? + const selectedCategory = + payment.giftCategories.find( + (category) => category.category === payment.selectedGiftCategory, + ) ?? null; + const visibleProducts = payment.selectedGiftCategory + ? payment.giftProducts.filter( + (product) => product.category === payment.selectedGiftCategory, + ) + : []; + const selectedProduct = + visibleProducts.find( + (product) => product.planId === payment.selectedPlanId, + ) ?? null; + const selectedPlan = + payment.plans.find((plan) => plan.planId === payment.selectedPlanId) ?? null; - const priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType); - const availableCoffeePlans = useMemo( - () => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])), - [coffeeTiers], + const returnPath = buildTipGiftPath( + { + category: payment.selectedGiftCategory, + planId: payment.selectedPlanId || null, + }, + characterRoutes.tip, ); - const tierItems = useMemo( - () => - coffeeTiers.map(({ option, plan }) => ({ - type: option.type, - 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); + + usePaymentPlanAnalytics(payment.plans, TIP_ANALYTICS_CONTEXT); + const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid; const canCreateOrder = - coffeePlan !== null && - payment.selectedPlanId === coffeePlan.planId && + selectedProduct !== null && + selectedPlan !== null && payment.agreed && !payment.autoRenew && !payment.isLoadingPlans && !isPaymentBusy; - const showMissingPlan = - payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null; - const isTierSelectionDisabled = - payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; + const isSelectionDisabled = + !["ready", "failed", "expired"].includes(payment.status) || + payment.isLoadingPlans || + isPaymentBusy; + const catalogLoaded = + payment.status === "ready" && !payment.isLoadingPlans; + const showCatalogError = + catalogLoaded && payment.errorMessage !== null && visibleProducts.length === 0; + const showEmptyCatalog = + catalogLoaded && payment.errorMessage === null && visibleProducts.length === 0; const handlePaymentMethodChange = (payChannel: PayChannel) => { paymentDispatch({ @@ -140,18 +131,7 @@ export function TipScreen({ }); useEffect(() => { - if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) { - return; - } - if (!coffeePlan) return; - - if (payment.selectedPlanId !== coffeePlan.planId) { - paymentDispatch({ - type: "PaymentPlanSelected", - planId: coffeePlan.planId, - }); - return; - } + if (!selectedProduct || isPaymentBusy || payment.isLoadingPlans) return; if (payment.autoRenew) { paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false }); @@ -162,55 +142,62 @@ export function TipScreen({ paymentDispatch({ type: "PaymentAgreementChanged", agreed: true }); } }, [ - coffeePlan, + isPaymentBusy, payment.agreed, payment.autoRenew, - payment.isCreatingOrder, payment.isLoadingPlans, - payment.isPollingOrder, - payment.selectedPlanId, paymentDispatch, + selectedProduct, ]); const handleOrder = () => { - if (!canCreateOrder) return; + if (!canCreateOrder || !selectedPlan) return; - behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT); + behaviorAnalytics.planClick(selectedPlan, TIP_ANALYTICS_CONTEXT); paymentDispatch({ type: "PaymentCreateOrderSubmitted", recipientCharacterId: character.id, }); }; - 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 handleResetPaidState = () => { - paymentDispatch({ type: "PaymentReset" }); + const handleProductChange = (planId: string) => { + if (isSelectionDisabled || planId === payment.selectedPlanId) return; + if (!visibleProducts.some((product) => product.planId === planId)) return; + paymentDispatch({ type: "PaymentPlanSelected", planId }); }; if (payment.isPaid) { + const successProduct = + payment.giftProducts.find( + (product) => product.planId === payment.tipMessage?.planId, + ) ?? selectedProduct; + const successCategory = + payment.giftCategories.find( + (category) => category.category === successProduct?.category, + ) ?? selectedCategory; + return ( + paymentDispatch({ type: "PaymentTipMessageRetryRequested" }) + } + onSendAgain={() => paymentDispatch({ type: "PaymentReset" })} /> ); } @@ -262,57 +249,94 @@ export function TipScreen({

-
-
- {`${coffeeOption.displayName} +
+
+ + + +
+
+ ) : selectedProduct ? ( +
+
+ +
+ +
+ + +

{selectedProduct.planName}

+

+ {formatGiftPrice( + selectedProduct.amountCents, + selectedProduct.currency, + )} +

+ {selectedProduct.description ? ( +

+ {selectedProduct.description} +

+ ) : null} +
+ + - +
+ ) : null} -
- - -

- {coffeeOption.displayName} -

-

{priceLabel}

-
- - - + {showCatalogError || showEmptyCatalog ? ( +
+

+ {showCatalogError + ? "We could not load gifts for this character." + : "This character does not have any gifts available yet."} +

+ {showCatalogError ? ( + + ) : null} +
+ ) : null} - {showMissingPlan ? ( -

- Coffee tip is not available yet. Please try again later. -

- ) : null} -
diff --git a/src/app/tip/tip-success-view.module.css b/src/app/tip/tip-success-view.module.css index 3b44ad7e..fb5b1575 100644 --- a/src/app/tip/tip-success-view.module.css +++ b/src/app/tip/tip-success-view.module.css @@ -174,6 +174,38 @@ margin: 0; } +.messageRetry { + display: grid; + justify-items: center; + gap: 9px; + margin-top: 15px; + color: #8a6870; + font-size: 12px; + font-weight: 650; +} + +.messageRetry p { + margin: 0; +} + +.retryAction { + min-height: 36px; + padding: 0 15px; + border: 1px solid rgba(199, 68, 112, 0.18); + border-radius: 999px; + background: #fff0f4; + color: #bd3d69; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 850; +} + +.retryAction:disabled { + cursor: wait; + opacity: 0.62; +} + .actions { display: grid; gap: 10px; diff --git a/src/app/tip/tip-success-view.tsx b/src/app/tip/tip-success-view.tsx index 95de6800..6dc60c5e 100644 --- a/src/app/tip/tip-success-view.tsx +++ b/src/app/tip/tip-success-view.tsx @@ -1,25 +1,29 @@ "use client"; import { useEffect, useRef, type CSSProperties } from "react"; -import Image from "next/image"; import Link from "next/link"; -import { Coffee, Heart, Sparkles } from "lucide-react"; +import { Gift, Heart, Sparkles } from "lucide-react"; import { CharacterAvatar } from "@/app/_components"; import { MobileShell } from "@/app/_components/core"; -import type { TipCoffeeOption } from "@/lib/tip/tip_coffee"; -import { resolveTipSuccessCopy } from "./tip-screen.helpers"; +import { TipProductImage } from "./tip-product-image"; import styles from "./tip-success-view.module.css"; +const GENERIC_TIP_SUCCESS_MESSAGE = + "Thank you. Your gift made me smile."; + export interface TipSuccessViewProps { characterName: string; characterAvatar: string; characterCover: string; - coffeeOption: TipCoffeeOption; + giftImageSources: readonly string[]; + giftName: string; + isMessageLoading: boolean; + message: string | null; + messageError: string | null; splashHref: string; - tipCount: number | null; - thankYouMessage: string | null; + onRetryMessage: () => void; onSendAgain: () => void; } @@ -27,14 +31,16 @@ export function TipSuccessView({ characterName, characterAvatar, characterCover, - coffeeOption, + giftImageSources, + giftName, + isMessageLoading, + message, + messageError, splashHref, - tipCount, - thankYouMessage, + onRetryMessage, onSendAgain, }: TipSuccessViewProps) { const titleRef = useRef(null); - const copy = resolveTipSuccessCopy(tipCount, thankYouMessage); useEffect(() => { titleRef.current?.focus({ preventScroll: true }); @@ -76,12 +82,10 @@ export function TipSuccessView({ />
- {`${coffeeOption.displayName} @@ -89,8 +93,8 @@ export function TipSuccessView({

-

- {copy.title} + You made {characterName} smile.

- {copy.body.length > 0 ? ( -
- {copy.body.map((paragraph) => ( -

{paragraph}

- ))} +
+

+ {message ?? + (isMessageLoading + ? "Your gift arrived. A thank-you note is on its way." + : GENERIC_TIP_SUCCESS_MESSAGE)} +

+
+ + {messageError ? ( +
+

We could not load the personal thank-you note.

+
) : null} @@ -115,7 +134,7 @@ export function TipSuccessView({ data-analytics-key="tip.send_again" onClick={onSendAgain} > - Send another coffee + Send another gift { - it("adapts tip plans without caching them as subscription plans", async () => { - const getTipPlans = vi.fn().mockResolvedValue( - TipPaymentPlansResponseSchema.parse({ - plans: [ - { - planId: "tip_coffee_usd_9_99", - planName: "Medium Coffee", - amountCents: 999, - currency: "USD", - }, - ], - }), - ); + it("loads a character gift catalog without adapting product metadata", async () => { + const catalog = GiftProductsResponseSchema.parse({ + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], + plans: [ + { + planId: "tip_coffee_usd_9_99", + planName: "Golden Reserve", + orderType: "tip", + tipType: "coffee_medium", + category: "coffee", + characterId: "elio", + description: "Buy Elio a coffee", + imageUrl: null, + amountCents: 999, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + ], + }); + const getGiftProducts = vi.fn().mockResolvedValue(catalog); const repository = new PaymentRepository({ - getTipPlans, + getGiftProducts, } as unknown as PaymentApi); - const result = await repository.getTipPlans(); + const result = await repository.getGiftProducts("elio"); - expect(getTipPlans).toHaveBeenCalledOnce(); - expect(Result.isOk(result) && result.data.plans[0]).toMatchObject({ + expect(getGiftProducts).toHaveBeenCalledWith("elio"); + expect(Result.isOk(result) && result.data).toBe(catalog); + }); + + it("validates and forwards a Tip message order id", async () => { + const tipMessage = TipMessageResponseSchema.parse({ + orderId: "pay_xxx", + characterId: "elio", planId: "tip_coffee_usd_9_99", - planName: "Medium Coffee", - orderType: "tip", - amountCents: 999, - currency: "USD", - isFirstRechargeOffer: false, + productName: "Golden Reserve", + tipCount: 1, + poolIndex: 1, + message: "Thank you.", }); + const getTipMessage = vi.fn().mockResolvedValue(tipMessage); + const repository = new PaymentRepository({ + getTipMessage, + } as unknown as PaymentApi); + + const result = await repository.getTipMessage("pay_xxx"); + + expect(getTipMessage).toHaveBeenCalledWith({ orderId: "pay_xxx" }); + expect(Result.isOk(result) && result.data.message).toBe("Thank you."); }); }); diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts index 1092b141..e02ceccd 100644 --- a/src/data/repositories/interfaces/ipayment_repository.ts +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -3,9 +3,11 @@ */ import type { CreatePaymentOrderResponse, + GiftProductsResponse, PayChannel, PaymentOrderStatusResponse, PaymentPlansResponse, + TipMessageResponse, } from "@/data/schemas/payment"; import type { Result } from "@/utils/result"; @@ -16,8 +18,8 @@ export interface IPaymentRepository { /** 获取本地缓存套餐列表。 */ getCachedPlans(): Promise>; - /** 获取咖啡打赏套餐列表。 */ - getTipPlans(): Promise>; + /** 获取当前角色的完整礼物目录。 */ + getGiftProducts(characterId: string): Promise>; /** 清除本地缓存套餐列表。 */ clearCachedPlans(): Promise>; @@ -32,4 +34,7 @@ export interface IPaymentRepository { /** 查询支付订单状态。 */ getOrderStatus(orderId: string): Promise>; + + /** 获取已支付礼物订单的角色感谢文案。 */ + getTipMessage(orderId: string): Promise>; } diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index f256ca90..f56b2287 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -7,10 +7,13 @@ import type { IPaymentRepository } from "@/data/repositories/interfaces"; import { CreatePaymentOrderRequestSchema, CreatePaymentOrderResponse, + GiftProductsResponse, PayChannel, PaymentOrderStatusResponse, PaymentPlansResponse, PaymentPlansResponseSchema, + TipMessageRequestSchema, + TipMessageResponse, } from "@/data/schemas/payment"; import { PaymentApi, paymentApi } from "@/data/services/api"; import { PaymentPlansStorage } from "@/data/storage/payment"; @@ -38,25 +41,11 @@ export class PaymentRepository implements IPaymentRepository { }); } - /** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */ - async getTipPlans(): Promise> { - return Result.wrap(async () => { - const response = await this.api.getTipPlans(); - return PaymentPlansResponseSchema.parse({ - plans: response.plans.map((plan) => ({ - ...plan, - orderType: "tip", - vipDays: null, - dolAmount: null, - creditBalance: 0, - originalAmountCents: null, - dailyPriceCents: null, - isFirstRechargeOffer: false, - firstRechargeDiscountPercent: null, - promotionType: null, - })), - }); - }); + /** 获取当前角色的完整礼物目录,不写入订阅套餐缓存。 */ + async getGiftProducts( + characterId: string, + ): Promise> { + return Result.wrap(() => this.api.getGiftProducts(characterId)); } /** 清除本地缓存套餐列表。 */ @@ -89,6 +78,12 @@ export class PaymentRepository implements IPaymentRepository { ): Promise> { return Result.wrap(() => this.api.getOrderStatus(orderId)); } + + /** 获取已支付礼物订单的角色感谢文案。 */ + async getTipMessage(orderId: string): Promise> { + const request = TipMessageRequestSchema.parse({ orderId }); + return Result.wrap(() => this.api.getTipMessage(request)); + } } /** 全局懒单例。 */ diff --git a/src/data/schemas/payment/__tests__/payment_plan.test.ts b/src/data/schemas/payment/__tests__/payment_plan.test.ts index ae9d7995..f979585b 100644 --- a/src/data/schemas/payment/__tests__/payment_plan.test.ts +++ b/src/data/schemas/payment/__tests__/payment_plan.test.ts @@ -2,21 +2,21 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { + GiftProductsResponseSchema, PaymentOrderStatusResponseSchema, PaymentPlanSchema, PaymentPlansResponseSchema, - TipPaymentPlansResponseSchema, + TipMessageResponseSchema, } from "@/data/schemas/payment"; describe("PaymentOrderStatusResponse", () => { - it("parses paid Tip success metadata", () => { + it("parses the paid gift order shape", () => { const response = PaymentOrderStatusResponseSchema.parse({ orderId: "tip_order_123", status: "paid", orderType: "tip", planId: "tip_coffee_usd_9_99", - tipCount: 2, - thankYouMessage: " You made my day. ", + creditsAdded: 0, }); expect(response).toEqual({ @@ -24,37 +24,20 @@ describe("PaymentOrderStatusResponse", () => { status: "paid", orderType: "tip", planId: "tip_coffee_usd_9_99", - tipCount: 2, - thankYouMessage: "You made my day.", + creditsAdded: 0, }); }); - it("degrades missing or invalid optional Tip metadata to null", () => { + it("accepts expired status and a nullable plan id", () => { expect( PaymentOrderStatusResponseSchema.parse({ orderId: "pay_order_456", - status: "pending", - orderType: "vip_monthly", - planId: "vip_monthly", - }), - ).toMatchObject({ - tipCount: null, - thankYouMessage: null, - }); - - expect( - PaymentOrderStatusResponseSchema.parse({ - orderId: "tip_order_invalid", - status: "paid", + status: "expired", orderType: "tip", - planId: "tip_coffee_usd_4_99", - tipCount: 0, - thankYouMessage: " ", + planId: null, + creditsAdded: 0, }), - ).toMatchObject({ - tipCount: null, - thankYouMessage: null, - }); + ).toMatchObject({ status: "expired", planId: null, creditsAdded: 0 }); }); }); @@ -209,34 +192,58 @@ describe("PaymentPlansResponse", () => { }); }); -describe("TipPaymentPlansResponse", () => { - it("keeps only fields used by the tip payment flow", () => { - const response = TipPaymentPlansResponseSchema.parse({ +describe("GiftProductsResponse", () => { + it("parses and freezes categories and complete gift products", () => { + const response = GiftProductsResponseSchema.parse({ + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], plans: [ { planId: "tip_coffee_usd_4_99", - planName: "Small Coffee", + planName: "Velvet Espresso", orderType: "tip", tipType: "coffee_small", + category: "coffee", + characterId: "elio", description: "Buy Elio a small coffee", + imageUrl: null, amountCents: 499, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0, + promotionType: null, }, ], }); - expect(response).toEqual({ - plans: [ - { - planId: "tip_coffee_usd_4_99", - planName: "Small Coffee", - amountCents: 499, - currency: "USD", - }, - ], - }); + expect(response.categories[0]?.category).toBe("coffee"); + expect(response.plans[0]?.description).toBe("Buy Elio a small coffee"); + expect(Object.isFrozen(response)).toBe(true); + expect(Object.isFrozen(response.categories)).toBe(true); + expect(Object.isFrozen(response.plans[0])).toBe(true); + }); +}); + +describe("TipMessageResponse", () => { + it("parses the complete stable backend message", () => { + const response = TipMessageResponseSchema.parse({ + orderId: "pay_xxx", + characterId: "elio", + planId: "tip_coffee_usd_4_99", + productName: "Velvet Espresso", + tipCount: 2, + poolIndex: 37, + message: "You have a knack for making me smile.", + }); + + expect(response.message).toBe("You have a knack for making me smile."); }); }); diff --git a/src/data/schemas/payment/gift_category.ts b/src/data/schemas/payment/gift_category.ts new file mode 100644 index 00000000..7dd390d8 --- /dev/null +++ b/src/data/schemas/payment/gift_category.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const GiftCategorySchema = z + .object({ + category: z.string().min(1), + name: z.string(), + productCount: z.number().int().nonnegative(), + imageUrl: z.string().nullable(), + }) + .readonly(); + +export type GiftCategoryInput = z.input; +export type GiftCategoryData = z.output; +export type GiftCategory = GiftCategoryData; diff --git a/src/data/schemas/payment/gift_product.ts b/src/data/schemas/payment/gift_product.ts new file mode 100644 index 00000000..e1c71b1c --- /dev/null +++ b/src/data/schemas/payment/gift_product.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { stringOrNull } from "../nullable-defaults"; + +export const GiftProductSchema = z + .object({ + planId: z.string().min(1), + planName: z.string(), + orderType: z.literal("tip"), + tipType: z.string(), + category: z.string().min(1), + characterId: z.string().min(1), + description: z.string(), + imageUrl: z.string().nullable(), + amountCents: z.number().int().nonnegative(), + currency: z.string(), + autoRenew: z.literal(false), + isFirstRechargeOffer: z.literal(false), + firstRechargeDiscountPercent: z.number().int(), + promotionType: stringOrNull, + }) + .readonly(); + +export type GiftProductInput = z.input; +export type GiftProductData = z.output; +export type GiftProduct = GiftProductData; diff --git a/src/data/schemas/payment/index.ts b/src/data/schemas/payment/index.ts index fe00c83f..c3d58250 100644 --- a/src/data/schemas/payment/index.ts +++ b/src/data/schemas/payment/index.ts @@ -3,9 +3,12 @@ */ export * from "./payment_plan"; -export * from "./tip_payment_plan"; +export * from "./gift_category"; +export * from "./gift_product"; export * from "./request/create_payment_order_request"; +export * from "./request/tip_message_request"; export * from "./response/create_payment_order_response"; +export * from "./response/gift_products_response"; export * from "./response/payment_order_status_response"; export * from "./response/payment_plans_response"; -export * from "./response/tip_payment_plans_response"; +export * from "./response/tip_message_response"; diff --git a/src/data/schemas/payment/request/index.ts b/src/data/schemas/payment/request/index.ts index 7e746cc4..67ac7295 100644 --- a/src/data/schemas/payment/request/index.ts +++ b/src/data/schemas/payment/request/index.ts @@ -3,3 +3,4 @@ */ export * from "./create_payment_order_request"; +export * from "./tip_message_request"; diff --git a/src/data/schemas/payment/request/tip_message_request.ts b/src/data/schemas/payment/request/tip_message_request.ts new file mode 100644 index 00000000..9a6ca616 --- /dev/null +++ b/src/data/schemas/payment/request/tip_message_request.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const TipMessageRequestSchema = z + .object({ + orderId: z.string().min(1), + }) + .readonly(); + +export type TipMessageRequestInput = z.input; +export type TipMessageRequestData = z.output; +export type TipMessageRequest = TipMessageRequestData; diff --git a/src/data/schemas/payment/response/gift_products_response.ts b/src/data/schemas/payment/response/gift_products_response.ts new file mode 100644 index 00000000..cf3c0154 --- /dev/null +++ b/src/data/schemas/payment/response/gift_products_response.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { arrayOrEmpty, stringOrNull } from "../../nullable-defaults"; +import { GiftCategorySchema } from "../gift_category"; +import { GiftProductSchema } from "../gift_product"; + +export const GiftProductsResponseSchema = z + .object({ + characterId: stringOrNull, + categories: arrayOrEmpty(GiftCategorySchema), + plans: arrayOrEmpty(GiftProductSchema), + }) + .readonly(); + +export type GiftProductsResponseInput = z.input< + typeof GiftProductsResponseSchema +>; +export type GiftProductsResponseData = z.output< + typeof GiftProductsResponseSchema +>; +export type GiftProductsResponse = GiftProductsResponseData; diff --git a/src/data/schemas/payment/response/index.ts b/src/data/schemas/payment/response/index.ts index 789faa41..f8bdcf71 100644 --- a/src/data/schemas/payment/response/index.ts +++ b/src/data/schemas/payment/response/index.ts @@ -3,6 +3,7 @@ */ export * from "./create_payment_order_response"; +export * from "./gift_products_response"; export * from "./payment_order_status_response"; export * from "./payment_plans_response"; -export * from "./tip_payment_plans_response"; +export * from "./tip_message_response"; diff --git a/src/data/schemas/payment/response/payment_order_status_response.ts b/src/data/schemas/payment/response/payment_order_status_response.ts index d078abf8..8eb75ef0 100644 --- a/src/data/schemas/payment/response/payment_order_status_response.ts +++ b/src/data/schemas/payment/response/payment_order_status_response.ts @@ -3,33 +3,20 @@ */ import { z } from "zod"; -export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]); - -const tipCountOrNull = z.preprocess( - (value) => - typeof value === "number" && Number.isInteger(value) && value > 0 - ? value - : null, - z.number().int().positive().nullable(), -); - -const thankYouMessageOrNull = z.preprocess( - (value) => { - if (typeof value !== "string") return null; - const message = value.trim(); - return message.length > 0 ? message : null; - }, - z.string().nullable(), -); +export const PaymentOrderStatusSchema = z.enum([ + "pending", + "paid", + "failed", + "expired", +]); export const PaymentOrderStatusResponseSchema = z .object({ orderId: z.string(), status: PaymentOrderStatusSchema, orderType: z.string(), - planId: z.string(), - tipCount: tipCountOrNull, - thankYouMessage: thankYouMessageOrNull, + planId: z.string().nullable(), + creditsAdded: z.number().int(), }) .readonly(); diff --git a/src/data/schemas/payment/response/tip_message_response.ts b/src/data/schemas/payment/response/tip_message_response.ts new file mode 100644 index 00000000..2e230777 --- /dev/null +++ b/src/data/schemas/payment/response/tip_message_response.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const TipMessageResponseSchema = z + .object({ + orderId: z.string().min(1), + characterId: z.string().min(1), + planId: z.string().min(1), + productName: z.string(), + tipCount: z.number().int().positive(), + poolIndex: z.number().int().min(0).max(99), + message: z.string().min(1), + }) + .readonly(); + +export type TipMessageResponseInput = z.input; +export type TipMessageResponseData = z.output; +export type TipMessageResponse = TipMessageResponseData; diff --git a/src/data/schemas/payment/response/tip_payment_plans_response.ts b/src/data/schemas/payment/response/tip_payment_plans_response.ts deleted file mode 100644 index a29653c4..00000000 --- a/src/data/schemas/payment/response/tip_payment_plans_response.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod"; - -import { arrayOrEmpty } from "../../nullable-defaults"; -import { TipPaymentPlanSchema } from "../tip_payment_plan"; - -export const TipPaymentPlansResponseSchema = z - .object({ - plans: arrayOrEmpty(TipPaymentPlanSchema), - }) - .readonly(); - -export type TipPaymentPlansResponseInput = z.input< - typeof TipPaymentPlansResponseSchema ->; -export type TipPaymentPlansResponseData = z.output< - typeof TipPaymentPlansResponseSchema ->; - -export type TipPaymentPlansResponse = TipPaymentPlansResponseData; diff --git a/src/data/schemas/payment/tip_payment_plan.ts b/src/data/schemas/payment/tip_payment_plan.ts deleted file mode 100644 index 01599dbd..00000000 --- a/src/data/schemas/payment/tip_payment_plan.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from "zod"; - -export const TipPaymentPlanSchema = z - .object({ - planId: z.string(), - planName: z.string(), - amountCents: z.number(), - currency: z.string(), - }) - .readonly(); - -export type TipPaymentPlanInput = z.input; -export type TipPaymentPlanData = z.output; - -export type TipPaymentPlan = TipPaymentPlanData; diff --git a/src/data/services/api/__tests__/payment_api.test.ts b/src/data/services/api/__tests__/payment_api.test.ts index 7a907fa2..21f96b90 100644 --- a/src/data/services/api/__tests__/payment_api.test.ts +++ b/src/data/services/api/__tests__/payment_api.test.ts @@ -13,39 +13,72 @@ describe("PaymentApi", () => { httpClientMock.mockReset(); }); - it("loads public coffee tip plans from the dedicated endpoint", async () => { + it("loads the complete public gift catalog for a character", async () => { httpClientMock.mockResolvedValue({ success: true, data: { + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], plans: [ { planId: "tip_coffee_usd_19_99", planName: "Large Coffee", orderType: "tip", tipType: "coffee_large", + category: "coffee", + characterId: "elio", description: "Buy Elio a large coffee", + imageUrl: null, amountCents: 1999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0, + promotionType: null, }, ], }, }); - const response = await new PaymentApi().getTipPlans(); + const response = await new PaymentApi().getGiftProducts("elio"); - expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans"); - expect(response).toEqual({ - plans: [ - { - planId: "tip_coffee_usd_19_99", - planName: "Large Coffee", - amountCents: 1999, - currency: "USD", - }, - ], + expect(httpClientMock).toHaveBeenCalledWith( + "/api/payment/gift-products", + { query: { characterId: "elio" } }, + ); + expect(response.categories[0]?.category).toBe("coffee"); + expect(response.plans[0]?.planId).toBe("tip_coffee_usd_19_99"); + }); + + it("posts the paid order id when loading the Tip message", async () => { + httpClientMock.mockResolvedValue({ + success: true, + data: { + orderId: "pay_xxx", + characterId: "elio", + planId: "tip_coffee_usd_19_99", + productName: "Large Coffee", + tipCount: 1, + poolIndex: 7, + message: "Thank you for the thoughtful gift.", + }, }); + + const response = await new PaymentApi().getTipMessage({ + orderId: "pay_xxx", + }); + + expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-message", { + method: "POST", + body: { orderId: "pay_xxx" }, + }); + expect(response.message).toBe("Thank you for the thoughtful gift."); }); }); diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index ae01de69..4adc5269 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -15,7 +15,8 @@ "paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" }, "paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" }, "paymentPlans": { "method": "get", "path": "/api/payment/plans" }, - "paymentTipPlans": { "method": "get", "path": "/api/payment/tip-plans" }, + "paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" }, + "paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" }, "chatSend": { "method": "post", "path": "/api/chat/send" }, "chatHistory": { "method": "get", "path": "/api/chat/history" }, "chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index f26c6316..7ea07ca6 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -60,8 +60,11 @@ export class ApiPath { /** 获取商品套餐列表 */ static readonly paymentPlans = apiContract.paymentPlans.path; - /** 获取咖啡打赏套餐列表 */ - static readonly paymentTipPlans = apiContract.paymentTipPlans.path; + /** 获取角色的完整礼物目录 */ + static readonly paymentGiftProducts = apiContract.paymentGiftProducts.path; + + /** 获取已支付礼物订单的角色感谢文案 */ + static readonly paymentTipMessage = apiContract.paymentTipMessage.path; // ============ 聊天相关 ============ /** 发送消息 */ diff --git a/src/data/services/api/payment_api.ts b/src/data/services/api/payment_api.ts index 3ddd6c9f..71a8df41 100644 --- a/src/data/services/api/payment_api.ts +++ b/src/data/services/api/payment_api.ts @@ -7,12 +7,15 @@ import { CreatePaymentOrderRequest, CreatePaymentOrderResponse, CreatePaymentOrderResponseSchema, + GiftProductsResponse, + GiftProductsResponseSchema, PaymentOrderStatusResponse, PaymentOrderStatusResponseSchema, PaymentPlansResponse, PaymentPlansResponseSchema, - TipPaymentPlansResponse, - TipPaymentPlansResponseSchema, + TipMessageRequest, + TipMessageResponse, + TipMessageResponseSchema, } from "@/data/schemas/payment"; import { ApiPath } from "./api_path"; @@ -28,10 +31,13 @@ export class PaymentApi { ); } - /** 获取咖啡打赏套餐列表。 */ - async getTipPlans(): Promise { - const env = await httpClient>(ApiPath.paymentTipPlans); - return TipPaymentPlansResponseSchema.parse( + /** 一次获取当前角色的完整礼物目录。 */ + async getGiftProducts(characterId: string): Promise { + const env = await httpClient>( + ApiPath.paymentGiftProducts, + { query: { characterId } }, + ); + return GiftProductsResponseSchema.parse( unwrap(env) as Record, ); } @@ -64,6 +70,20 @@ export class PaymentApi { unwrap(env) as Record, ); } + + /** 获取已支付礼物订单的稳定感谢文案。 */ + async getTipMessage(body: TipMessageRequest): Promise { + const env = await httpClient>( + ApiPath.paymentTipMessage, + { + method: "POST", + body, + }, + ); + return TipMessageResponseSchema.parse( + unwrap(env) as Record, + ); + } } /** diff --git a/src/data/storage/payment/pending_payment_order_storage.ts b/src/data/storage/payment/pending_payment_order_storage.ts index 3172bd69..9af53d5c 100644 --- a/src/data/storage/payment/pending_payment_order_storage.ts +++ b/src/data/storage/payment/pending_payment_order_storage.ts @@ -7,15 +7,18 @@ import { SpAsyncUtil } from "@/utils/storage"; import { StorageKeys } from "../storage_keys"; -const PendingPaymentOrderSchema = z.object({ - orderId: z.string().min(1), - payChannel: z.literal("ezpay"), - subscriptionType: z.enum(["vip", "topup", "tip"]), - tipCoffeeType: z.enum(["small", "medium", "large"]).optional(), - returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(), - characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), - createdAt: z.number(), -}); +const PendingPaymentOrderSchema = z + .object({ + orderId: z.string().min(1), + payChannel: z.literal("ezpay"), + subscriptionType: z.enum(["vip", "topup", "tip"]), + giftCategory: z.string().min(1).nullable().default(null), + giftPlanId: z.string().min(1).nullable().default(null), + returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(), + characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), + createdAt: z.number(), + }) + .readonly(); export type PendingPaymentOrder = z.output; diff --git a/src/lib/payment/__tests__/pending_payment_order.test.ts b/src/lib/payment/__tests__/pending_payment_order.test.ts index 55dd9b5e..24f8f94e 100644 --- a/src/lib/payment/__tests__/pending_payment_order.test.ts +++ b/src/lib/payment/__tests__/pending_payment_order.test.ts @@ -88,26 +88,27 @@ describe("pending payment order helpers", () => { await clearPendingPaymentOrder(); }); - it("routes tip payments back to the tip page", () => { + it("routes tip payments back with dynamic category and plan id", () => { expect( buildPendingPaymentSubscriptionUrl({ payChannel: "ezpay", subscriptionType: "tip", - tipCoffeeType: "large", + giftCategory: "coffee", + giftPlanId: "tip_coffee_usd_19_99", }), ).toBe( - "/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large", + "/characters/elio/tip?category=coffee&planId=tip_coffee_usd_19_99&payChannel=ezpay&paymentReturn=1", ); }); - it("defaults legacy tip payment returns to medium coffee", () => { + it("lets legacy tip records fall back to the first backend product", () => { expect( buildPendingPaymentSubscriptionUrl({ payChannel: "ezpay", subscriptionType: "tip", }), ).toBe( - "/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium", + "/characters/elio/tip?payChannel=ezpay&paymentReturn=1", ); }); @@ -116,11 +117,12 @@ describe("pending payment order helpers", () => { buildPendingPaymentSubscriptionUrl({ payChannel: "ezpay", subscriptionType: "tip", - tipCoffeeType: "small", + giftCategory: "coffee", + giftPlanId: "tip_coffee_usd_4_99", characterSlug: "nayeli", }), ).toBe( - "/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small", + "/characters/nayeli/tip?category=coffee&planId=tip_coffee_usd_4_99&payChannel=ezpay&paymentReturn=1", ); }); }); diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index c77fc4b8..4e9cd13d 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -7,7 +7,6 @@ import { savePendingEzpayOrder, type PendingPaymentReturnTo, type PendingPaymentSubscriptionType, - type PendingPaymentTipCoffeeType, } from "./pending_payment_order"; const log = new Logger("LibPaymentPaymentLaunch"); @@ -63,7 +62,8 @@ export interface LaunchEzpayRedirectInput { orderId: string | null; paymentUrl: string; subscriptionType: PendingPaymentSubscriptionType; - tipCoffeeType?: PendingPaymentTipCoffeeType; + giftCategory?: string | null; + giftPlanId?: string | null; returnTo?: PendingPaymentReturnTo; characterSlug?: string; onOpened?: () => void; @@ -74,7 +74,8 @@ export async function launchEzpayRedirect({ orderId, paymentUrl, subscriptionType, - tipCoffeeType, + giftCategory, + giftPlanId, returnTo, characterSlug, onOpened, @@ -101,7 +102,8 @@ export async function launchEzpayRedirect({ const saveResult = await savePendingEzpayOrder({ orderId, subscriptionType, - ...(tipCoffeeType ? { tipCoffeeType } : {}), + giftCategory, + giftPlanId, ...(returnTo ? { returnTo } : {}), ...(characterSlug ? { characterSlug } : {}), }); diff --git a/src/lib/payment/pending_payment_order.ts b/src/lib/payment/pending_payment_order.ts index 89ec1967..97badded 100644 --- a/src/lib/payment/pending_payment_order.ts +++ b/src/lib/payment/pending_payment_order.ts @@ -9,23 +9,18 @@ import { getCharacterBySlug, } from "@/data/constants/character"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; -import { - DEFAULT_TIP_COFFEE_TYPE, - TIP_COFFEE_TYPE_PARAM, -} from "@/lib/tip/tip_coffee"; +import { buildTipGiftPath } from "@/lib/tip/tip_gift"; import type { Result } from "@/utils/result"; export type PendingPaymentSubscriptionType = PendingPaymentOrder["subscriptionType"]; export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"]; -export type PendingPaymentTipCoffeeType = NonNullable< - PendingPaymentOrder["tipCoffeeType"] ->; export function savePendingEzpayOrder(input: { orderId: string; subscriptionType: PendingPaymentSubscriptionType; - tipCoffeeType?: PendingPaymentTipCoffeeType; + giftCategory?: string | null; + giftPlanId?: string | null; returnTo?: PendingPaymentReturnTo; characterSlug?: string; createdAt?: number; @@ -34,7 +29,8 @@ export function savePendingEzpayOrder(input: { orderId: input.orderId, payChannel: "ezpay", subscriptionType: input.subscriptionType, - ...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}), + giftCategory: input.giftCategory ?? null, + giftPlanId: input.giftPlanId ?? null, ...(input.returnTo ? { returnTo: input.returnTo } : {}), ...(input.characterSlug ? { characterSlug: input.characterSlug } : {}), createdAt: input.createdAt ?? Date.now(), @@ -63,22 +59,24 @@ export function buildPendingPaymentSubscriptionUrl( | "payChannel" | "returnTo" | "subscriptionType" - | "tipCoffeeType" | "characterSlug" - >, + > & + Partial>, ): string { const characterSlug = getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG; const characterRoutes = getCharacterRoutes(characterSlug); if (order.subscriptionType === "tip") { - const params = new URLSearchParams({ - payChannel: order.payChannel, - paymentReturn: "1", - [TIP_COFFEE_TYPE_PARAM]: - order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE, - }); - return `${characterRoutes.tip}?${params.toString()}`; + const path = buildTipGiftPath( + { + category: order.giftCategory ?? null, + planId: order.giftPlanId ?? null, + }, + characterRoutes.tip, + ); + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}payChannel=${order.payChannel}&paymentReturn=1`; } const params = new URLSearchParams({ diff --git a/src/lib/tip/__tests__/tip_coffee.test.ts b/src/lib/tip/__tests__/tip_coffee.test.ts deleted file mode 100644 index 41b139d8..00000000 --- a/src/lib/tip/__tests__/tip_coffee.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildTipCoffeePath, - DEFAULT_TIP_COFFEE_TYPE, - getTipCoffeeOption, - resolveTipCoffeeType, - TIP_COFFEE_OPTIONS, -} from "../tip_coffee"; - -describe("tip coffee configuration", () => { - it("resolves supported types case-insensitively", () => { - expect(resolveTipCoffeeType(" Small ")).toBe("small"); - expect(resolveTipCoffeeType("MEDIUM")).toBe("medium"); - expect(resolveTipCoffeeType("large")).toBe("large"); - }); - - it("rejects missing and unsupported types", () => { - expect(resolveTipCoffeeType(null)).toBeNull(); - expect(resolveTipCoffeeType("espresso")).toBeNull(); - }); - - 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: "Gilded Heart", - image: { src: "/images/tip/medium.png", width: 1024, height: 1024 }, - planId: "tip_coffee_usd_9_99", - }); - expect(getTipCoffeeOption("large")).toMatchObject({ - amountCents: 1999, - displayName: "Crown Blossom", - 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"); - expect(buildTipCoffeePath("large")).toBe("/tip?coffee_type=large"); - }); -}); diff --git a/src/lib/tip/__tests__/tip_gift.test.ts b/src/lib/tip/__tests__/tip_gift.test.ts new file mode 100644 index 00000000..713b7ad5 --- /dev/null +++ b/src/lib/tip/__tests__/tip_gift.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { + buildTipGiftPath, + normalizeTipGiftParam, +} from "../tip_gift"; + +describe("tip gift navigation", () => { + it("normalizes optional gift query values", () => { + expect(normalizeTipGiftParam(" coffee ")).toBe("coffee"); + expect(normalizeTipGiftParam(" ")).toBeNull(); + expect(normalizeTipGiftParam(null)).toBeNull(); + }); + + it("builds canonical category and plan return paths", () => { + expect( + buildTipGiftPath({ category: "coffee", planId: "gift_1" }), + ).toBe("/tip?category=coffee&planId=gift_1"); + }); + + it("omits missing selection values without restoring coffee_type", () => { + expect(buildTipGiftPath({ category: null, planId: null })).toBe("/tip"); + }); +}); diff --git a/src/lib/tip/tip_coffee.ts b/src/lib/tip/tip_coffee.ts deleted file mode 100644 index 2b72453c..00000000 --- a/src/lib/tip/tip_coffee.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ROUTES } from "@/router/routes"; - -export const TIP_COFFEE_TYPE_PARAM = "coffee_type"; -export const DEFAULT_TIP_COFFEE_TYPE = "medium"; - -export type TipCoffeeType = "small" | "medium" | "large"; - -export interface TipCoffeeOption { - readonly type: TipCoffeeType; - readonly amountCents: number; - readonly displayName: string; - readonly image: { - readonly src: string; - readonly width: number; - readonly height: number; - }; - readonly planId: string; -} - -const TIP_COFFEE_OPTION_BY_TYPE: Record = { - small: { - type: "small", - amountCents: 499, - displayName: "Velvet Espresso", - image: { - src: "/images/tip/small.jpg", - width: 736, - height: 736, - }, - planId: "tip_coffee_usd_4_99", - }, - medium: { - type: "medium", - amountCents: 999, - displayName: "Gilded Heart", - image: { - src: "/images/tip/medium.png", - width: 1024, - height: 1024, - }, - planId: "tip_coffee_usd_9_99", - }, - large: { - type: "large", - amountCents: 1999, - displayName: "Crown Blossom", - 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 { - const normalized = value?.trim().toLowerCase(); - if ( - normalized === "small" || - normalized === "medium" || - normalized === "large" - ) { - return normalized; - } - return null; -} - -export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption { - return TIP_COFFEE_OPTION_BY_TYPE[type]; -} - -export function buildTipCoffeePath( - type: TipCoffeeType, - basePath: string = ROUTES.tip, -): string { - const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type }); - return `${basePath}?${params.toString()}`; -} diff --git a/src/lib/tip/tip_gift.ts b/src/lib/tip/tip_gift.ts new file mode 100644 index 00000000..91cf280c --- /dev/null +++ b/src/lib/tip/tip_gift.ts @@ -0,0 +1,31 @@ +import { ROUTES } from "@/router/routes"; + +export const TIP_GIFT_CATEGORY_PARAM = "category"; +export const TIP_GIFT_PLAN_ID_PARAM = "planId"; + +export interface TipGiftSelection { + category: string | null; + planId: string | null; +} + +export function normalizeTipGiftParam( + value: string | null | undefined, +): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +export function buildTipGiftPath( + selection: TipGiftSelection, + basePath: string = ROUTES.tip, +): string { + const params = new URLSearchParams(); + if (selection.category) { + params.set(TIP_GIFT_CATEGORY_PARAM, selection.category); + } + if (selection.planId) { + params.set(TIP_GIFT_PLAN_ID_PARAM, selection.planId); + } + const query = params.toString(); + return query ? `${basePath}?${query}` : basePath; +} diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts index 82c3df87..07193cfe 100644 --- a/src/stores/payment/__tests__/payment-catalog-flow.test.ts +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -8,10 +8,10 @@ import { import { createTestPaymentMachine, + giftCatalog, lifetimePlan, monthlyPlan, quarterlyPlan, - tipPlan, } from "./payment-machine.test-utils"; describe("payment catalog flow", () => { @@ -31,25 +31,76 @@ describe("payment catalog flow", () => { }); it("loads the tip catalog and disables auto renew", async () => { - const loadCatalog = vi.fn(); - const refreshCatalog = vi.fn(); + const loadGiftProducts = vi.fn(); const actor = createActor( createTestPaymentMachine({ - refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }), - onLoadCatalog: loadCatalog, - onRefreshCatalog: refreshCatalog, + onLoadGiftProducts: loadGiftProducts, }), ).start(); - actor.send({ type: "PaymentInit", catalog: "tip" }); + actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); - expect(loadCatalog).toHaveBeenCalledWith("tip"); - expect(refreshCatalog).toHaveBeenCalledWith("tip"); + expect(loadGiftProducts).toHaveBeenCalledWith("elio"); expect(actor.getSnapshot().context).toMatchObject({ planCatalog: "tip", - selectedPlanId: tipPlan.planId, + selectedGiftCategory: "coffee", + selectedPlanId: "tip_coffee_usd_4_99", autoRenew: false, }); + expect(actor.getSnapshot().context.plans).toHaveLength(2); + actor.stop(); + }); + + it("restores a valid first-category product and rejects another category", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + actor.send({ + type: "PaymentInit", + catalog: "tip", + characterId: "elio", + category: "coffee", + planId: "tip_coffee_usd_9_99", + }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + expect(actor.getSnapshot().context.selectedPlanId).toBe( + "tip_coffee_usd_9_99", + ); + + actor.send({ + type: "PaymentInit", + catalog: "tip", + characterId: "elio", + category: "flowers", + planId: "tip_flowers_usd_12_99", + }); + await waitFor( + actor, + (snapshot) => + snapshot.matches("ready") && + snapshot.context.requestedGiftPlanId === null, + ); + expect(actor.getSnapshot().context.selectedPlanId).toBe( + giftCatalog.plans[0]?.planId, + ); + actor.stop(); + }); + + it("clears gift prices after a catalog request fails and supports retry", async () => { + const actor = createActor( + createTestPaymentMachine({ + giftProductsError: new Error("catalog unavailable"), + }), + ).start(); + actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + expect(actor.getSnapshot().context).toMatchObject({ + plans: [], + giftProducts: [], + selectedPlanId: "", + errorMessage: "catalog unavailable", + }); + actor.send({ type: "PaymentCatalogRetryRequested" }); + await waitFor(actor, (snapshot) => snapshot.matches("loadingGiftProducts")); actor.stop(); }); diff --git a/src/stores/payment/__tests__/payment-machine.test-utils.ts b/src/stores/payment/__tests__/payment-machine.test-utils.ts index c18c1bcc..098cb238 100644 --- a/src/stores/payment/__tests__/payment-machine.test-utils.ts +++ b/src/stores/payment/__tests__/payment-machine.test-utils.ts @@ -4,10 +4,15 @@ import { fromPromise } from "xstate"; import { CreatePaymentOrderResponse, CreatePaymentOrderResponseSchema, + GiftProductsResponse, + GiftProductsResponseSchema, type PayChannel, + type PaymentOrderStatus, PaymentOrderStatusResponseSchema, PaymentPlansResponse, PaymentPlansResponseSchema, + TipMessageResponse, + TipMessageResponseSchema, } from "@/data/schemas/payment"; import { paymentMachine } from "@/stores/payment/payment-machine"; import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; @@ -77,9 +82,77 @@ export const quarterlyPlan = { currency: "usd", }; +export const giftCatalog = GiftProductsResponseSchema.parse({ + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 2, + imageUrl: null, + }, + { + category: "flowers", + name: "Flowers", + productCount: 1, + imageUrl: null, + }, + ], + plans: [ + { + planId: "tip_coffee_usd_4_99", + planName: "Velvet Espresso", + orderType: "tip", + tipType: "coffee_small", + category: "coffee", + characterId: "elio", + description: "A small coffee", + imageUrl: null, + amountCents: 499, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + { + planId: "tip_coffee_usd_9_99", + planName: "Golden Reserve", + orderType: "tip", + tipType: "coffee_medium", + category: "coffee", + characterId: "elio", + description: "A medium coffee", + imageUrl: null, + amountCents: 999, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + { + planId: "tip_flowers_usd_12_99", + planName: "Rose Bouquet", + orderType: "tip", + tipType: "flowers", + category: "flowers", + characterId: "elio", + description: "A bouquet", + imageUrl: null, + amountCents: 1299, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + ], +}); + export const tipPlan = { planId: "tip_coffee_usd_4_99", - planName: "Small Coffee", + planName: "Velvet Espresso", orderType: "tip", vipDays: null, dolAmount: null, @@ -90,21 +163,35 @@ export const tipPlan = { currency: "USD", }; +const defaultTipMessage = TipMessageResponseSchema.parse({ + orderId: "pay_test_001", + characterId: "elio", + planId: "tip_coffee_usd_4_99", + productName: "Velvet Espresso", + tipCount: 1, + poolIndex: 37, + message: "Thank you for the thoughtful gift.", +}); + export function createTestPaymentMachine( overrides: Partial<{ cachedPlans: PaymentPlansResponse | null; refreshedPlans: PaymentPlansResponse; refreshPlans: () => Promise; + giftProducts: GiftProductsResponse; + giftProductsError: Error; onLoadCatalog: (catalog: PaymentPlanCatalog) => void; onRefreshCatalog: (catalog: PaymentPlanCatalog) => void; + onLoadGiftProducts: (characterId: string) => void; createOrderSpy: CreateOrderSpy; createOrderError: Error; - orderStatus: "pending" | "paid" | "failed"; - orderStatuses: ("pending" | "paid" | "failed")[]; + orderStatus: PaymentOrderStatus; + orderStatuses: PaymentOrderStatus[]; orderType: string; - orderPlanId: string; - tipCount: number | null; - thankYouMessage: string | null; + orderPlanId: string | null; + tipMessage: TipMessageResponse; + tipMessageError: Error; + onLoadTipMessage: (orderId: string) => void; }> = {}, ) { const createOrderSpy = overrides.createOrderSpy ?? vi.fn(); @@ -133,6 +220,14 @@ export function createTestPaymentMachine( ); }, ), + loadGiftProducts: fromPromise< + GiftProductsResponse, + { characterId: string } + >(async ({ input }) => { + overrides.onLoadGiftProducts?.(input.characterId); + if (overrides.giftProductsError) throw overrides.giftProductsError; + return overrides.giftProducts ?? giftCatalog; + }), createOrder: fromPromise( async ({ input }) => { createOrderSpy(input); @@ -156,10 +251,16 @@ export function createTestPaymentMachine( status, orderType: overrides.orderType ?? "vip_monthly", planId: overrides.orderPlanId ?? "vip_monthly", - tipCount: overrides.tipCount ?? null, - thankYouMessage: overrides.thankYouMessage ?? null, + creditsAdded: 0, }); }), + loadTipMessage: fromPromise( + async ({ input }) => { + overrides.onLoadTipMessage?.(input.orderId); + if (overrides.tipMessageError) throw overrides.tipMessageError; + return overrides.tipMessage ?? defaultTipMessage; + }, + ), }, }); } diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index 33062842..e8bd325f 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -65,8 +65,8 @@ describe("payment order flow", () => { expect(actor.getSnapshot().context).toMatchObject({ currentOrderId: "pay_test_001", orderStatus: "paid", - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: null, launchNonce: 1, }); @@ -81,33 +81,46 @@ describe("payment order flow", () => { actor.stop(); }); - it("stores a stable Tip success result and clears it on reset", async () => { + it("loads a stable Tip message after payment and clears it on reset", async () => { + const loadTipMessage = vi.fn(); const actor = createActor( createTestPaymentMachine({ orderType: "tip", orderPlanId: "tip_coffee_usd_9_99", - tipCount: 2, - thankYouMessage: "You made my day.", + onLoadTipMessage: loadTipMessage, + tipMessage: { + orderId: "pay_test_001", + characterId: "elio", + planId: "tip_coffee_usd_9_99", + productName: "Golden Reserve", + tipCount: 2, + poolIndex: 18, + message: "You made my day.", + }, }), ).start(); - await initialize(actor); + await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted", - recipientCharacterId: "maya-tan", + recipientCharacterId: "elio", }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); + expect(loadTipMessage).toHaveBeenCalledWith("pay_test_001"); expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "paid", - tipCount: 2, - thankYouMessage: "You made my day.", + tipMessage: expect.objectContaining({ + tipCount: 2, + message: "You made my day.", + }), + tipMessageError: null, }); actor.send({ type: "PaymentReset" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); expect(actor.getSnapshot().context).toMatchObject({ - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, }); actor.stop(); }); @@ -116,19 +129,18 @@ describe("payment order flow", () => { const actor = createActor( createTestPaymentMachine({ orderType: "tip", - tipCount: 3, - thankYouMessage: "Another warm coffee.", + orderPlanId: "tip_coffee_usd_4_99", }), ).start(); - await initialize(actor); + await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); actor.send({ type: "PaymentCreateOrderSubmitted" }); expect(actor.getSnapshot().matches("creatingOrder")).toBe(true); expect(actor.getSnapshot().context).toMatchObject({ - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, }); actor.stop(); }); @@ -137,11 +149,10 @@ describe("payment order flow", () => { const actor = createActor( createTestPaymentMachine({ orderType: "tip", - tipCount: 4, - thankYouMessage: "That was so thoughtful.", + orderPlanId: "tip_coffee_usd_4_99", }), ).start(); - await initialize(actor); + await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); @@ -149,8 +160,8 @@ describe("payment order flow", () => { expect(actor.getSnapshot().matches("ready")).toBe(true); expect(actor.getSnapshot().context).toMatchObject({ payChannel: "ezpay", - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, }); actor.stop(); }); @@ -211,8 +222,8 @@ describe("payment order flow", () => { currentOrderId: null, payParams: null, orderStatus: null, - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: null, errorMessage: null, }); @@ -235,6 +246,48 @@ describe("payment order flow", () => { actor.stop(); }); + it("moves expired orders to a dedicated terminal state", async () => { + const actor = createActor( + createTestPaymentMachine({ orderStatus: "expired" }), + ).start(); + await initialize(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("expired")); + + expect(actor.getSnapshot().context).toMatchObject({ + orderStatus: "expired", + payParams: null, + errorMessage: + "This payment order has expired. Please create a new order.", + }); + actor.stop(); + }); + + it("keeps paid state when the Tip message fails and retries only the message", async () => { + const loadTipMessage = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ + orderType: "tip", + orderPlanId: "tip_coffee_usd_4_99", + tipMessageError: new Error("message unavailable"), + onLoadTipMessage: loadTipMessage, + }), + ).start(); + await initializeTip(actor); + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("tipMessageFailed")); + + expect(actor.getSnapshot().context).toMatchObject({ + orderStatus: "paid", + tipMessage: null, + tipMessageError: "message unavailable", + }); + actor.send({ type: "PaymentTipMessageRetryRequested" }); + await waitFor(actor, () => loadTipMessage.mock.calls.length === 2); + expect(actor.getSnapshot().context.orderStatus).toBe("paid"); + actor.stop(); + }); + it("times out an old pending order", async () => { const statusPollTimeout = vi .spyOn(behaviorAnalytics, "statusPollTimeout") @@ -336,3 +389,10 @@ async function initialize( actor.send({ type: "PaymentInit" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); } + +async function initializeTip( + actor: ReturnType>>, +): Promise { + actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); +} diff --git a/src/stores/payment/helper/gift.ts b/src/stores/payment/helper/gift.ts new file mode 100644 index 00000000..c880ab9c --- /dev/null +++ b/src/stores/payment/helper/gift.ts @@ -0,0 +1,88 @@ +import { + PaymentPlanSchema, + type GiftProduct, + type GiftProductsResponse, + type PaymentPlan, +} from "@/data/schemas/payment"; + +import type { PaymentState } from "../payment-state"; + +export function giftProductToPaymentPlan( + product: GiftProduct, +): PaymentPlan { + return PaymentPlanSchema.parse({ + planId: product.planId, + planName: product.planName, + orderType: product.orderType, + vipDays: null, + dolAmount: null, + creditBalance: 0, + amountCents: product.amountCents, + originalAmountCents: null, + dailyPriceCents: null, + currency: product.currency, + isFirstRechargeOffer: false, + mostPopular: false, + firstRechargeDiscountPercent: null, + promotionType: product.promotionType, + }); +} + +export function hydrateGiftProductsState( + response: GiftProductsResponse, + characterId: string, + restoredCategory: string | null, + restoredPlanId: string | null, +): Pick< + PaymentState, + | "plans" + | "giftCategories" + | "giftProducts" + | "selectedGiftCategory" + | "selectedPlanId" + | "requestedGiftCategory" + | "requestedGiftPlanId" + | "autoRenew" + | "isFirstRecharge" + | "errorMessage" +> { + const giftCategories = response.categories; + const giftProducts = response.plans.filter( + (product) => product.characterId === characterId, + ); + const selectedGiftCategory = giftCategories[0]?.category ?? null; + const visibleProducts = selectedGiftCategory + ? giftProducts.filter( + (product) => product.category === selectedGiftCategory, + ) + : []; + const canRestoreSelection = + restoredCategory === selectedGiftCategory && + visibleProducts.some((product) => product.planId === restoredPlanId); + const selectedPlanId = canRestoreSelection + ? (restoredPlanId ?? "") + : (visibleProducts[0]?.planId ?? ""); + + return { + plans: visibleProducts.map(giftProductToPaymentPlan), + giftCategories, + giftProducts, + selectedGiftCategory, + selectedPlanId, + requestedGiftCategory: null, + requestedGiftPlanId: null, + autoRenew: false, + isFirstRecharge: false, + errorMessage: null, + }; +} + +export function getSelectedGiftProduct( + context: Pick, +): GiftProduct | null { + return ( + context.giftProducts.find( + (product) => product.planId === context.selectedPlanId, + ) ?? null + ); +} diff --git a/src/stores/payment/helper/index.ts b/src/stores/payment/helper/index.ts index 265e7c06..174a5af0 100644 --- a/src/stores/payment/helper/index.ts +++ b/src/stores/payment/helper/index.ts @@ -1,2 +1,3 @@ export * from "./catalog"; +export * from "./gift"; export * from "./order"; diff --git a/src/stores/payment/helper/order.ts b/src/stores/payment/helper/order.ts index edad320f..d486a128 100644 --- a/src/stores/payment/helper/order.ts +++ b/src/stores/payment/helper/order.ts @@ -25,8 +25,8 @@ export function resetOrderState(): Partial { currentOrderPlanId: null, payParams: null, orderStatus: null, - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: null, errorMessage: null, }; diff --git a/src/stores/payment/machine/actors/gifts.ts b/src/stores/payment/machine/actors/gifts.ts new file mode 100644 index 00000000..48079a0a --- /dev/null +++ b/src/stores/payment/machine/actors/gifts.ts @@ -0,0 +1,30 @@ +import { fromPromise } from "xstate"; + +import type { + GiftProductsResponse, + TipMessageResponse, +} from "@/data/schemas/payment"; +import { getPaymentRepository } from "@/data/repositories/payment_repository"; +import { Result } from "@/utils/result"; + +export const loadGiftProductsActor = fromPromise< + GiftProductsResponse, + { characterId: string } +>(async ({ input }) => { + if (!input.characterId) throw new Error("Missing gift recipient character."); + const result = await getPaymentRepository().getGiftProducts( + input.characterId, + ); + if (Result.isErr(result)) throw result.error; + return result.data; +}); + +export const loadTipMessageActor = fromPromise< + TipMessageResponse, + { orderId: string } +>(async ({ input }) => { + if (!input.orderId) throw new Error("Missing paid Tip order id."); + const result = await getPaymentRepository().getTipMessage(input.orderId); + if (Result.isErr(result)) throw result.error; + return result.data; +}); diff --git a/src/stores/payment/machine/actors/plans.ts b/src/stores/payment/machine/actors/plans.ts index fc2e8200..191bd062 100644 --- a/src/stores/payment/machine/actors/plans.ts +++ b/src/stores/payment/machine/actors/plans.ts @@ -21,10 +21,10 @@ export const refreshPaymentPlansActor = fromPromise< { catalog: PaymentPlanCatalog } >(async ({ input }) => { const paymentRepo = getPaymentRepository(); - const result = - input.catalog === "tip" - ? await paymentRepo.getTipPlans() - : await paymentRepo.getPlans(); + if (input.catalog === "tip") { + throw new Error("Gift catalogs must use the gift products actor."); + } + const result = await paymentRepo.getPlans(); if (Result.isErr(result)) throw result.error; return result.data; }); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index 146921fc..fadbb95c 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -1,50 +1,76 @@ import type { DoneActorEvent, ErrorActorEvent } from "xstate"; -import type { PaymentPlansResponse } from "@/data/schemas/payment"; +import type { + GiftProductsResponse, + PaymentPlansResponse, +} from "@/data/schemas/payment"; import { consumeFirstRechargeState, + hydrateGiftProductsState, hydratePlansResponseState, refreshPlansResponseState, selectPlanState, -} from "../helper/catalog"; -import { - resetOrderState, - toPaymentErrorMessage, -} from "../helper/order"; +} from "../helper"; +import { resetOrderState, toPaymentErrorMessage } from "../helper/order"; +import type { PaymentEvent } from "../payment-events"; +import type { PaymentState } from "../payment-state"; import { basePaymentMachineSetup, createPaymentActorActionSetup, } from "./setup"; +function initializeCatalogState( + context: PaymentState, + event: Extract, +): Partial { + const planCatalog = event.catalog ?? context.planCatalog; + const giftCharacterId = + planCatalog === "tip" + ? (event.characterId ?? context.giftCharacterId) + : null; + const catalogChanged = planCatalog !== context.planCatalog; + const characterChanged = giftCharacterId !== context.giftCharacterId; + const selectionChanged = + planCatalog === "tip" && + ((event.category ?? null) !== context.selectedGiftCategory || + (event.planId ?? null) !== (context.selectedPlanId || null)); + + return { + planCatalog, + ...(event.payChannel ? { payChannel: event.payChannel } : {}), + giftCharacterId, + requestedGiftCategory: + planCatalog === "tip" ? (event.category ?? null) : null, + requestedGiftPlanId: + planCatalog === "tip" ? (event.planId ?? null) : null, + ...(catalogChanged || characterChanged || selectionChanged + ? { + ...resetOrderState(), + plans: [], + giftCategories: [], + giftProducts: [], + selectedGiftCategory: null, + selectedPlanId: "", + isFirstRecharge: false, + autoRenew: planCatalog !== "tip", + } + : {}), + }; +} + const initializeCatalogAction = basePaymentMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "PaymentInit") return {}; - return { - planCatalog: event.catalog ?? context.planCatalog, - ...(event.payChannel ? { payChannel: event.payChannel } : {}), - }; - }, + ({ context, event }) => + event.type === "PaymentInit" + ? initializeCatalogState(context, event) + : {}, ); const refreshCatalogAction = basePaymentMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "PaymentInit") return {}; - const planCatalog = event.catalog ?? context.planCatalog; - const catalogChanged = planCatalog !== context.planCatalog; - return { - planCatalog, - ...(event.payChannel ? { payChannel: event.payChannel } : {}), - ...(catalogChanged - ? { - plans: [], - selectedPlanId: "", - isFirstRecharge: false, - autoRenew: planCatalog !== "tip", - } - : {}), - }; - }, + ({ context, event }) => + event.type === "PaymentInit" + ? initializeCatalogState(context, event) + : {}, ); const selectPlanAction = basePaymentMachineSetup.assign( @@ -105,6 +131,12 @@ export const catalogMachineSetup = basePaymentMachineSetup.extend({ resetOrder: resetOrderAction, consumeFirstRecharge: consumeFirstRechargeAction, }, + guards: { + isTipInit: ({ context, event }) => + event.type === "PaymentInit" && + (event.catalog ?? context.planCatalog) === "tip", + isTipCatalog: ({ context }) => context.planCatalog === "tip", + }, }); const cachedPlansDoneSetup = @@ -113,6 +145,8 @@ const cachedPlansDoneSetup = >(); const plansDoneSetup = createPaymentActorActionSetup>(); +const giftProductsDoneSetup = + createPaymentActorActionSetup>(); const plansErrorSetup = createPaymentActorActionSetup(); const hydrateCachedPlansAction = cachedPlansDoneSetup.assign( @@ -130,16 +164,42 @@ const refreshPlansAction = plansDoneSetup.assign(({ context, event }) => refreshPlansResponseState(event.output, context.selectedPlanId), ); +const hydrateGiftProductsAction = giftProductsDoneSetup.assign( + ({ context, event }) => + hydrateGiftProductsState( + event.output, + context.giftCharacterId ?? "", + context.requestedGiftCategory ?? context.selectedGiftCategory, + context.requestedGiftPlanId ?? context.selectedPlanId, + ), +); + const applyPlansErrorAction = plansErrorSetup.assign(({ event }) => ({ errorMessage: toPaymentErrorMessage(event.error), })); +const applyGiftProductsErrorAction = plansErrorSetup.assign(({ event }) => ({ + plans: [], + giftCategories: [], + giftProducts: [], + selectedGiftCategory: null, + selectedPlanId: "", + errorMessage: toPaymentErrorMessage(event.error), +})); + export const idleState = catalogMachineSetup.createStateConfig({ on: { - PaymentInit: { - target: "loadingCachedPlans", - actions: "initializeCatalog", - }, + PaymentInit: [ + { + guard: "isTipInit", + target: "loadingGiftProducts", + actions: "initializeCatalog", + }, + { + target: "loadingCachedPlans", + actions: "initializeCatalog", + }, + ], }, }); @@ -190,12 +250,44 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({ }, }); +export const loadingGiftProductsState = + catalogMachineSetup.createStateConfig({ + invoke: { + src: "loadGiftProducts", + input: ({ context }) => ({ + characterId: context.giftCharacterId ?? "", + }), + onDone: { + target: "ready", + actions: hydrateGiftProductsAction, + }, + onError: { + target: "ready", + actions: applyGiftProductsErrorAction, + }, + }, + }); + export const readyState = catalogMachineSetup.createStateConfig({ on: { - PaymentInit: { - target: "refreshingPlans", - actions: "refreshCatalog", - }, + PaymentInit: [ + { + guard: "isTipInit", + target: "loadingGiftProducts", + actions: "refreshCatalog", + }, + { + target: "refreshingPlans", + actions: "refreshCatalog", + }, + ], + PaymentCatalogRetryRequested: [ + { + guard: "isTipCatalog", + target: "loadingGiftProducts", + }, + { target: "refreshingPlans" }, + ], PaymentPlanSelected: { actions: "selectPlan" }, PaymentPayChannelChanged: { actions: "changePayChannel" }, PaymentAutoRenewChanged: { actions: "changeAutoRenew" }, diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index 820aa3d6..98f5931e 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -3,6 +3,7 @@ import type { DoneActorEvent, ErrorActorEvent } from "xstate"; import type { CreatePaymentOrderResponse, PaymentOrderStatusResponse, + TipMessageResponse, } from "@/data/schemas/payment"; import { behaviorAnalytics } from "@/lib/analytics"; @@ -18,6 +19,7 @@ import { idleState, loadingCachedPlansState, loadingPlansState, + loadingGiftProductsState, readyState, refreshingPlansState, } from "./catalog-flow"; @@ -47,8 +49,8 @@ const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => { currentOrderPlanId: null, payParams: null, orderStatus: "pending", - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: event.createdAt ?? Date.now(), errorMessage: null, }; @@ -77,6 +79,8 @@ const pollOrderDoneSetup = createPaymentActorActionSetup< DoneActorEvent >(); +const tipMessageDoneSetup = + createPaymentActorActionSetup>(); const orderErrorSetup = createPaymentActorActionSetup(); const trackCreateOrderSuccessAction = createOrderDoneSetup.createAction( @@ -99,8 +103,8 @@ const applyCreateOrderSuccessAction = createOrderDoneSetup.assign( currentOrderPlanId: context.selectedPlanId, payParams: event.output.payParams, orderStatus: "pending", - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: Date.now(), errorMessage: null, launchNonce: context.launchNonce + 1, @@ -138,8 +142,8 @@ const trackPollTimeoutAction = pollOrderDoneSetup.createAction( const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ orderStatus: event.output.status, - tipCount: event.output.tipCount, - thankYouMessage: event.output.thankYouMessage, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: null, errorMessage: null, })); @@ -150,6 +154,13 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ errorMessage: "Payment failed or was cancelled.", })); +const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ + orderStatus: event.output.status, + payParams: null, + orderPollingStartedAt: null, + errorMessage: "This payment order has expired. Please create a new order.", +})); + const applyTimedOutOrderAction = pollOrderDoneSetup.assign({ orderStatus: "failed", orderPollingStartedAt: null, @@ -161,6 +172,18 @@ const applyPendingOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ errorMessage: null, })); +const applyTipMessageSuccessAction = tipMessageDoneSetup.assign( + ({ event }) => ({ + tipMessage: event.output, + tipMessageError: null, + }), +); + +const applyTipMessageErrorAction = orderErrorSetup.assign(({ event }) => ({ + tipMessage: null, + tipMessageError: toPaymentErrorMessage(event.error), +})); + const creatingOrderState = paymentMachineSetup.createStateConfig({ entry: "trackCreateOrderStart", invoke: { @@ -193,11 +216,22 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({ src: "pollOrderStatus", input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }), onDone: [ + { + guard: ({ context, event }) => + event.output.status === "paid" && context.planCatalog === "tip", + target: "loadingTipMessage", + actions: [trackPollUpdateAction, applyPaidOrderAction], + }, { guard: ({ event }) => event.output.status === "paid", target: "paid", actions: [trackPollUpdateAction, applyPaidOrderAction], }, + { + guard: ({ event }) => event.output.status === "expired", + target: "expired", + actions: [trackPollUpdateAction, applyExpiredOrderAction], + }, { guard: ({ event }) => event.output.status === "failed", target: "failed", @@ -239,6 +273,45 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({ }, }); +const loadingTipMessageState = paymentMachineSetup.createStateConfig({ + invoke: { + src: "loadTipMessage", + input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }), + onDone: { + target: "paid", + actions: applyTipMessageSuccessAction, + }, + onError: { + target: "tipMessageFailed", + actions: applyTipMessageErrorAction, + }, + }, + on: { + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + +const tipMessageFailedState = paymentMachineSetup.createStateConfig({ + on: { + PaymentTipMessageRetryRequested: { target: "loadingTipMessage" }, + PaymentPlanSelected: { + target: "ready", + actions: "selectPlan", + }, + PaymentPayChannelChanged: { + target: "ready", + actions: "changePayChannel", + }, + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + const paidState = paymentMachineSetup.createStateConfig({ on: { PaymentPlanSelected: { @@ -269,6 +342,42 @@ const paidState = paymentMachineSetup.createStateConfig({ const failedState = paymentMachineSetup.createStateConfig({ on: { + PaymentPlanSelected: { + target: "ready", + actions: "selectPlan", + }, + PaymentPayChannelChanged: { + target: "ready", + actions: "changePayChannel", + }, + PaymentCreateOrderSubmitted: [ + { + guard: "canCreateOrder", + target: "creatingOrder", + actions: "resetOrder", + }, + ], + PaymentErrorCleared: { + target: "ready", + actions: "clearPaymentError", + }, + PaymentReset: { + target: "ready", + actions: "resetOrder", + }, + }, +}); + +const expiredState = paymentMachineSetup.createStateConfig({ + on: { + PaymentPlanSelected: { + target: "ready", + actions: "selectPlan", + }, + PaymentPayChannelChanged: { + target: "ready", + actions: "changePayChannel", + }, PaymentCreateOrderSubmitted: [ { guard: "canCreateOrder", @@ -293,15 +402,30 @@ export const paymentRootStateConfig = paymentMachineSetup.createStateConfig({ idle: idleState, loadingCachedPlans: loadingCachedPlansState, loadingPlans: loadingPlansState, + loadingGiftProducts: loadingGiftProductsState, refreshingPlans: refreshingPlansState, ready: readyState, creatingOrder: creatingOrderState, pollingOrder: pollingOrderState, waitingForPayment: waitingForPaymentState, + loadingTipMessage: loadingTipMessageState, + tipMessageFailed: tipMessageFailedState, paid: paidState, failed: failedState, + expired: expiredState, }, on: { + PaymentInit: [ + { + guard: "isTipInit", + target: ".loadingGiftProducts", + actions: "refreshCatalog", + }, + { + target: ".refreshingPlans", + actions: "refreshCatalog", + }, + ], PaymentFirstRechargeConsumed: { actions: "consumeFirstRecharge" }, PaymentReturned: { target: ".pollingOrder", diff --git a/src/stores/payment/machine/setup.ts b/src/stores/payment/machine/setup.ts index 4bfbe834..c7eaaf59 100644 --- a/src/stores/payment/machine/setup.ts +++ b/src/stores/payment/machine/setup.ts @@ -6,6 +6,7 @@ import { createPaymentOrderActor, pollPaymentOrderStatusActor, } from "./actors/order"; +import { loadGiftProductsActor, loadTipMessageActor } from "./actors/gifts"; import { loadCachedPaymentPlansActor, refreshPaymentPlansActor, @@ -19,6 +20,8 @@ export const basePaymentMachineSetup = setup({ actors: { loadCachedPlans: loadCachedPaymentPlansActor, refreshPlans: refreshPaymentPlansActor, + loadGiftProducts: loadGiftProductsActor, + loadTipMessage: loadTipMessageActor, createOrder: createPaymentOrderActor, pollOrderStatus: pollPaymentOrderStatusActor, }, diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 9dc8fda9..5c55becb 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -13,7 +13,11 @@ import type { export interface PaymentContextState { status: string; + planCatalog: MachineContext["planCatalog"]; plans: MachineContext["plans"]; + giftCategories: MachineContext["giftCategories"]; + giftProducts: MachineContext["giftProducts"]; + selectedGiftCategory: string | null; isFirstRecharge: MachineContext["isFirstRecharge"]; selectedPlanId: string; payChannel: MachineContext["payChannel"]; @@ -22,11 +26,12 @@ export interface PaymentContextState { currentOrderId: string | null; payParams: Record | null; orderStatus: MachineContext["orderStatus"]; - tipCount: number | null; - thankYouMessage: string | null; + tipMessage: MachineContext["tipMessage"]; + tipMessageError: string | null; errorMessage: string | null; launchNonce: number; isLoadingPlans: boolean; + isLoadingTipMessage: boolean; isCreatingOrder: boolean; isPollingOrder: boolean; isPaid: boolean; @@ -63,7 +68,11 @@ export function usePaymentSelector( function selectPaymentState(state: PaymentSnapshot): PaymentContextState { return { status: String(state.value), + planCatalog: state.context.planCatalog, plans: state.context.plans, + giftCategories: state.context.giftCategories, + giftProducts: state.context.giftProducts, + selectedGiftCategory: state.context.selectedGiftCategory, isFirstRecharge: state.context.isFirstRecharge, selectedPlanId: state.context.selectedPlanId, payChannel: state.context.payChannel, @@ -72,17 +81,19 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState { currentOrderId: state.context.currentOrderId, payParams: state.context.payParams, orderStatus: state.context.orderStatus, - tipCount: state.context.tipCount, - thankYouMessage: state.context.thankYouMessage, + tipMessage: state.context.tipMessage, + tipMessageError: state.context.tipMessageError, errorMessage: state.context.errorMessage, launchNonce: state.context.launchNonce, isLoadingPlans: state.matches("loadingCachedPlans") || state.matches("loadingPlans") || + state.matches("loadingGiftProducts") || state.matches("refreshingPlans"), + isLoadingTipMessage: state.matches("loadingTipMessage"), isCreatingOrder: state.matches("creatingOrder"), isPollingOrder: state.matches("pollingOrder") || state.matches("waitingForPayment"), - isPaid: state.matches("paid"), + isPaid: state.context.orderStatus === "paid", }; } diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 02910979..028920d0 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -7,6 +7,9 @@ export type PaymentEvent = type: "PaymentInit"; payChannel?: PayChannel; catalog?: PaymentPlanCatalog; + characterId?: string; + category?: string | null; + planId?: string | null; } | { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel } @@ -19,5 +22,7 @@ export type PaymentEvent = | { type: "PaymentReturned"; orderId: string; createdAt?: number } | { type: "PaymentLaunchFailed"; errorMessage: string } | { type: "PaymentFirstRechargeConsumed" } + | { type: "PaymentCatalogRetryRequested" } + | { type: "PaymentTipMessageRetryRequested" } | { type: "PaymentErrorCleared" } | { type: "PaymentReset" }; diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index 56cc177d..f2c4f4aa 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -1,8 +1,11 @@ /** Context shared by the default and Tip payment catalogs. */ import type { + GiftCategory, + GiftProduct, PayChannel, PaymentOrderStatus, PaymentPlan, + TipMessageResponse, } from "@/data/schemas/payment"; export type PaymentPlanCatalog = "default" | "tip"; @@ -10,6 +13,12 @@ export type PaymentPlanCatalog = "default" | "tip"; export interface PaymentState { planCatalog: PaymentPlanCatalog; plans: readonly PaymentPlan[]; + giftCategories: readonly GiftCategory[]; + giftProducts: readonly GiftProduct[]; + giftCharacterId: string | null; + selectedGiftCategory: string | null; + requestedGiftCategory: string | null; + requestedGiftPlanId: string | null; isFirstRecharge: boolean; selectedPlanId: string; payChannel: PayChannel; @@ -19,8 +28,8 @@ export interface PaymentState { currentOrderPlanId: string | null; payParams: Record | null; orderStatus: PaymentOrderStatus | null; - tipCount: number | null; - thankYouMessage: string | null; + tipMessage: TipMessageResponse | null; + tipMessageError: string | null; orderPollingStartedAt: number | null; errorMessage: string | null; launchNonce: number; @@ -29,6 +38,12 @@ export interface PaymentState { export const initialState: PaymentState = { planCatalog: "default", plans: [], + giftCategories: [], + giftProducts: [], + giftCharacterId: null, + selectedGiftCategory: null, + requestedGiftCategory: null, + requestedGiftPlanId: null, isFirstRecharge: false, selectedPlanId: "", payChannel: "stripe", @@ -38,8 +53,8 @@ export const initialState: PaymentState = { currentOrderPlanId: null, payParams: null, orderStatus: null, - tipCount: null, - thankYouMessage: null, + tipMessage: null, + tipMessageError: null, orderPollingStartedAt: null, errorMessage: null, launchNonce: 0,