Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

10.4 kB Ruw bestand
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);
}