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>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|