| 1 | /** |
| 2 | * A workspace's complimentary enrollment: the monthly usage allowance (at |
| 3 | * ordinary prices), the recurring monthly fee apart from it, this month's |
| 4 | * standing, the Stripe subscription that stands for it, and its terms over |
| 5 | * time. Staff enroll each workspace by hand; nothing enrolls one on its own. |
| 6 | */ |
| 7 | import type { ComplimentaryStanding } from "@g1t/contracts"; |
| 8 | |
| 9 | import { Badge, Button, Field, Input, Notice, Section, Textarea, When } from "~/components/ui"; |
| 10 | import { dollarsField, usd } from "~/lib/money"; |
| 11 | import type { SectionError } from "~/lib/review"; |
| 12 | |
| 13 | const STATUS: Record<ComplimentaryStanding["status"], { label: string; tone: "mint" | "warn" | "plain" }> = { |
| 14 | active: { label: "Active", tone: "mint" }, |
| 15 | pending: { label: "Waiting for Stripe", tone: "warn" }, |
| 16 | ended: { label: "Ended", tone: "plain" }, |
| 17 | }; |
| 18 | |
| 19 | function allowanceText(micros: number, unlimited?: boolean) { |
| 20 | return unlimited ? "Unlimited" : `${usd(micros, { cents: true })} a month`; |
| 21 | } |
| 22 | |
| 23 | function feeText(cents?: number) { |
| 24 | return cents ? `${usd(cents * 10_000, { cents: true })} a month` : "Waived"; |
| 25 | } |
| 26 | |
| 27 | export function ComplimentarySection({ |
| 28 | workspace, |
| 29 | standing, |
| 30 | unavailable, |
| 31 | pathname, |
| 32 | error, |
| 33 | }: { |
| 34 | workspace: string; |
| 35 | standing: ComplimentaryStanding | null; |
| 36 | unavailable: string | null; |
| 37 | pathname: string; |
| 38 | error: SectionError; |
| 39 | }) { |
| 40 | const values = error?.values; |
| 41 | const open = standing && standing.status !== "ended" ? standing : null; |
| 42 | const month = open?.month ?? null; |
| 43 | return ( |
| 44 | <Section |
| 45 | id="complimentary" |
| 46 | title="Complimentary" |
| 47 | description="The plan's features, a monthly usage allowance at ordinary prices, and a monthly fee of its own. Past the allowance new work that costs money stops; nothing is ever charged for it. No rollover." |
| 48 | > |
| 49 | {unavailable && <Notice tone="error">Billing could not say: {unavailable}</Notice>} |
| 50 | {open ? ( |
| 51 | <div className="mb-4 space-y-3"> |
| 52 | <div className="flex flex-wrap items-center gap-2 text-sm"> |
| 53 | <Badge tone={STATUS[open.status].tone}>{STATUS[open.status].label}</Badge> |
| 54 | <span className="text-fg-soft">{allowanceText(open.allowanceMicros, open.unlimited)} allowance</span> |
| 55 | <span className="text-faint">·</span> |
| 56 | <span className="text-fg-soft">Fee {feeText(open.feeCents)}</span> |
| 57 | <span className="text-faint">·</span> |
| 58 | <span className="text-fg-soft">{open.chargePastAllowance ? "Past it: their card is charged" : "Past it: work stops"}</span> |
| 59 | </div> |
| 60 | {month && ( |
| 61 | <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm sm:grid-cols-3"> |
| 62 | <Stat label="Used this month" value={usd(month.usedMicros, { cents: true })} /> |
| 63 | <Stat label="Metered, not billed yet" value={usd(month.pendingMicros ?? 0, { cents: true })} /> |
| 64 | <Stat label="Held by running work" value={usd(month.heldMicros ?? 0, { cents: true })} /> |
| 65 | <Stat label="Remaining" value={month.unlimited ? "Unlimited" : usd(month.remainingMicros, { cents: true })} /> |
| 66 | <Stat label="Covered past it by g1t" value={usd(month.overrunMicros ?? 0, { cents: true })} /> |
| 67 | <Stat label="Resets" value={<When at={month.resetsAt} />} /> |
| 68 | </dl> |
| 69 | )} |
| 70 | <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"> |
| 71 | <Stat label="Money received, ever" value={usd(open.moneyReceivedMicros ?? 0, { cents: true })} /> |
| 72 | <Stat label="Promotional credit, ever" value={usd(open.promotionalCreditMicros ?? 0, { cents: true })} /> |
| 73 | <Stat label="Gifted usage, ever" value={usd(open.giftedMicros ?? 0, { cents: true })} /> |
| 74 | </dl> |
| 75 | <p className="text-xs text-muted"> |
| 76 | Stripe {open.live ? "live" : "test"}: {open.customerId ?? "no customer yet"}, {open.subscriptionId ?? "no subscription yet"} |
| 77 | {open.stripeStatus ? ` (${open.stripeStatus})` : ""}. Grant {open.grantId}. |
| 78 | </p> |
| 79 | {open.pendingOp && ( |
| 80 | <Notice tone="info"> |
| 81 | Still owed at Stripe: {open.pendingOp}. Tried again every fifteen minutes{open.lastError ? `; last error: ${open.lastError}` : "."} |
| 82 | </Notice> |
| 83 | )} |
| 84 | </div> |
| 85 | ) : ( |
| 86 | <p className="mb-4 text-sm text-muted"> |
| 87 | {standing?.status === "ended" |
| 88 | ? `Not enrolled. The last enrollment ended ${standing.endedReason === "converted" ? "when the owner started the plan" : "by staff"}.` |
| 89 | : "Not enrolled: the workspace is on its own plan."} |
| 90 | </p> |
| 91 | )} |
| 92 | |
| 93 | <form method="post" action={`${pathname}#complimentary`} className="space-y-4"> |
| 94 | <input type="hidden" name="intent" value="complimentary" /> |
| 95 | {error && <Notice tone="error">{error.error}</Notice>} |
| 96 | <div className="grid gap-4 sm:grid-cols-2"> |
| 97 | <Field label="Monthly allowance $" hint="Usage at ordinary prices, each calendar month (UTC). Changing it keeps what this month used."> |
| 98 | <Input |
| 99 | name="allowance" |
| 100 | inputMode="decimal" |
| 101 | placeholder="100.00" |
| 102 | defaultValue={values?.allowance ?? (open ? dollarsField(open.allowanceMicros) : "100.00")} |
| 103 | /> |
| 104 | </Field> |
| 105 | <Field label="Monthly fee $" hint="Recurring, invoiced by Stripe, apart from the allowance. Blank or 0: waived."> |
| 106 | <Input name="fee" inputMode="decimal" placeholder="Waived" defaultValue={values?.fee ?? (open?.feeCents ? dollarsField(open.feeCents * 10_000) : "")} /> |
| 107 | </Field> |
| 108 | </div> |
| 109 | <fieldset className="space-y-2 text-sm"> |
| 110 | <legend className="mb-1 text-xs text-faint">Past the allowance</legend> |
| 111 | <label className="flex cursor-pointer items-start gap-2.5"> |
| 112 | <input |
| 113 | type="radio" |
| 114 | name="past" |
| 115 | value="stop" |
| 116 | defaultChecked={values ? values.past !== "charge" : !open?.chargePastAllowance} |
| 117 | className="mt-0.5" |
| 118 | /> |
| 119 | <span> |
| 120 | <span className="block font-medium">New paid work stops</span> |
| 121 | <span className="block text-xs text-muted">Nothing is ever charged. For partners g1t carries entirely.</span> |
| 122 | </span> |
| 123 | </label> |
| 124 | <label className="flex cursor-pointer items-start gap-2.5"> |
| 125 | <input |
| 126 | type="radio" |
| 127 | name="past" |
| 128 | value="charge" |
| 129 | defaultChecked={values ? values.past === "charge" : Boolean(open?.chargePastAllowance)} |
| 130 | className="mt-0.5" |
| 131 | /> |
| 132 | <span> |
| 133 | <span className="block font-medium">Their card is charged</span> |
| 134 | <span className="block text-xs text-muted"> |
| 135 | g1t covers the allowance; past it, usage is charged to the workspace's own card at the usual prices, up to its spend |
| 136 | limit. For employees and friends. Without a card, work stops until they add one. |
| 137 | </span> |
| 138 | </span> |
| 139 | </label> |
| 140 | </fieldset> |
| 141 | <label className="flex cursor-pointer items-start gap-2.5 text-sm"> |
| 142 | <input type="checkbox" name="unlimited" defaultChecked={values ? values.unlimited === "on" : Boolean(open?.unlimited)} className="mt-0.5" /> |
| 143 | <span> |
| 144 | <span className="block font-medium">Unlimited allowance</span> |
| 145 | <span className="block text-xs text-muted">For g1t's own workspaces. Infrastructure limits still apply.</span> |
| 146 | </span> |
| 147 | </label> |
| 148 | <Field label="Note" hint="Required. Why, for whoever looks next."> |
| 149 | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner, $500 a month through Q1" /> |
| 150 | </Field> |
| 151 | <Field label={`Type ${workspace} to confirm`}> |
| 152 | <Input name="confirmation" autoComplete="off" defaultValue="" /> |
| 153 | </Field> |
| 154 | <div className="flex justify-end"> |
| 155 | <Button type="submit">{open ? "Change terms" : "Enroll"}</Button> |
| 156 | </div> |
| 157 | </form> |
| 158 | |
| 159 | {open && ( |
| 160 | <form method="post" action={`${pathname}#complimentary`} className="mt-6 space-y-3 border-t border-line pt-4"> |
| 161 | <input type="hidden" name="intent" value="end-complimentary" /> |
| 162 | <p className="text-sm text-muted"> |
| 163 | Ending it puts the workspace on Free now and cancels its Stripe subscription. Usage the allowance paid for stays paid for. |
| 164 | </p> |
| 165 | <div className="grid gap-3 sm:grid-cols-2"> |
| 166 | <Field label="Note"> |
| 167 | <Input name="note" required maxLength={500} placeholder="Why" /> |
| 168 | </Field> |
| 169 | <Field label={`Type ${workspace} to end`}> |
| 170 | <Input name="confirmation" autoComplete="off" /> |
| 171 | </Field> |
| 172 | </div> |
| 173 | <div className="flex justify-end"> |
| 174 | <Button type="submit" variant="danger"> |
| 175 | End enrollment |
| 176 | </Button> |
| 177 | </div> |
| 178 | </form> |
| 179 | )} |
| 180 | |
| 181 | {standing?.periods && standing.periods.length > 0 && ( |
| 182 | <div className="mt-6 border-t border-line pt-4"> |
| 183 | <p className="text-xs text-faint">Terms over time</p> |
| 184 | <ul className="mt-2 space-y-1 text-xs"> |
| 185 | {standing.periods.map((period) => ( |
| 186 | <li key={period.id} className="flex flex-wrap gap-x-2 text-fg-soft"> |
| 187 | <When at={period.startsAt} /> |
| 188 | <span>→</span> |
| 189 | {period.endsAt ? <When at={period.endsAt} /> : <span>now</span>} |
| 190 | <span> |
| 191 | {allowanceText(period.allowanceMicros, period.unlimited)}, fee {feeText(period.feeCents)},{" "} |
| 192 | {period.chargePastAllowance ? "then their card" : "then stops"} |
| 193 | </span> |
| 194 | <span className="text-muted"> |
| 195 | {period.createdBy}: {period.note} |
| 196 | </span> |
| 197 | </li> |
| 198 | ))} |
| 199 | </ul> |
| 200 | </div> |
| 201 | )} |
| 202 | </Section> |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | function Stat({ label, value }: { label: string; value: React.ReactNode }) { |
| 207 | return ( |
| 208 | <div> |
| 209 | <dt className="text-xs text-faint">{label}</dt> |
| 210 | <dd className="tabular-nums text-fg-soft">{value}</dd> |
| 211 | </div> |
| 212 | ); |
| 213 | } |