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

257 lines
9.8 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
import { useEffect, useState } from "react";
2026-07-15 15:23:59 +00:00
import { Link, useNavigate, useSearchParams } from "react-router-dom";
2026-07-10 05:10:31 +00:00
import { PageHeader } from "../components/layout/PageHeader";
2026-07-15 15:23:59 +00:00
import { Button, Card } from "../components/ui";
2026-07-13 03:18:08 +00:00
import { useData, useRepos } from "../data/DataContext";
2026-07-15 15:23:59 +00:00
import type { BillingCheckoutStatus } from "../data/repos";
2026-07-10 05:10:31 +00:00
import { useI18n } from "../i18n/I18nContext";
2026-07-15 15:23:59 +00:00
import {
billingErrorMessage,
isSafeBillingUrl,
pollCheckout,
startBillingRedirect,
type CheckoutPollResult,
} from "../lib/billingCheckout";
2026-07-10 05:10:31 +00:00
import { getPlanRights, planCtaLabel } from "../lib/planRights";
import { PLANS, type PlanId } from "../lib/usageMeter";
2026-07-15 15:23:59 +00:00
const PENDING_CHECKOUT_KEY = "harbor.billing.pending-checkout";
function isPlanId(value: string | null): value is PlanId {
return value === "free" || value === "starter" || value === "pro";
}
function checkoutIdFromReturn(value: string | null): string {
if (value && !/[{}<>]/.test(value) && value !== "checkout_id") return value;
return sessionStorage.getItem(PENDING_CHECKOUT_KEY) ?? "";
2026-07-10 05:10:31 +00:00
}
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 { t, formatPlanPrice } = useI18n();
const navigate = useNavigate();
const [params] = useSearchParams();
2026-07-15 15:23:59 +00:00
const result = params.get("result");
2026-07-10 05:10:31 +00:00
const planParam = params.get("plan");
2026-07-15 15:23:59 +00:00
const planId = isPlanId(planParam) ? planParam : null;
2026-07-10 05:10:31 +00:00
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
2026-07-15 15:23:59 +00:00
const [currentId, setCurrentId] = useState<PlanId | null>(null);
const [loadingError, setLoadingError] = useState("");
2026-07-10 05:10:31 +00:00
const [busy, setBusy] = useState(false);
2026-07-15 15:23:59 +00:00
const [redirecting, setRedirecting] = useState(false);
2026-07-10 05:10:31 +00:00
const [error, setError] = useState("");
2026-07-15 15:23:59 +00:00
const [pollResult, setPollResult] = useState<CheckoutPollResult | null>(null);
const [lastCheckout, setLastCheckout] = useState<BillingCheckoutStatus | null>(null);
const [pollAttempt, setPollAttempt] = useState(0);
const checkoutId = result === "success" ? checkoutIdFromReturn(params.get("checkout_id")) : "";
2026-07-10 05:10:31 +00:00
useEffect(() => {
2026-07-15 15:23:59 +00:00
if (result) return;
void repos.usage
.getBillingSubscription()
.then((subscription) => {
setCurrentId(subscription.plan_id);
setLoadingError("");
})
.catch((e: unknown) => setLoadingError(billingErrorMessage(e, t, "plans.loadFail")));
}, [repos.usage, result, t]);
2026-07-10 05:10:31 +00:00
2026-07-15 15:23:59 +00:00
useEffect(() => {
if (result !== "success" || !checkoutId) return;
let stopped = false;
setBusy(true);
setError("");
setPollResult(null);
void pollCheckout(() => repos.usage.getCheckoutSession(checkoutId), {
stopped: () => stopped,
})
.then((outcome) => {
if (!outcome || stopped) return;
setLastCheckout(outcome.checkout);
setPollResult(outcome);
if (outcome.outcome === "fulfilled") {
sessionStorage.removeItem(PENDING_CHECKOUT_KEY);
refresh();
navigate("/app/usage", {
replace: true,
state: { purchaseOk: outcome.checkout.plan_id },
});
}
})
.catch((e: unknown) => {
if (!stopped) setError(billingErrorMessage(e, t, "checkout.pollFail"));
})
.finally(() => {
if (!stopped) setBusy(false);
});
return () => {
stopped = true;
};
}, [checkoutId, navigate, pollAttempt, refresh, repos.usage, result, t]);
2026-07-10 05:10:31 +00:00
2026-07-15 15:23:59 +00:00
async function startCheckout() {
if (!planId || !currentId || (planId === "free" && currentId === "free")) return;
setBusy(true);
setRedirecting(false);
setError("");
try {
if (planId === "free" || planId === currentId) {
const portal = await repos.usage.createBillingPortalSession();
if (!isSafeBillingUrl(portal.url)) {
setError(t("checkout.invalidUrl"));
setBusy(false);
return;
}
beginRedirect(portal.url);
return;
}
const checkout = await repos.usage.createCheckoutSession(planId, crypto.randomUUID());
if (!checkout.id || !isSafeBillingUrl(checkout.url)) {
setError(t("checkout.invalidUrl"));
setBusy(false);
return;
}
sessionStorage.setItem(PENDING_CHECKOUT_KEY, checkout.id);
beginRedirect(checkout.url);
} catch (e) {
setError(billingErrorMessage(e, t, "checkout.fail"));
setBusy(false);
}
}
function beginRedirect(url: string) {
setRedirecting(true);
startBillingRedirect(
() => window.location.assign(url),
() => {
setRedirecting(false);
setBusy(false);
setError(t("checkout.redirectFailed"));
},
);
}
function retryPoll() {
setPollAttempt((attempt) => attempt + 1);
}
if (result === "cancel") {
2026-07-10 05:10:31 +00:00
return (
<>
<PageHeader title={t("checkout.title")} />
<Card>
2026-07-15 15:23:59 +00:00
<h2>{t("checkout.canceledTitle")}</h2>
<p className="text-muted">{t("checkout.canceledBody")}</p>
<Link to="/app/usage/plans" className="hb-btn hb-btn--primary">
{t("checkout.viewPlans")}
</Link>
</Card>
</>
);
}
if (result === "success") {
const status = lastCheckout
? `${lastCheckout.checkout_status} / ${lastCheckout.payment_status} / ${lastCheckout.fulfillment_status}`
: "";
return (
<>
<PageHeader title={t("checkout.title")} />
<Card>
{!checkoutId ? <p className="hb-form-error" role="alert">{t("checkout.missingId")}</p> : null}
{busy ? <p role="status">{t("checkout.verifying")}</p> : null}
{error ? <p className="hb-form-error" role="alert">{error}</p> : null}
{pollResult?.outcome === "failed" ? (
<p className="hb-form-error" role="alert">{t("checkout.terminalFail", { status })}</p>
) : null}
{pollResult?.outcome === "timeout" ? (
<p className="hb-banner-warn" role="status">{t("checkout.timeout")}</p>
) : null}
{(error || pollResult) && checkoutId ? (
<Button type="button" onClick={retryPoll}>{t("checkout.retry")}</Button>
) : null}
<div className="hb-checkout__links">
<Link to="/app/usage">{t("plans.usageLink")}</Link>
2026-07-10 05:10:31 +00:00
</div>
</Card>
</>
);
}
2026-07-15 15:23:59 +00:00
if (!plan || !planId || !rights) {
return (
<>
<PageHeader title={t("checkout.title")} />
<Card>
<p className="text-muted">{t("checkout.pickFirst")}</p>
<Link to="/app/usage/plans" className="hb-btn hb-btn--primary">{t("checkout.viewPlans")}</Link>
</Card>
</>
);
}
if (!currentId) {
return (
<>
<PageHeader title={t("checkout.title")} />
{loadingError ? <p className="hb-form-error" role="alert">{loadingError}</p> : <p>{t("common.loading")}</p>}
</>
);
2026-07-10 05:10:31 +00:00
}
2026-07-15 15:23:59 +00:00
const already = currentId === planId;
const currentFree = planId === "free" && currentId === "free";
const portalAction = (planId === "free" && currentId !== "free") || (already && currentId !== "free");
const actionLabel = portalAction
? t("plans.manage")
2026-07-10 05:10:31 +00:00
: t("checkout.payAndAction", {
action: planCtaLabel(currentId, planId, t),
price: formatPlanPrice(plan.price_twd),
});
return (
<>
<PageHeader title={t("checkout.title")} />
2026-07-15 15:23:59 +00:00
{error ? <p className="hb-form-error" role="alert">{error}</p> : null}
{redirecting ? <p role="status">{t("checkout.redirecting")}</p> : null}
2026-07-10 05:10:31 +00:00
<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>
2026-07-15 15:23:59 +00:00
<p className="text-muted">{rights.headline}</p>
2026-07-10 05:10:31 +00:00
</div>
<div className="hb-checkout__price-block">
2026-07-15 15:23:59 +00:00
<p className="hb-checkout__big-price">{formatPlanPrice(plan.price_twd)}<span className="hb-checkout__period">{t("checkout.perMonth")}</span></p>
<p className="text-muted">{t("checkout.monthlyCredits", { n: plan.monthly_credits })}</p>
2026-07-10 05:10:31 +00:00
</div>
</div>
<div className="hb-usage-checkout__rights">
2026-07-15 15:23:59 +00:00
<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>
2026-07-10 05:10:31 +00:00
</div>
</Card>
</div>
<aside className="hb-checkout__aside">
<Card>
<p className="hb-usage-checkout__label">{t("checkout.amountDue")}</p>
2026-07-15 15:23:59 +00:00
<p className="hb-checkout__big-price">{formatPlanPrice(plan.price_twd)}<span className="hb-checkout__period">{t("checkout.perMonth")}</span></p>
{portalAction ? <p className="hb-banner-warn">{t("checkout.manageInstead")}</p> : null}
<Button type="button" disabled={busy || currentFree} onClick={() => void startCheckout()}>
{busy ? t("checkout.processing") : actionLabel}
2026-07-10 05:10:31 +00:00
</Button>
2026-07-15 15:23:59 +00:00
<div className="hb-checkout__links"><Link to="/app/usage/plans">{t("checkout.pickOther")}</Link><Link to="/app/usage">{t("checkout.cancel")}</Link></div>
2026-07-10 05:10:31 +00:00
</Card>
</aside>
</div>
</>
);
}