Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { FORMAT_LOCALES, formatCount, PLAN_IDS, PLANS, planOf, type PlanId } from '@wellformed/shared';
import { api } from '../api/client';
import { useI18n, type TranslateFn } from '../i18n';
import { useSession } from '../state/session';
import { useToasts } from '../state/toasts';
import { Modal } from './Modal';
/**
* What this workspace is on, and what it is using.
*
* Usage is shown against the limit even when there is a long way to go, because
* the moment someone wants this screen is the moment they hit one — and the
* only useful version of that conversation starts with the real numbers.
*/
export function BillingDialog({ onClose }: { onClose(): void }): ReactNode {
const session = useSession();
const toasts = useToasts();
const { t, locale } = useI18n();
const [busy, setBusy] = useState(false);
const billing = useQuery({ queryKey: ['billing'], queryFn: () => api.billing() });
/*
* Null means "not edited yet", so the field follows the server until someone
* types — an initial-state copy would go stale the moment the query settles.
*/
const [draft, setDraft] = useState<string | null>(null);
const invoiceTo = draft ?? billing.data?.billingEmail ?? '';
const dirty = draft !== null && draft.trim() !== (billing.data?.billingEmail ?? '');
async function saveContact() {
setBusy(true);
try {
await api.setBillingContact(invoiceTo.trim());
setDraft(null);
await billing.refetch();
toasts.push(t('billing.invoicesAddressed'), 'success');
} catch (error) {
toasts.fail(error);
} finally {
setBusy(false);
}
}
async function upgrade(plan: PlanId) {
if (!invoiceTo.trim()) {
toasts.push(t('billing.needInvoiceAddress'), 'error');
return;
}
setBusy(true);
try {
const { url } = await api.checkout(plan, invoiceTo.trim());
// The provider is the merchant of record; the rest happens on their page.
window.location.assign(url);
} catch (error) {
toasts.fail(error);
setBusy(false);
}
}
async function manage() {
setBusy(true);
try {
const { url } = await api.billingPortal();
window.location.assign(url);
} catch (error) {
toasts.fail(error);
setBusy(false);
}
}
const summary = billing.data;
const current = summary ? planOf(summary.effectivePlan) : null;
/*
* On an unmetered install the plan a workspace records is not a ceiling, so
* the meters read against nothing and the price list does not belong on the
* screen at all. What is left — how much is in here — is the half of this
* dialog that was always true regardless of who was paying.
*/
const metered = summary?.metered ?? true;
const workspaceName = session.workspace?.name ?? t('billing.thisWorkspace');
const limitText = (limit: number | null, noun: 'rows' | 'tables') =>
limit === null ? t(`billing.unlimited.${noun}`) : t(`billing.count.${noun}`, limit, locale);
return (
<Modal
title={metered ? t('billing.planFor', workspaceName) : t('billing.usageIn', workspaceName)}
wide
onClose={onClose}
footer={
<>
<div className="grow" />
{summary?.subscription.provider && (
<button className="btn" onClick={() => void manage()} disabled={busy}>
{t('billing.manageOrCancel')}
</button>
)}
<button className="btn primary" onClick={onClose}>
{t('common.done')}
</button>
</>
}
>
<div className="mb">
{billing.isLoading && <div className="hint">{t('common.loading')}</div>}
{billing.error && <div className="formerror">{t('billing.loadFailed')}</div>}
{summary && current && (
<>
<div className="notice">
<div className="hint">
{!metered ? (
<>
{summary.licensedTo ? (
<>
{t('billing.licensedToBefore')}
<b>{summary.licensedTo}</b>
{t('billing.licensedToAfter')}
</>
) : (
<>{t('billing.ownLicence')}</>
)}
</>
) : (
<>
{t('billing.onPlanBefore')}
<b>{current.name}</b>
</>
)}
{metered && summary.subscription.status === 'trialing' && summary.subscription.trialEndsAt && (
<>
{t(
'billing.trialClause',
PLANS.team.name,
daysLeft(summary.subscription.trialEndsAt, t),
)}
</>
)}
{metered && summary.subscription.status === 'past_due' && (
<>{t('billing.pastDueClause')}</>
)}
{metered && summary.subscription.cancelAtPeriodEnd && summary.subscription.periodEnd && (
<>
{t(
'billing.endsClause',
new Intl.DateTimeFormat(FORMAT_LOCALES[locale], { timeZone: 'UTC' }).format(
new Date(summary.subscription.periodEnd.slice(0, 10)),
),
)}
</>
)}
.
</div>
<Meter
label={t('billing.rows')}
used={summary.usage.rows}
limit={metered ? current.rowLimit : null}
noun="rows"
/>
<Meter
label={t('billing.tables')}
used={summary.usage.tables}
limit={metered ? current.tableLimit : null}
noun="tables"
/>
</div>
{/*
Belongs to the workspace, not to an admin — which is the whole
point of asking rather than reading it off whoever is signed in.
*/}
{summary.checkoutAvailable && (
<div className="notice">
<label>
{t('billing.invoicesGoTo')}
<input
type="email"
placeholder="finance@yourcompany.com"
value={invoiceTo}
onChange={(e) => setDraft(e.target.value)}
/>
</label>
{dirty && (
<div className="rowend">
<button className="btn" onClick={() => void saveContact()} disabled={busy}>
{t('common.save')}
</button>
</div>
)}
<p className="hint">
{t('billing.contactHint')}
{summary.subscription.provider &&
t('billing.contactProviderHint', summary.subscription.provider)}
</p>
</div>
)}
{metered && (
<div className="plans">
{PLAN_IDS.map((id) => {
const plan = PLANS[id];
const active = summary.effectivePlan === id;
return (
<div className={'plan' + (active ? ' featured' : '')} key={id}>
<h3>{plan.name}</h3>
<div className="price">
{plan.priceEur === 0 ? (
'€0'
) : (
<>
€{plan.priceEur}
<span className="per">{t('billing.perMonth')}</span>
</>
)}
</div>
<div className="limits mono">
{limitText(plan.rowLimit, 'rows')} · {limitText(plan.tableLimit, 'tables')}
</div>
{active ? (
<span className="hint">{t('billing.currentPlan')}</span>
) : plan.priceEur > 0 ? (
<button
className="btn primary"
disabled={busy || !summary.checkoutAvailable}
onClick={() => void upgrade(id)}
>
{t('billing.upgrade')}
</button>
) : null}
</div>
);
})}
</div>
)}
{metered && !summary.checkoutAvailable && (
<p className="hint">
{t('billing.noPaymentsBefore')}
<span className="mono">npm run plan</span>
{t('billing.noPaymentsAfter')}
</p>
)}
{metered ? (
<p className="hint">
{t('billing.overLimitBefore')}
<b>{t('billing.overLimitBold')}</b>
{t('billing.overLimitAfter')}
</p>
) : (
<p className="hint">{t('billing.unmeteredHint')}</p>
)}
</>
)}
</div>
</Modal>
);
}
function Meter({
label,
used,
limit,
noun,
}: {
label: string;
used: number;
limit: number | null;
noun: 'rows' | 'tables';
}): ReactNode {
const { t, locale } = useI18n();
const share = limit === null ? 0 : Math.min(1, used / limit);
return (
<div className="meter">
<div className="meterhead">
<span>{label}</span>
<span className="mono">
{formatCount(used, locale)} / {limit === null ? '∞' : formatCount(limit, locale)}
</span>
</div>
{limit !== null && (
<div
className="meterbar"
role="img"
aria-label={t('billing.usedOf', used, t(`billing.count.${noun}`, limit, locale), locale)}
>
<div
className={'meterfill' + (share >= 1 ? ' full' : share > 0.85 ? ' near' : '')}
style={{ width: `${Math.round(share * 100)}%` }}
/>
</div>
)}
</div>
);
}
/** "6 days left", or "over" — never a raw timestamp on this screen. */
export function daysLeft(iso: string, t: TranslateFn): string {
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return t('billing.trialOver');
const days = Math.ceil(ms / 86_400_000);
return t('billing.daysLeft', days);
}