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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|