From a17ff258d8ddbf982c5b03f663cc8fc43ad3eb31 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 10 Aug 2026 11:14:31 +0800 Subject: [PATCH] fix: correct membership back flow and add onboarding paywall --- docs/BUG_HISTORY.md | 16 ++ frontend/src/app/globals.css | 11 ++ frontend/src/app/membership/orders/page.tsx | 2 +- frontend/src/app/page.tsx | 49 +++++-- .../components/onboarding-redeem-paywall.tsx | 138 ++++++++++++++++++ frontend/tests/membership-page.test.ts | 18 +++ frontend/tests/sidebar-contract.test.ts | 7 +- 7 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/onboarding-redeem-paywall.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 16f5d764..fb949440 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2584,3 +2584,19 @@ - 相关记录:BUG-128、BUG-129、BUG-136、ERR-095、ERR-099、ERR-103 - 复发自:无 - 修复版本:待新 SHA 的 Release Gate 验证 + +## BUG-152 | 订单返回套餐后再次返回会重新进入订单页 + +- 状态:resolved(local candidate) +- 首次发现:2026-08-10 +- 最近更新:2026-08-10 +- 影响面:`/membership` 与 `/membership/orders` 的浏览器返回历史;支付、订单读取、兑换与 production 未改动。 +- 用户现象:从套餐与会员页进入订单记录,点击返回套餐页后,再点击套餐页返回,会再次进入订单记录页,形成往返循环。 +- 触发条件:套餐页用普通 Link 将订单页压入历史记录,订单页返回时又用普通 Link 将套餐页再次压入历史记录。 +- 根因:订单页的“返回套餐与会员”是返回语义,却使用了默认 push 导航,历史栈变成 `套餐页 → 订单页 → 套餐页`。 +- 修复:订单页返回 Link 使用 Next.js 原生 `replace`,将当前订单页历史项替换为套餐页;套餐页再次返回时回到进入套餐前的原入口。 +- 验证:聚焦 membership 合同测试锁定返回 Link 的 `replace` 语义;前端生产构建 `next build --webpack` 通过;`git diff --check` 通过。未 push、deploy 或触碰 production。 +- 防复发:固定目标的“返回上级页”不能再次 push 当前上级页;会员页回归测试持续锁定订单页返回的 replace 语义。 +- 相关记录:无 +- 复发自:无 +- 修复版本:本地候选提交(未 push / deploy) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 388c561f..719e05d0 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -749,6 +749,7 @@ button:disabled { cursor: default; opacity: .45; } .avatar-randomize { justify-self: start; } .avatar-notice { margin-top: var(--space-4); } .redeem-modal { width: min(100%, 420px); } +.paywall-modal { width: min(100%, 480px); } .logout-modal { width: min(100%, 400px); } .account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; } .account-modal h2 { margin: 0; font-size: var(--type-display-sm); letter-spacing: -.025em; } @@ -758,6 +759,14 @@ button:disabled { cursor: default; opacity: .45; } .redeem-balance span { color: var(--color-ink-secondary); font-size: var(--type-caption); } .redeem-balance strong { color: var(--color-ink); font-size: var(--type-title-lg); font-variant-numeric: tabular-nums; font-weight: 400; } .account-redeem-form { padding-top: var(--space-5); } +.paywall-intro { display: flex; align-items: flex-start; gap: var(--space-4); padding-bottom: var(--space-5); } +.paywall-mark { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border-radius: 50%; background: var(--color-action-soft); color: var(--color-action); } +.paywall-mark svg { width: 20px; height: 20px; } +.paywall-intro b { display: block; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.paywall-intro p { margin: var(--space-1) 0 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; text-wrap: pretty; } +.paywall-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-6); padding-top: var(--space-5); border-top: 1px solid var(--color-border); } +.paywall-footer span { color: var(--color-ink-secondary); font-size: var(--type-caption); } +.paywall-footer > a, .paywall-footer > button { flex: 0 0 auto; text-decoration: none; } .logout-copy { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.6; text-wrap: pretty; } .dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); } .sheet-section { border-bottom: 1px solid var(--color-border); padding: var(--space-6) 0; border-color: var(--color-border); } @@ -910,6 +919,8 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled .starter-content span { font-size: var(--type-title-sm); } .account-modal h2 { font-size: var(--type-title-lg); } .account-redeem-form > div { grid-template-columns: 1fr; } + .paywall-footer { align-items: stretch; flex-direction: column; } + .paywall-footer > a, .paywall-footer > button { width: 100%; } .dialog-actions { flex-direction: column-reverse; } .dialog-actions > button { width: 100%; } .auth-story { min-height: 220px; padding: var(--space-6) var(--space-5); } diff --git a/frontend/src/app/membership/orders/page.tsx b/frontend/src/app/membership/orders/page.tsx index 2384fb73..3a65ab97 100644 --- a/frontend/src/app/membership/orders/page.tsx +++ b/frontend/src/app/membership/orders/page.tsx @@ -84,7 +84,7 @@ export default function MembershipOrdersPage() {
- +
) : null} - +
@@ -3552,6 +3564,13 @@ export default function Home() {
)} + {onboardingPaywallOpen && account && ( + setOnboardingPaywallOpen(false)} + onCreditsChanged={(credits) => setAccount((current) => current ? { ...current, credits } : current)} + /> + )} ); diff --git a/frontend/src/components/onboarding-redeem-paywall.tsx b/frontend/src/components/onboarding-redeem-paywall.tsx new file mode 100644 index 00000000..b472dc9e --- /dev/null +++ b/frontend/src/components/onboarding-redeem-paywall.tsx @@ -0,0 +1,138 @@ +"use client"; + +import Link from "next/link"; +import { Gift, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { FormEvent } from "react"; +import { keepFocusWithin } from "@/lib/focus-trap"; +import { membershipHref, notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership"; + +type OnboardingRedeemPaywallProps = { + credits: number; + onClose: () => void; + onCreditsChanged: (credits: number) => void; +}; + +export function OnboardingRedeemPaywall({ + credits, + onClose, + onCreditsChanged, +}: OnboardingRedeemPaywallProps) { + const [code, setCode] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [pending, setPending] = useState(false); + const [unlocked, setUnlocked] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + const returnTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusFrame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => { + window.cancelAnimationFrame(focusFrame); + window.requestAnimationFrame(() => returnTarget?.focus()); + }; + }, []); + + function close() { + if (!pending) onClose(); + } + + async function redeem(event: FormEvent) { + event.preventDefault(); + const trimmedCode = code.trim(); + if (!trimmedCode || pending || unlocked) return; + setPending(true); + setError(""); + setMessage(""); + try { + const response = await fetch("/api/redeem", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: trimmedCode }), + }); + const payload = await response.json().catch(() => null); + if (response.status === 401) { + window.location.assign("/login"); + return; + } + if (!response.ok) throw new Error(redeemErrorMessage(response.status, payload)); + const result = payload as { awardedCredits?: number; credits: number; message?: string }; + const awarded = typeof result.awardedCredits === "number" ? result.awardedCredits : result.credits; + onCreditsChanged(result.credits); + setCode(""); + setUnlocked(true); + setMessage(result.message || `已到账 ${awarded} 点,现在可以开始完整咨询。`); + notifyBalanceChanged(result.credits); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); + } finally { + setPending(false); + } + } + + return ( +
+
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") close(); + else keepFocusWithin(event.nativeEvent, event.currentTarget); + }} + > +
+

解锁完整咨询

+ +
+
+ +
+ 入门问题已经准备好 +

填写兑换码,解锁后续咨询、生时校正与完整解读。

+
+
+
+ 当前可用点数 + {credits} 点 +
+
+ +
+ { + setCode(event.target.value); + setError(""); + setMessage(""); + setUnlocked(false); + }} + placeholder="输入完整兑换码" + /> + +
+ {error &&

{error}

} + {message &&

{message}

} +
+
+ {unlocked ? "点数已到账,可以继续选择问题。" : "还没有兑换码?"} + {unlocked ? ( + + ) : ( + 查看套餐与会员 + )} +
+
+
+ ); +} diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index 504f9ccc..6b7bfe47 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -5,6 +5,8 @@ import test from "node:test"; const projectFile = (path: string) => new URL(`../${path}`, import.meta.url); const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); const globalStyles = readProjectFile("src/app/globals.css"); +const homePageSource = readProjectFile("src/app/page.tsx"); +const onboardingPaywallSource = readProjectFile("src/components/onboarding-redeem-paywall.tsx"); const pageSource = readProjectFile("src/app/membership/page.tsx"); const ordersPageSource = readProjectFile("src/app/membership/orders/page.tsx"); const tabsSource = readProjectFile("src/components/ui/tabs.tsx"); @@ -216,6 +218,22 @@ test("independent orders page preserves loading, empty, error, refresh and list assert.match(membershipLib, /paid: "已支付"/); }); +test("orders return replaces its history entry so membership back cannot reopen orders", () => { + assert.match(ordersPageSource, / { + assert.match(homePageSource, /!starterHomeVisible/); + assert.match(homePageSource, /account\.credits > 0/); + assert.match(homePageSource, /account\.activeSubscription\?\.status === "active"/); + assert.match(homePageSource, /setOnboardingPaywallOpen\(true\)/); + assert.match(homePageSource, / { assert.match(pageSource, /本页会自动检查支付状态并刷新余额。/); assert.match(pageSource, /「订单记录」页面/); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index d03903e2..9bf4cd0d 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -173,9 +173,10 @@ test("removes the user-facing admin entry and routes credit control to membershi test("composes the chat page with the app sidebar shell", () => { const page = readProjectFile("src/app/page.tsx"); - assert.match(page, //); + assert.match(page, /const modalOpen = activeAccountDialog !== null \|\| onboardingPaywallOpen/); + assert.match(page, //); assert.match(page, /
[\s\S]*/); + assert.match(page, //); assert.match(page, //); }); @@ -192,7 +193,7 @@ test("blocks the provider mobile Escape action behind layered account UI", () => const page = readProjectFile("src/app/page.tsx"); const sidebar = readProjectFile("src/components/ui/sidebar.tsx"); - assert.match(page, /escapeBlocked=\{accountMenuOpen \|\| activeAccountDialog !== null\}/); + assert.match(page, /escapeBlocked=\{accountMenuOpen \|\| modalOpen\}/); assert.match(sidebar, /event\.key === "Escape" && isMobile && openMobile && !escapeBlocked/); });