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 (
-
- );
-}
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 (
+
+ );
+}
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
+
{
+ 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({
-
-
-
+
+
+
+
+
+
+
+ ) : selectedProduct ? (
+
+
+
+
+
+
+
+
+ {selectedCategory?.name ?? "Gift"}
+
+
{selectedProduct.planName}
+
+ {formatGiftPrice(
+ selectedProduct.amountCents,
+ selectedProduct.currency,
+ )}
+
+ {selectedProduct.description ? (
+
+ {selectedProduct.description}
+
+ ) : null}
+
+
+
-
+
+ ) : null}
-
-
-
- Coffee Gift
-
-
- {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({
/>
-
@@ -89,8 +93,8 @@ export function TipSuccessView({
-
- Coffee received
+
+ Gift received
- {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,