Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import type { ReactNode } from 'react';
import { PLANS } from '@wellformed/shared';
import { usePublicConfig } from '../api/hooks';
import { useI18n, type TranslateFn } from '../i18n';
import { useSession } from '../state/session';
import { daysLeft } from './BillingDialog';
/**
* A slim line under the top bar, shown only when the plan is about to change
* what someone can do.
*
* Deliberately silent the rest of the time. A permanent "you are on Free"
* ribbon is an advert, and an advert that never goes away is the first thing
* people learn to stop reading — including on the day it says something that
* matters.
*
* Silent for good on an unmetered install. Nothing there starts a trial, so
* in practice there would be nothing to say anyway — but an install converted
* with one running would otherwise keep counting down to a limit that has
* been switched off, which is worse than a wrong advert.
*/
export function PlanBanner({ onOpen }: { onOpen(): void }): ReactNode {
const session = useSession();
const install = usePublicConfig();
const { t } = useI18n();
const workspace = session.workspace;
if (!workspace || session.visiting || !session.can('admin')) return null;
if (install.data && !install.data.metered) return null;
const notice = describe(workspace.billingStatus, workspace.trialEndsAt, t);
if (!notice) return null;
return (
<div className={`planbar ${notice.tone}`} role="status">
<span>{notice.message}</span>
<button className="linkbtn" onClick={onOpen}>
{notice.action}
</button>
</div>
);
}
interface Notice {
tone: 'warn' | 'bad';
message: string;
action: string;
}
/** Trials only speak up in their last week; a payment problem speaks up at once. */
function describe(status: string, trialEndsAt: string | null, t: TranslateFn): Notice | null {
if (status === 'past_due') {
return {
tone: 'bad',
message: t('billing.pastDueBanner'),
action: t('billing.fixPayment'),
};
}
if (status === 'trialing' && trialEndsAt) {
const remaining = new Date(trialEndsAt).getTime() - Date.now();
if (remaining <= 0) {
return {
tone: 'warn',
message: t('billing.trialEndedBanner'),
action: t('billing.seePlans'),
};
}
if (remaining <= 7 * 86_400_000) {
return {
tone: 'warn',
message: t('billing.trialBanner', PLANS.team.name, daysLeft(trialEndsAt, t)),
action: t('billing.seePlans'),
};
}
}
return null;
}