thread-master/apps/web/src/pages/PlanCheckoutPage.tsx

224 lines
7.8 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
import { useEffect, useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { Button, Card, Input } from "../components/ui";
2026-07-13 03:18:08 +00:00
import { useData, useRepos } from "../data/DataContext";
2026-07-10 05:10:31 +00:00
import { useI18n } from "../i18n/I18nContext";
import { getPlanRights, planCtaLabel } from "../lib/planRights";
import { PLANS, type PlanId } from "../lib/usageMeter";
function isPlanId(v: string | null): v is PlanId {
return v === "free" || v === "starter" || v === "pro";
}
/**
* + + mock
*/
export function PlanCheckoutPage() {
const repos = useRepos();
2026-07-13 03:18:08 +00:00
const { refresh } = useData();
2026-07-10 05:10:31 +00:00
const { member } = useAuth();
const { t, formatPlanPrice } = useI18n();
const navigate = useNavigate();
const [params] = useSearchParams();
const planParam = params.get("plan");
const planId: PlanId | null = isPlanId(planParam) ? planParam : null;
const plan = planId ? PLANS[planId] : null;
2026-07-13 03:18:08 +00:00
const rights = planId ? getPlanRights(planId, t, formatPlanPrice) : null;
2026-07-10 05:10:31 +00:00
const [currentId, setCurrentId] = useState<PlanId>("free");
const [cardName, setCardName] = useState(member?.display_name || "");
const [cardLast4, setCardLast4] = useState("4242");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
void repos.usage.getPlanId().then(setCurrentId);
}, [repos.usage]);
const free = plan?.price_twd === 0;
const already = planId != null && currentId === planId;
if (!member) return <Navigate to="/login" replace />;
if (!plan || !planId || !rights) {
return (
<>
<PageHeader title={t("checkout.title")} />
<Card>
<p className="text-muted" style={{ margin: 0 }}>
{t("checkout.pickFirst")}
</p>
<div style={{ marginTop: "0.75rem" }}>
<Link to="/app/usage/plans">
<Button type="button">{t("checkout.viewPlans")}</Button>
</Link>
</div>
</Card>
</>
);
}
async function submit() {
if (!planId || already) return;
setError("");
if (!free) {
const digits = cardLast4.replace(/\D/g, "");
if (digits.length < 4) {
setError(t("checkout.cardLast4Required"));
return;
}
if (!cardName.trim()) {
setError(t("checkout.cardNameRequired"));
return;
}
}
setBusy(true);
try {
await repos.usage.purchasePlan(planId, {
mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`,
});
2026-07-13 03:18:08 +00:00
// 強制重載 repos頂欄用量PLANS 配給依新 plan_id
refresh();
2026-07-10 05:10:31 +00:00
navigate("/app/usage", {
replace: true,
state: { purchaseOk: planId },
});
} catch (e) {
setError(e instanceof Error ? e.message : t("checkout.fail"));
} finally {
setBusy(false);
}
}
const actionLabel = free
? t("checkout.confirmFree")
: t("checkout.payAndAction", {
action: planCtaLabel(currentId, planId, t),
price: formatPlanPrice(plan.price_twd),
});
return (
<>
<PageHeader title={t("checkout.title")} />
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<div className="hb-checkout">
<div className="hb-checkout__main hb-stack">
<Card>
<div className="hb-checkout__order">
<div>
<p className="hb-usage-checkout__label">{t("checkout.subscribe")}</p>
<p className="hb-usage-checkout__name">{plan.name}</p>
<p
className="text-muted"
style={{ margin: "0.35rem 0 0", fontSize: "var(--hb-text-sm)" }}
>
{rights.headline}
</p>
</div>
<div className="hb-checkout__price-block">
<p className="hb-checkout__big-price">
{formatPlanPrice(plan.price_twd)}
<span className="hb-checkout__period">{t("checkout.perMonth")}</span>
</p>
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("checkout.monthlyCredits", { n: plan.monthly_credits })}
</p>
2026-07-13 03:18:08 +00:00
<p className="text-muted" style={{ margin: "0.25rem 0 0", fontSize: "var(--hb-text-xs)" }}>
{rights.approxCallsLine}
</p>
2026-07-10 05:10:31 +00:00
</div>
</div>
<div className="hb-usage-checkout__rights">
<section className="hb-usage-checkout__block">
<h3 className="hb-usage-checkout__h">{t("checkout.youGet")}</h3>
<ul className="hb-usage-checkout__list">
{rights.rights.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
<section className="hb-usage-checkout__block">
<h3 className="hb-usage-checkout__h">{t("checkout.quota")}</h3>
<ul className="hb-usage-checkout__list">
{rights.quota.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
<section className="hb-usage-checkout__block">
<h3 className="hb-usage-checkout__h">{t("checkout.notes")}</h3>
<ul className="hb-usage-checkout__list hb-usage-checkout__list--notes">
{rights.notes.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
</div>
</Card>
{!free && !already ? (
<Card>
<div className="hb-stack">
<Input
label={t("checkout.cardholder")}
value={cardName}
onChange={(e) => setCardName(e.target.value)}
autoComplete="cc-name"
disabled={busy}
/>
<Input
label={t("checkout.cardLast4")}
value={cardLast4}
onChange={(e) => setCardLast4(e.target.value.replace(/\D/g, "").slice(0, 4))}
inputMode="numeric"
maxLength={4}
placeholder="4242"
disabled={busy}
/>
</div>
</Card>
) : null}
</div>
<aside className="hb-checkout__aside">
<Card>
<p className="hb-usage-checkout__label">{t("checkout.amountDue")}</p>
<p className="hb-checkout__big-price">
{formatPlanPrice(plan.price_twd)}
<span className="hb-checkout__period">{t("checkout.perMonth")}</span>
</p>
<p className="text-muted" style={{ margin: "0.25rem 0 1rem", fontSize: "var(--hb-text-sm)" }}>
{t("checkout.billedMonthly", { name: plan.name })}
</p>
{already ? (
<p className="hb-banner-ok" style={{ margin: "0 0 0.75rem" }}>
{t("checkout.already")}
</p>
) : null}
<Button type="button" disabled={busy || already} onClick={() => void submit()}>
{busy
? t("checkout.processing")
: already
? t("checkout.currentPlan")
: actionLabel}
</Button>
<div className="hb-checkout__links">
<Link to="/app/usage/plans">{t("checkout.pickOther")}</Link>
<Link to="/app/usage">{t("checkout.cancel")}</Link>
</div>
</Card>
</aside>
</div>
</>
);
}