Files
Jyotisha/frontend/src/components/billing-panel.tsx
T
Jesse_ChenandClaude Opus 5 c3bcddb652
Independent Staging Quality Gate / validate (push) Canceled after 8m19s
Independent Staging Quality Gate / publish (push) Canceled after 0s
feat(billing): ?plan= 深链用一句文案指向目标卡,而不是第三层描边
D13 把 URL 高亮改成纯滚动定位后留下一个空档:桌面上三张套餐卡本来就都在
视口内,滚动等于什么都没指。付费转化路径上,用户从「开通年度会员」的链接
进来,看不到任何东西指向那张卡。

产品在「加一行文案 / 维持现状 / 把描边加回来」里选了第一个。

用卡片自己的套餐名,不用序数——手机端 [data-plan-alias="monthly"] 带
order:-1,序数会指错。新增断言锁住这两点(必须有 note、不得出现序数写法)。

测试 3354 不变,fail 仍 31 且与基线逐条一致;/ 仍 Static;
干净构建 CSS gzip 39,724→39,733。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:35:52 +00:00

356 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect } from "react";
import { CheckCircle2, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBillingPanel } from "@/hooks/use-billing-panel";
import type { Account } from "@/lib/home-types";
import {
entitlementLabel,
formatMembershipDate,
formatPrice,
formatProductDuration,
orderStatusLabel,
planAlias,
planDisplayLabel,
productAudience,
selectMembershipPlans,
type BillingPaneTab,
type MembershipPlanAlias,
} from "@/lib/membership";
export type BillingPanelProps = {
readonly account: Account;
readonly highlightedPlan: MembershipPlanAlias | null;
readonly initialTab: BillingPaneTab;
};
function payingLabel(paying: boolean, idle: string) {
return paying ? "跳转收银台…" : idle;
}
export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab }: BillingPanelProps) {
const billing = useBillingPanel({ account: seedAccount, highlightedPlan, initialTab });
const {
account,
activeSubscription,
closeRedeem,
createPayment,
currentProductCode,
fetchOrders,
highlightedCardRef,
orders,
ordersState,
packagesError,
packagesSettled,
payingProductId,
paymentEnabled,
paymentError,
paymentOrder,
paymentPackages,
redeem,
redeemCode,
redeemDone,
redeemError,
redeeming,
redeemMessage,
selectedProductId,
setRedeemCode,
setRedeemDone,
setRedeemError,
setRedeemMessage,
} = billing;
const plans = selectMembershipPlans(paymentPackages);
/* Named by the card's own title, not by position: on phones the monthly card
takes `order: -1`, so an ordinal would point at the wrong card. */
const highlightedName = highlightedPlan
? plans.find((product) => planAlias(product) === highlightedPlan)?.name ?? null
: null;
const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack");
const credits = account?.credits ?? seedAccount.credits;
useEffect(() => {
if (billing.initialTab === "orders") void fetchOrders();
}, [billing.initialTab, fetchOrders]);
return (
<div className="billing-panel">
<p className="billing-summary">
余额 {credits} 点
{" · "}
{activeSubscription
? `${activeSubscription.product?.name || activeSubscription.productCode},${formatMembershipDate(activeSubscription.endsAt)} 到期`
: "未开通会员"}
</p>
<Tabs
defaultValue={billing.initialTab}
onValueChange={(value) => {
if (value === "orders") void fetchOrders();
}}
>
<TabsList className="membership-tabs" aria-label="账户与点数">
<TabsTrigger className="membership-tab" id="membership-plans-tab" value="membership">
会员套餐
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-credits-tab" value="credits">
点数包
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-redeem-tab" value="redeem">
兑换码
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-orders-tab" value="orders">
订单记录
</TabsTrigger>
</TabsList>
{paymentError && (
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(selectedProductId)}
>
{payingLabel(payingProductId === selectedProductId, "重新支付")}
</Button>
)}
</div>
)}
{packagesError && <p className="form-error" role="alert">{packagesError}</p>}
<TabsContent className="membership-panel" value="membership">
{packagesSettled && !packagesError && !paymentEnabled && <p className="membership-empty">在线支付暂未开放。</p>}
{/* A ?plan= deep link no longer tints or outlines the card it points at
(D13: one card, one state). On desktop all three cards are visible
anyway, so scrolling alone pointed at nothing — this line is the
wayfinding, in copy instead of a third border layer. */}
{highlightedName && (
<p className="membership-plan-note">你从「{highlightedName}」的链接过来的,就在下面。</p>
)}
{plans.length > 0 && (
<div className="membership-plan-grid">
{plans.map((product) => {
const alias = planAlias(product);
const recommended = alias === "monthly";
const isCurrent = currentProductCode === product.code;
const highlighted = alias === highlightedPlan;
// D13: two mutually exclusive states. 当前套餐 wins over 推荐 because the
// card's own action is 续费, not 立即购买. `highlighted` carries no
// appearance any more — it only picks the scroll/focus target.
const stateClass = isCurrent ? " is-current" : recommended ? " is-recommended" : "";
return (
<article
key={product.id}
ref={highlighted ? highlightedCardRef : undefined}
tabIndex={highlighted ? -1 : undefined}
aria-current={highlighted ? "true" : undefined}
data-plan-alias={alias ?? undefined}
className={`membership-plan-card${stateClass}`}
>
<header className="membership-card-header">
<div>
<h3>{product.name}</h3>
<p className="membership-card-meta">{planDisplayLabel(alias ?? "monthly")}</p>
</div>
{(isCurrent || recommended) && <span className={`membership-status${isCurrent ? " is-current" : ""}`}>{isCurrent ? "当前套餐" : "推荐"}</span>}
</header>
<p className="membership-card-price">
{formatPrice(product.priceCents, product.currency)}
<small>/{formatProductDuration(product)}</small>
</p>
<p className="membership-card-audience">{productAudience(product)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
{product.entitlements.length === 0 && <li>购买后按页面说明到账</li>}
</ul>
<details className="membership-rules">
<summary>详细规则</summary>
<ul>
<li>{isCurrent
? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。`
: "支付成功后权益立即生效。"}</li>
<li>订单与到账状态可在「订单记录」页签查看。</li>
<li>支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。</li>
</ul>
</details>
<Button
className="membership-buy"
variant={recommended ? "default" : "outline"}
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingLabel(payingProductId === product.id, isCurrent ? "续费" : product.productType === "trial" ? "立即开通" : "立即购买")}
</Button>
</article>
);
})}
</div>
)}
{packagesSettled && !packagesError && paymentEnabled && plans.length === 0 && <p className="membership-empty">会员套餐正在上架中。</p>}
</TabsContent>
<TabsContent className="membership-panel" value="credits">
{creditPacks.length > 0 && (
<div className="membership-credit-grid">
{creditPacks.map((product) => (
<article className="membership-credit-card" key={product.id}>
<div className="membership-credit-copy">
<h3>{product.name}</h3>
<p className="membership-card-meta">{product.credits > 0 ? `${product.credits} 点` : "点数包"}</p>
<p>{product.description || "按需补充咨询点数"}</p>
</div>
<p className="membership-card-price">{formatPrice(product.priceCents, product.currency)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
</ul>
<details className="membership-rules">
<summary>详细规则</summary>
<ul>
<li>点数充值支付成功后立即到账。</li>
</ul>
</details>
<Button
className="membership-buy"
variant="outline"
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingLabel(payingProductId === product.id, "立即购买")}
</Button>
</article>
))}
</div>
)}
{packagesSettled && !packagesError && paymentEnabled && creditPacks.length === 0 && <p className="membership-empty">点数包正在上架中。</p>}
</TabsContent>
<TabsContent className="membership-panel" value="redeem">
<h2 id="membership-redeem-title">兑换点数</h2>
<div className="redeem-balance">
<span>{activeSubscription ? "当前会员" : "当前余额"}</span>
<strong>{activeSubscription
? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期`
: `${credits} 点`}</strong>
</div>
<form className="redeem-form" onSubmit={redeem}>
<label htmlFor="membership-redeem-code">兑换码</label>
<div>
<input
id="membership-redeem-code"
autoComplete="off"
value={redeemCode}
onChange={(event) => {
setRedeemCode(event.target.value);
setRedeemError("");
setRedeemMessage("");
setRedeemDone(false);
}}
placeholder="输入完整兑换码"
/>
<Button type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
{redeeming ? "兑换中" : "立即兑换"}
</Button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
{redeemDone && (
<div className="dialog-actions">
<Button type="button" onClick={closeRedeem}>完成</Button>
</div>
)}
</form>
<details className="membership-rules">
<summary>兑换规则</summary>
<ul>
<li>请完整输入兑换码,系统会按 JYOTISH-XXXX-XXXX 格式校验。</li>
<li>兑换成功后点数立即到账,余额实时更新。</li>
<li>每个兑换码仅可使用一次,无法转让或退换。</li>
</ul>
</details>
</TabsContent>
<TabsContent className="membership-panel" value="orders">
<div className="membership-section-heading">
<div>
<h2 id="membership-orders-title">全部订单</h2>
<p>查看套餐与点数包的支付和到账状态。</p>
</div>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}>刷新</button>
</div>
{ordersState === "unavailable" && <p className="membership-empty">订单记录暂时不可用,请稍后刷新。</p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty">暂无订单。</p>}
{orders.length > 0 && (
<ul className="membership-order-list">
{orders.map((order) => (
<li className="membership-order-row" key={order.orderNo}>
<div>
<strong>{order.productName || "订单"}</strong>
<small>{order.orderNo}</small>
</div>
<span className={`membership-order-status status-${order.status}`}>
{orderStatusLabel(order.status)}
</span>
<b>{formatPrice(order.moneyCents, order.currency)}</b>
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
{paymentOrder && (
<section className="membership-payment-panel" role="status" aria-live="polite">
<span className="membership-payment-icon" aria-hidden="true">
{paymentOrder.status === "paid" ? <CheckCircle2 /> : paymentOrder.status === "failed" ? <X /> : <Sparkles />}
</span>
<div className="membership-payment-copy">
{paymentOrder.status === "paid" && (
<>
<h3>支付成功,权益已到账</h3>
<p>订单号:{paymentOrder.orderNo}</p>
<p>余额与会员状态已刷新。</p>
</>
)}
{paymentOrder.status === "failed" && (
<>
<h3>支付失败</h3>
<p>订单号:{paymentOrder.orderNo}</p>
<p>订单未完成支付,可重新支付或稍后再试。</p>
</>
)}
{paymentOrder.status === "pending" && (
<>
<h3>等待支付结果</h3>
<p>订单号:{paymentOrder.orderNo}</p>
<p>请在已打开的收银台页面完成支付;打开后会自动检查支付状态并刷新余额。</p>
</>
)}
</div>
<div className="dialog-actions">
{paymentOrder.status === "failed" && (
<Button
variant="outline"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(paymentOrder.productId)}
>
{payingLabel(payingProductId === paymentOrder.productId, "重新支付")}
</Button>
)}
</div>
</section>
)}
</div>
);
}