Files
Jyotisha/frontend/src/components/billing-panel.tsx
T
Jesse_ChenandCursor dc6598d7e4 fix(web): keep the settings dialog one size and move billing into it (BUG-554)
The four account panes now share a fixed frame, chart profiles open as list then detail, and membership lives in the homepage dialog instead of a separate page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 18:29:51 +08:00

339 lines
14 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);
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>}
{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;
return (
<article
key={product.id}
ref={highlighted ? highlightedCardRef : undefined}
tabIndex={highlighted ? -1 : undefined}
aria-current={highlighted ? "true" : undefined}
className={`membership-plan-card${recommended ? " membership-plan-card--recommended" : ""}${isCurrent ? " membership-plan-card--current" : ""}${highlighted ? " membership-plan-card--highlighted" : ""}`}
>
<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>
);
}