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 (
{notice.message}
); } 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; }