From edf50e9cc430eec601872603f0f2adbcc65d1291 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 20 Jul 2026 19:06:42 +0800 Subject: [PATCH] feat(tip): add personalized payment success experience --- .../FRONTEND_TIP_PAYMENT_SUCCESS_API.md | 126 +++++++ .../__tests__/tip-screen.checkout.test.tsx | 80 ++++- .../tip/__tests__/tip-screen.helpers.test.ts | 32 ++ src/app/tip/tip-screen.helpers.ts | 48 +++ src/app/tip/tip-screen.module.css | 52 +-- src/app/tip/tip-screen.tsx | 37 +- src/app/tip/tip-success-view.module.css | 315 ++++++++++++++++++ src/app/tip/tip-success-view.tsx | 132 ++++++++ .../payment/__tests__/payment_plan.test.ts | 51 +++ .../response/payment_order_status_response.ts | 19 ++ .../__tests__/payment-machine.test-utils.ts | 10 +- .../__tests__/payment-order-flow.test.ts | 78 +++++ src/stores/payment/helper/order.ts | 2 + src/stores/payment/machine/order-flow.ts | 6 + src/stores/payment/payment-context.tsx | 4 + src/stores/payment/payment-state.ts | 4 + 16 files changed, 922 insertions(+), 74 deletions(-) create mode 100644 docs/backend/FRONTEND_TIP_PAYMENT_SUCCESS_API.md create mode 100644 src/app/tip/tip-success-view.module.css create mode 100644 src/app/tip/tip-success-view.tsx diff --git a/docs/backend/FRONTEND_TIP_PAYMENT_SUCCESS_API.md b/docs/backend/FRONTEND_TIP_PAYMENT_SUCCESS_API.md new file mode 100644 index 00000000..cebf9efa --- /dev/null +++ b/docs/backend/FRONTEND_TIP_PAYMENT_SUCCESS_API.md @@ -0,0 +1,126 @@ +# Tip 支付成功结果 API 接口定义 + +## 1. 文档状态 + +本文档定义咖啡打赏支付成功后,前端展示累计打赏次数和角色感谢语所需的接口扩展,供后端实现和前后端联调使用。 + +本次不新增 Endpoint,仅扩展现有订单状态接口: + +```http +GET /api/payment/order-status?order_id= +``` + +## 2. 身份与统计口径 + +订单状态查询保持当前认证规则。前端存在 Token 时继续发送 Bearer Token,没有 Token 时不增加 `Authorization` Header。 + +`tipCount` 表示包含当前订单在内,同一付款身份向同一 `recipientCharacterId` 成功打赏的累计次数: + +| 付款身份 | 统计方式 | +| --- | --- | +| 正式用户 Login Token | 按用户 ID 与收款角色 ID 统计 | +| 游客 Guest Token | 按游客 ID 与收款角色 ID 统计 | +| 无 Token 匿名用户 | 无法可靠跨订单识别,当前订单固定返回 `1` | + +只有最终支付成功的 Tip 订单计入次数。pending、failed 或取消订单不增加次数。 + +## 3. 请求定义 + +### 3.1 请求地址 + +```http +GET /api/payment/order-status?order_id= +``` + +### 3.2 Query 参数 + +| 字段 | 类型 | 是否必填 | 说明 | +| --- | --- | --- | --- | +| `order_id` | string | 是 | 创建支付订单接口返回的订单 ID。 | + +### 3.3 请求示例 + +```bash +curl 'https://api.banlv-ai.com/api/payment/order-status?order_id=tip_order_123' \ + -H 'Authorization: Bearer ' +``` + +匿名 Tip 订单不发送 Authorization Header: + +```bash +curl 'https://api.banlv-ai.com/api/payment/order-status?order_id=tip_order_123' +``` + +## 4. 响应定义 + +### 4.1 paid Tip 订单 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "orderId": "tip_order_123", + "status": "paid", + "orderType": "tip", + "planId": "tip_coffee_usd_9_99", + "tipCount": 2, + "thankYouMessage": "You always know how to make my day a little sweeter." + } +} +``` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `orderId` | string | 当前订单 ID。 | +| `status` | `pending \| paid \| failed` | 当前支付状态。 | +| `orderType` | string | Tip 订单固定为 `tip`。 | +| `planId` | string | 当前订单套餐 ID。 | +| `tipCount` | integer \| null | 当前身份向当前角色累计成功打赏次数,最小值为 `1`。 | +| `thankYouMessage` | string \| null | 收款角色配置的纯文本感谢语。 | + +`thankYouMessage` 不得包含 HTML。后端可以返回换行,前端会按纯文本保留展示。 + +### 4.2 pending、failed 和非 Tip 订单 + +非 paid Tip 订单不生成打赏成功结果,两个扩展字段必须返回 `null`: + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "orderId": "pay_order_456", + "status": "pending", + "orderType": "vip_monthly", + "planId": "vip_monthly", + "tipCount": null, + "thankYouMessage": null + } +} +``` + +角色感谢语配置缺失时,后端允许 paid Tip 响应中的 `thankYouMessage` 为 `null`,前端会展示本地通用感谢文案。 + +## 5. 幂等与一致性 + +后端在订单首次从非 paid 状态转换为 paid 时,必须原子完成以下操作: + +1. 标记支付订单成功; +2. 将当前 Tip 计入付款身份和收款角色的累计次数; +3. 保存当前订单对应的 `tipCount`; +4. 从 `recipientCharacterId` 对应的角色配置读取并保存 `thankYouMessage`。 + +同一支付回调重复执行时不得重复累计。对同一个 `order_id` 重复查询必须返回相同的 `tipCount` 和 `thankYouMessage`,后续新订单不能改变旧订单的成功结果。 + +## 6. 验收标准 + +1. 正式用户和 Guest 对不同角色的累计次数相互独立。 +2. 无 Token 匿名用户的成功 Tip 订单返回 `tipCount=1`。 +3. 首次成功返回 `tipCount=1`,第二次成功返回 `tipCount=2`。 +4. pending、failed 和非 Tip 订单返回两个 `null` 扩展字段。 +5. 同一支付回调重试不会重复增加次数。 +6. 同一订单重复轮询得到稳定的次数和感谢语。 +7. 感谢语为角色配置的纯文本,不返回 HTML。 diff --git a/src/app/tip/__tests__/tip-screen.checkout.test.tsx b/src/app/tip/__tests__/tip-screen.checkout.test.tsx index 792ba704..5a092d81 100644 --- a/src/app/tip/__tests__/tip-screen.checkout.test.tsx +++ b/src/app/tip/__tests__/tip-screen.checkout.test.tsx @@ -145,7 +145,6 @@ describe("TipScreen checkout", () => { ["the selected plan is missing", { plans: [], selectedPlanId: "" }], ["an order is being created", { isCreatingOrder: true }], ["an order is being polled", { isPollingOrder: true }], - ["the order is paid", { isPaid: true }], ] as const)("disables checkout when %s", (_label, overrides) => { mocks.payment = makePaymentState(overrides); renderScreen(); @@ -153,6 +152,75 @@ describe("TipScreen checkout", () => { expect(getCheckoutButton().disabled).toBe(true); }); + it("replaces checkout with the first Tip success experience", () => { + mocks.payment = makePaymentState({ + status: "paid", + isPaid: true, + orderStatus: "paid", + tipCount: 1, + thankYouMessage: "A backend message that is not used for first Tip.", + }); + 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.", + ); + expect(document.activeElement?.id).toBe("tip-success-title"); + + const sendAgain = getButton("Send another coffee"); + act(() => sendAgain.click()); + expect(mocks.paymentDispatch).toHaveBeenCalledWith({ + type: "PaymentReset", + }); + + 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 { act(() => root.render()); } @@ -164,6 +232,14 @@ describe("TipScreen checkout", () => { if (!button) throw new Error("Missing Tip checkout button"); return button; } + + function getButton(label: string): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll("button"), + ).find((item) => item.textContent?.trim() === label); + if (!button) throw new Error(`Missing button: ${label}`); + return button; + } }); function makePaymentState( @@ -180,6 +256,8 @@ function makePaymentState( currentOrderId: null, payParams: null, orderStatus: null, + tipCount: null, + thankYouMessage: null, errorMessage: null, launchNonce: 0, isLoadingPlans: false, diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts index 137e365a..72e1b6d7 100644 --- a/src/app/tip/__tests__/tip-screen.helpers.test.ts +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -5,7 +5,9 @@ import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan"; import { findTipCoffeePlan, + formatEnglishOrdinal, formatTipPrice, + resolveTipSuccessCopy, } from "../tip-screen.helpers"; function makePlan(input: Partial): PaymentPlan { @@ -73,4 +75,34 @@ describe("tip screen helpers", () => { 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: [], + }); + }); }); diff --git a/src/app/tip/tip-screen.helpers.ts b/src/app/tip/tip-screen.helpers.ts index abb79f71..1a256bad 100644 --- a/src/app/tip/tip-screen.helpers.ts +++ b/src/app/tip/tip-screen.helpers.ts @@ -4,6 +4,11 @@ import { type TipCoffeeType, } from "@/lib/tip/tip_coffee"; +export interface TipSuccessCopy { + readonly title: string; + readonly body: readonly string[]; +} + export function findTipCoffeePlan( plans: readonly PaymentPlan[], coffeeType: TipCoffeeType, @@ -29,3 +34,46 @@ export function formatTipPrice( 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`; + } +} + +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: [], + }; +} diff --git a/src/app/tip/tip-screen.module.css b/src/app/tip/tip-screen.module.css index a02ab1b8..ae3e2f03 100644 --- a/src/app/tip/tip-screen.module.css +++ b/src/app/tip/tip-screen.module.css @@ -59,7 +59,6 @@ .productCard, .paymentMethodSlot, .statusMessage, -.successCard, .checkoutSlot { position: relative; z-index: 1; @@ -360,23 +359,7 @@ box-shadow: 0 5px 14px rgba(248, 77, 150, 0.26); } -.successCard { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - margin-top: 16px; - padding: 16px 18px; - border: 1px solid rgba(118, 61, 55, 0.08); - border-radius: 24px; - background: rgba(255, 255, 255, 0.76); - box-shadow: 0 16px 38px rgba(119, 72, 67, 0.09); - backdrop-filter: blur(16px); -} - -.statusMessage, -.successTitle, -.successText { +.statusMessage { margin: 0; } @@ -393,39 +376,6 @@ margin-top: clamp(18px, 5.185vw, 28px); } -.successCard { - justify-content: flex-start; - border-color: rgba(255, 84, 135, 0.16); - background: rgba(255, 246, 249, 0.86); - color: #ff4f86; -} - -.successTitle { - color: #25191b; - font-size: 15px; - font-weight: 900; -} - -.successText { - margin-top: 2px; - color: #8a686b; - font-size: 13px; - font-weight: 620; -} - -.successButton { - margin-left: auto; - border: 0; - border-radius: 999px; - padding: 9px 12px; - background: #231719; - color: #ffffff; - font: inherit; - font-size: 12px; - font-weight: 850; - cursor: pointer; -} - .checkoutSlot { margin-top: clamp(18px, 5.185vw, 28px); } diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 8b31470d..d5ec9076 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type CSSProperties } from "react"; import Image from "next/image"; -import { Heart, Sparkles } from "lucide-react"; +import { Sparkles } from "lucide-react"; import { BackButton, CharacterAvatar } from "@/app/_components"; import { MobileShell } from "@/app/_components/core"; @@ -38,6 +38,7 @@ import { findTipCoffeePlan, formatTipPrice, } from "./tip-screen.helpers"; +import { TipSuccessView } from "./tip-success-view"; import styles from "./tip-screen.module.css"; const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = { @@ -197,6 +198,21 @@ export function TipScreen({ paymentDispatch({ type: "PaymentReset" }); }; + if (payment.isPaid) { + return ( + + ); + } + return (
) : null} - {payment.isPaid ? ( -
-
- ) : null} -