218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
|
|
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";
|
|||
|
|
import { useRepos } from "../data/DataContext";
|
|||
|
|
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();
|
|||
|
|
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;
|
|||
|
|
const rights = planId ? getPlanRights(planId, t) : null;
|
|||
|
|
|
|||
|
|
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)}`,
|
|||
|
|
});
|
|||
|
|
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>
|
|||
|
|
</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>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|