@meith/plugin-dues 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/admin.tsx CHANGED
@@ -8,19 +8,18 @@ import { describeBilling, isLifetime, loadPlans, MAX_PLAN_DAYS } from '../plans'
8
8
  import {
9
9
  allMemberships,
10
10
  attentionCount,
11
+ type CodeRow,
11
12
  listCodes,
13
+ type MembershipRow,
12
14
  monthlyTotals,
13
15
  ordersNeedingAttention,
16
+ type PlanRow,
14
17
  recentEvents,
15
18
  recentLedger,
16
- type CodeRow,
17
- type MembershipRow,
18
- type PlanRow,
19
19
  } from '../store'
20
20
  import { SUBSCRIBED_EVENT_TYPES } from '../stripe/events'
21
21
 
22
- const QUIET_PANEL =
23
- 'rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground'
22
+ const QUIET_PANEL = 'rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground'
24
23
 
25
24
  const CARD =
26
25
  'flex flex-col gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground ' +
@@ -36,8 +35,8 @@ const ACT_BUTTON =
36
35
  const QUIET_BUTTON =
37
36
  'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-sm'
38
37
 
39
- function fmt(date: Date): string {
40
- return date.toISOString().slice(0, 16).replace('T', ' ')
38
+ function fmt(date: Date, context: PluginAdminPageContext): string {
39
+ return context.t.parts(date, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })
41
40
  }
42
41
 
43
42
  function GoodNotice({ children }: { children: ReactNode }) {
@@ -59,13 +58,11 @@ function BadNotice({ children }: { children: ReactNode }) {
59
58
  )
60
59
  }
61
60
 
62
- function Attention({ count }: { count: number }) {
61
+ function Attention({ count, context }: { count: number; context: PluginAdminPageContext }) {
63
62
  if (count === 0) return null
64
63
  return (
65
64
  <p className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm">
66
- <strong>{count}</strong> record{count === 1 ? ' needs' : 's need'} attention —
67
- a payment that could not become a membership, or an amount that did not match its
68
- order. The members screen lists {count === 1 ? 'it' : 'them'} first.
65
+ {context.t.t('dues.admin.status.attention', { count })}
69
66
  </p>
70
67
  )
71
68
  }
@@ -87,26 +84,31 @@ export async function StatusPage({
87
84
 
88
85
  return (
89
86
  <div className="flex flex-col gap-4">
90
- {context.query.cleared !== undefined && <GoodNotice>Flag cleared.</GoodNotice>}
87
+ {context.query.cleared !== undefined && (
88
+ <GoodNotice>{context.t.t('dues.admin.status.flagCleared')}</GoodNotice>
89
+ )}
91
90
  {context.query.error !== undefined && (
92
- <BadNotice>That flag could not be found — it may already be cleared.</BadNotice>
91
+ <BadNotice>{context.t.t('dues.admin.status.flagMissing')}</BadNotice>
93
92
  )}
94
- <Attention count={attention} />
93
+ <Attention count={attention} context={context} />
95
94
 
96
95
  {flaggedOrders.length > 0 && (
97
96
  <section className={CARD}>
98
- <h2 className="font-heading text-lg font-semibold">Orders needing attention</h2>
97
+ <h2 className="font-heading text-lg font-semibold">
98
+ {context.t.t('dues.admin.status.orders')}
99
+ </h2>
99
100
  <p className="text-sm text-muted-foreground">
100
- Money moved but the order could not settle cleanly — most often an amount
101
- that did not match. Check the payment in Stripe&rsquo;s dashboard, put it
102
- right there, then clear the flag here.
101
+ {context.t.t('dues.admin.status.ordersText')}
103
102
  </p>
104
103
  <ul className="flex flex-col divide-y divide-border text-sm">
105
104
  {flaggedOrders.map((order) => (
106
105
  <li key={order.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
107
106
  <span>
108
- Order {order.id} — {order.planName},{' '}
109
- {formatMinor(order.amountMinor, order.currency)}
107
+ {context.t.t('dues.admin.status.order', {
108
+ id: order.id,
109
+ plan: order.planName,
110
+ amount: formatMinor(order.amountMinor, order.currency, context.locale),
111
+ })}
110
112
  <span className="block text-xs text-muted-foreground">
111
113
  {order.needsAttention}
112
114
  </span>
@@ -114,7 +116,7 @@ export async function StatusPage({
114
116
  <form method="post" action="/admin/api/plugins/dues/attention/clear">
115
117
  <input type="hidden" name="order" value={order.id} />
116
118
  <button type="submit" className={QUIET_BUTTON}>
117
- Clear the flag
119
+ {context.t.t('dues.admin.status.clear')}
118
120
  </button>
119
121
  </form>
120
122
  </li>
@@ -124,34 +126,40 @@ export async function StatusPage({
124
126
  )}
125
127
 
126
128
  <section className={CARD}>
127
- <h2 className="font-heading text-lg font-semibold">Is it working?</h2>
129
+ <h2 className="font-heading text-lg font-semibold">
130
+ {context.t.t('dues.admin.status.working')}
131
+ </h2>
128
132
  <dl className="flex flex-col gap-1 text-sm">
129
133
  <div className="flex justify-between gap-2">
130
- <dt className="text-muted-foreground">Stripe secret key</dt>
131
- <dd>{keySet ? 'set' : 'not set — nothing can be bought'}</dd>
134
+ <dt className="text-muted-foreground">{context.t.t('dues.admin.status.secret')}</dt>
135
+ <dd>
136
+ {context.t.t(keySet ? 'dues.admin.status.keySet' : 'dues.admin.status.keyUnset')}
137
+ </dd>
132
138
  </div>
133
139
  <div className="flex justify-between gap-2">
134
- <dt className="text-muted-foreground">Webhook signing secret</dt>
135
- <dd>{webhookSet ? 'set' : 'not set — payments cannot confirm'}</dd>
140
+ <dt className="text-muted-foreground">
141
+ {context.t.t('dues.admin.status.webhookSecret')}
142
+ </dt>
143
+ <dd>
144
+ {context.t.t(
145
+ webhookSet ? 'dues.admin.status.keySet' : 'dues.admin.status.webhookUnset',
146
+ )}
147
+ </dd>
136
148
  </div>
137
149
  <div className="flex justify-between gap-2">
138
- <dt className="text-muted-foreground">Grace after a failed renewal</dt>
139
- <dd>{config.graceDays} days</dd>
150
+ <dt className="text-muted-foreground">{context.t.t('dues.admin.status.grace')}</dt>
151
+ <dd>{context.t.t('dues.admin.status.days', { count: config.graceDays })}</dd>
140
152
  </div>
141
153
  </dl>
142
- <p className="text-xs text-muted-foreground">
143
- Keys resolve environment-first: the settings form below this page shows which
144
- source is winning.
145
- </p>
154
+ <p className="text-xs text-muted-foreground">{context.t.t('dues.admin.status.source')}</p>
146
155
  </section>
147
156
 
148
157
  <section className={CARD}>
149
- <h2 className="font-heading text-lg font-semibold">The webhook to create</h2>
158
+ <h2 className="font-heading text-lg font-semibold">
159
+ {context.t.t('dues.admin.status.webhook')}
160
+ </h2>
150
161
  <p className="text-sm text-muted-foreground">
151
- In the Stripe dashboard, add an endpoint at
152
- <code className="mx-1 text-xs">/api/plugins/dues/hook/stripe</code>
153
- on this board&rsquo;s public address, subscribed to exactly these events, and
154
- put its signing secret in the settings:
162
+ {context.t.t('dues.admin.status.webhookText')}
155
163
  </p>
156
164
  <p className="flex flex-wrap gap-x-3 gap-y-1">
157
165
  {SUBSCRIBED_EVENT_TYPES.map((type) => (
@@ -163,70 +171,72 @@ export async function StatusPage({
163
171
  </section>
164
172
 
165
173
  <section className={CARD}>
166
- <h2 className="font-heading text-lg font-semibold">Plans on sale</h2>
174
+ <h2 className="font-heading text-lg font-semibold">
175
+ {context.t.t('dues.admin.status.plans')}
176
+ </h2>
167
177
  <p className="text-sm text-muted-foreground">
168
- Plans are made and changed on the{' '}
169
- <a
170
- href="/admin/plugins/dues/plans"
171
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
172
- >
173
- plans screen
174
- </a>
175
- . Each grants membership of its group only while that group is marked
176
- &ldquo;may be granted by plugins&rdquo; under Admin → Groups; a purchase
177
- against a group that refuses shows up above as needing attention, with the
178
- payment kept and the reason recorded.
178
+ {context.t.t('dues.admin.status.plansText')}
179
179
  </p>
180
180
  <div className="overflow-x-auto">
181
181
  <table className="w-full min-w-96 border-collapse text-sm">
182
182
  <thead>
183
183
  <tr className="border-b border-border">
184
- <th className={TH}>Plan</th>
185
- <th className={TH}>Price</th>
186
- <th className={TH}>Billing</th>
187
- <th className={TH}>Group</th>
188
- <th className={TH}>Giftable</th>
184
+ <th className={TH}>{context.t.t('dues.admin.status.plan')}</th>
185
+ <th className={TH}>{context.t.t('dues.admin.status.price')}</th>
186
+ <th className={TH}>{context.t.t('dues.admin.status.billing')}</th>
187
+ <th className={TH}>{context.t.t('dues.admin.status.group')}</th>
188
+ <th className={TH}>{context.t.t('dues.admin.status.giftable')}</th>
189
189
  </tr>
190
190
  </thead>
191
191
  <tbody>
192
- {plans.filter((plan) => !plan.archived).map((plan) => (
193
- <tr key={plan.key} className="border-b border-border">
194
- <td className={TD}>
195
- {plan.name}
196
- {plan.hidden && (
197
- <span className="text-xs text-muted-foreground"> · hidden</span>
198
- )}
199
- </td>
200
- <td className={TD}>{formatMinor(plan.priceMinor, plan.currency)}</td>
201
- <td className={TD}>{describeBilling(plan)}</td>
202
- <td className={TD}>
203
- <code className="text-xs">{plan.groupKey}</code>
204
- </td>
205
- <td className={TD}>{plan.giftable ? 'yes' : 'no'}</td>
206
- </tr>
207
- ))}
192
+ {plans
193
+ .filter((plan) => !plan.archived)
194
+ .map((plan) => (
195
+ <tr key={plan.key} className="border-b border-border">
196
+ <td className={TD}>
197
+ {plan.name}
198
+ {plan.hidden && (
199
+ <span className="text-xs text-muted-foreground">
200
+ {' '}
201
+ · {context.t.t('dues.admin.status.hidden')}
202
+ </span>
203
+ )}
204
+ </td>
205
+ <td className={TD}>
206
+ {formatMinor(plan.priceMinor, plan.currency, context.locale)}
207
+ </td>
208
+ <td className={TD}>{describeBilling(plan)}</td>
209
+ <td className={TD}>
210
+ <code className="text-xs">{plan.groupKey}</code>
211
+ </td>
212
+ <td className={TD}>
213
+ {context.t.t(
214
+ plan.giftable ? 'dues.admin.status.yes' : 'dues.admin.status.no',
215
+ )}
216
+ </td>
217
+ </tr>
218
+ ))}
208
219
  </tbody>
209
220
  </table>
210
221
  </div>
211
222
  </section>
212
223
 
213
224
  <section className={CARD}>
214
- <h2 className="font-heading text-lg font-semibold">Latest webhook events</h2>
225
+ <h2 className="font-heading text-lg font-semibold">
226
+ {context.t.t('dues.admin.status.events')}
227
+ </h2>
215
228
  {events.length === 0 ? (
216
- <p className="text-sm text-muted-foreground">
217
- None yet. The first purchase, or Stripe&rsquo;s &ldquo;send test event&rdquo;
218
- button, will put a row here — which is how you prove the endpoint works.
219
- </p>
229
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.admin.status.none')}</p>
220
230
  ) : (
221
231
  <ul className="flex flex-col divide-y divide-border text-sm">
222
232
  {events.map((event) => (
223
233
  <li key={event.id} className="flex flex-wrap justify-between gap-2 py-1.5">
224
234
  <code className="text-xs">{event.type}</code>
225
235
  <span className="text-xs text-muted-foreground">
226
- {fmt(event.receivedAt)} ·{' '}
236
+ {fmt(event.receivedAt, context)} ·{' '}
227
237
  {event.processedAt === null
228
- ? 'unprocessed — the reconcile task retries it'
229
- : (event.outcome ?? 'done')}
238
+ ? context.t.t('dues.admin.status.unprocessed')
239
+ : (event.outcome ?? context.t.t('dues.admin.status.done'))}
230
240
  </span>
231
241
  </li>
232
242
  ))}
@@ -237,33 +247,39 @@ export async function StatusPage({
237
247
  )
238
248
  }
239
249
 
240
- function statusChip(membership: MembershipRow): ReactNode {
250
+ const STATUS_KEYS = {
251
+ active: 'dues.admin.members.active',
252
+ closing: 'dues.admin.members.closing',
253
+ expired: 'dues.admin.members.expired',
254
+ grace: 'dues.admin.members.grace',
255
+ revoked: 'dues.admin.members.revokedStatus',
256
+ } as const
257
+
258
+ function statusChip(membership: MembershipRow, context: PluginAdminPageContext): ReactNode {
241
259
  const tone =
242
260
  membership.status === 'active'
243
261
  ? 'text-muted-foreground'
244
262
  : membership.status === 'grace' || membership.needsAttention !== null
245
263
  ? ''
246
264
  : 'text-muted-foreground'
247
- return <span className={tone}>{membership.status}</span>
265
+ return <span className={tone}>{context.t.t(STATUS_KEYS[membership.status])}</span>
248
266
  }
249
267
 
250
- const MEMBER_NOTICES: Record<string, string> = {
251
- extended: 'Membership extended. The member holds their group until the new date.',
252
- cancelled: 'Renewal cancelled. They keep what they paid for until the period ends.',
253
- revoked: 'Membership revoked. Their access is gone as of now.',
254
- cleared: 'Flag cleared.',
255
- }
256
-
257
- const MEMBER_ERRORS: Record<string, string> = {
258
- 'bad-days': 'Extensions are 1 to 366 days.',
259
- 'not-live': 'That membership is not live any more, so there is nothing to act on.',
260
- 'not-cancellable': 'Only a live subscription has a renewal to cancel.',
261
- unconfigured: 'Stripe is not configured, so the subscription cannot be reached.',
262
- 'stripe-error': 'Stripe could not be reached. Nothing changed — try again shortly.',
263
- 'grant-refused':
264
- 'The extension was recorded, but the board refused the group grant — the row is ' +
265
- 'flagged with the reason.',
266
- }
268
+ const MEMBER_NOTICE_KEYS = new Set([
269
+ 'dues.admin.members.cancelled',
270
+ 'dues.admin.members.cleared',
271
+ 'dues.admin.members.extended',
272
+ 'dues.admin.members.revoked',
273
+ ])
274
+
275
+ const MEMBER_ERROR_KEYS = new Set([
276
+ 'dues.admin.members.badDays',
277
+ 'dues.admin.members.grantRefused',
278
+ 'dues.admin.members.noMembership',
279
+ 'dues.admin.members.noRenewal',
280
+ 'dues.admin.members.stripeError',
281
+ 'dues.admin.members.stripeUnconfigured',
282
+ ])
267
283
 
268
284
  function isLiveRow(membership: MembershipRow): boolean {
269
285
  return (
@@ -273,7 +289,13 @@ function isLiveRow(membership: MembershipRow): boolean {
273
289
  )
274
290
  }
275
291
 
276
- function MemberActions({ membership }: { membership: MembershipRow }) {
292
+ function MemberActions({
293
+ membership,
294
+ context,
295
+ }: {
296
+ membership: MembershipRow
297
+ context: PluginAdminPageContext
298
+ }) {
277
299
  if (!isLiveRow(membership) && membership.needsAttention === null) {
278
300
  return <span className="text-xs text-muted-foreground">—</span>
279
301
  }
@@ -293,11 +315,11 @@ function MemberActions({ membership }: { membership: MembershipRow }) {
293
315
  defaultValue={30}
294
316
  min={1}
295
317
  max={366}
296
- aria-label={`Days to extend membership ${membership.id} by`}
318
+ aria-label={context.t.t('dues.admin.members.extendAria', { id: membership.id })}
297
319
  className={`${INPUT} w-20`}
298
320
  />
299
321
  <button type="submit" className={QUIET_BUTTON}>
300
- Extend
322
+ {context.t.t('dues.admin.members.extend')}
301
323
  </button>
302
324
  </form>
303
325
  )}
@@ -308,7 +330,7 @@ function MemberActions({ membership }: { membership: MembershipRow }) {
308
330
  <form method="post" action="/admin/api/plugins/dues/members/cancel">
309
331
  <input type="hidden" name="membership" value={membership.id} />
310
332
  <button type="submit" className={QUIET_BUTTON}>
311
- Cancel renewal
333
+ {context.t.t('dues.admin.members.cancel')}
312
334
  </button>
313
335
  </form>
314
336
  )}
@@ -316,7 +338,7 @@ function MemberActions({ membership }: { membership: MembershipRow }) {
316
338
  <form method="post" action="/admin/api/plugins/dues/members/revoke">
317
339
  <input type="hidden" name="membership" value={membership.id} />
318
340
  <button type="submit" className={QUIET_BUTTON}>
319
- Revoke now
341
+ {context.t.t('dues.admin.members.revoke')}
320
342
  </button>
321
343
  </form>
322
344
  )}
@@ -324,7 +346,7 @@ function MemberActions({ membership }: { membership: MembershipRow }) {
324
346
  <form method="post" action="/admin/api/plugins/dues/attention/clear">
325
347
  <input type="hidden" name="membership" value={membership.id} />
326
348
  <button type="submit" className={QUIET_BUTTON}>
327
- Clear the flag
349
+ {context.t.t('dues.admin.members.clear')}
328
350
  </button>
329
351
  </form>
330
352
  )}
@@ -343,35 +365,32 @@ export async function MembersPage({ context }: { context: PluginAdminPageContext
343
365
  }
344
366
  }
345
367
 
346
- const good = Object.keys(MEMBER_NOTICES).find((key) => context.query[key] !== undefined)
347
- const bad = MEMBER_ERRORS[context.query.error ?? '']
368
+ const good = `dues.admin.members.${Object.keys(context.query).find((key) =>
369
+ MEMBER_NOTICE_KEYS.has(`dues.admin.members.${key}`),
370
+ )}`
371
+ const bad = `dues.admin.members.${context.query.error ?? ''}`
348
372
 
349
373
  return (
350
374
  <div className="flex flex-col gap-4">
351
- {good !== undefined && <GoodNotice>{MEMBER_NOTICES[good]}</GoodNotice>}
352
- {bad !== undefined && <BadNotice>{bad}</BadNotice>}
375
+ {MEMBER_NOTICE_KEYS.has(good) && <GoodNotice>{context.t.t(good)}</GoodNotice>}
376
+ {MEMBER_ERROR_KEYS.has(bad) && <BadNotice>{context.t.t(bad)}</BadNotice>}
353
377
  <p className="text-sm text-muted-foreground">
354
- Every membership this plugin has sold, flagged rows first. Extending is a grant,
355
- not a charge; revoking removes access on the spot without touching the money —
356
- refunds happen in Stripe&rsquo;s dashboard, and the refund webhook revokes on
357
- its own.
378
+ {context.t.t('dues.admin.members.description')}
358
379
  </p>
359
380
  {memberships.length === 0 ? (
360
- <p className={QUIET_PANEL}>
361
- Nothing sold yet.
362
- </p>
381
+ <p className={QUIET_PANEL}>{context.t.t('dues.admin.members.none')}</p>
363
382
  ) : (
364
383
  <div className="overflow-x-auto">
365
384
  <table className="w-full min-w-[48rem] border-collapse text-sm">
366
385
  <thead>
367
386
  <tr className="border-b border-border">
368
- <th className={TH}>Member</th>
369
- <th className={TH}>Plan</th>
370
- <th className={TH}>Status</th>
371
- <th className={TH}>Period ends</th>
372
- <th className={TH}>Grace until</th>
373
- <th className={TH}>Subscription</th>
374
- <th className={TH}>Actions</th>
387
+ <th className={TH}>{context.t.t('dues.admin.members.member')}</th>
388
+ <th className={TH}>{context.t.t('dues.admin.members.plan')}</th>
389
+ <th className={TH}>{context.t.t('dues.admin.members.status')}</th>
390
+ <th className={TH}>{context.t.t('dues.admin.members.periodEnds')}</th>
391
+ <th className={TH}>{context.t.t('dues.admin.members.graceUntil')}</th>
392
+ <th className={TH}>{context.t.t('dues.admin.members.subscription')}</th>
393
+ <th className={TH}>{context.t.t('dues.admin.members.actions')}</th>
375
394
  </tr>
376
395
  </thead>
377
396
  <tbody>
@@ -380,7 +399,7 @@ export async function MembersPage({ context }: { context: PluginAdminPageContext
380
399
  <td className={TD}>{names.get(membership.userId)}</td>
381
400
  <td className={TD}>{membership.planKey}</td>
382
401
  <td className={TD}>
383
- {statusChip(membership)}
402
+ {statusChip(membership, context)}
384
403
  {membership.needsAttention !== null && (
385
404
  <p className="mt-1 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs">
386
405
  {membership.needsAttention}
@@ -389,17 +408,19 @@ export async function MembersPage({ context }: { context: PluginAdminPageContext
389
408
  </td>
390
409
  <td className={TD}>
391
410
  {isLifetime(membership.currentPeriodEnd)
392
- ? 'for good'
393
- : fmt(membership.currentPeriodEnd)}
411
+ ? context.t.t('dues.admin.members.forGood')
412
+ : fmt(membership.currentPeriodEnd, context)}
394
413
  </td>
395
414
  <td className={TD}>
396
- {isLifetime(membership.currentPeriodEnd) ? '—' : fmt(membership.graceUntil)}
415
+ {isLifetime(membership.currentPeriodEnd)
416
+ ? '—'
417
+ : fmt(membership.graceUntil, context)}
397
418
  </td>
398
419
  <td className={TD}>
399
420
  <code className="text-xs">{membership.stripeSubscriptionId ?? '—'}</code>
400
421
  </td>
401
422
  <td className={TD}>
402
- <MemberActions membership={membership} />
423
+ <MemberActions membership={membership} context={context} />
403
424
  </td>
404
425
  </tr>
405
426
  ))}
@@ -424,17 +445,21 @@ export async function LedgerPage({
424
445
  return (
425
446
  <div className="flex flex-col gap-4">
426
447
  <section className={CARD}>
427
- <h2 className="font-heading text-lg font-semibold">By month</h2>
448
+ <h2 className="font-heading text-lg font-semibold">
449
+ {context.t.t('dues.admin.ledger.byMonth')}
450
+ </h2>
428
451
  {months.length === 0 ? (
429
- <p className="text-sm text-muted-foreground">No money has moved yet.</p>
452
+ <p className="text-sm text-muted-foreground">
453
+ {context.t.t('dues.admin.ledger.noMoney')}
454
+ </p>
430
455
  ) : (
431
456
  <table className="w-full border-collapse text-sm">
432
457
  <thead>
433
458
  <tr className="border-b border-border">
434
- <th className={TH}>Month</th>
435
- <th className={TH}>Charges</th>
436
- <th className={TH}>Gross</th>
437
- <th className={TH}>Refunded</th>
459
+ <th className={TH}>{context.t.t('dues.admin.ledger.month')}</th>
460
+ <th className={TH}>{context.t.t('dues.admin.ledger.charges')}</th>
461
+ <th className={TH}>{context.t.t('dues.admin.ledger.gross')}</th>
462
+ <th className={TH}>{context.t.t('dues.admin.ledger.refunded')}</th>
438
463
  </tr>
439
464
  </thead>
440
465
  <tbody>
@@ -442,11 +467,13 @@ export async function LedgerPage({
442
467
  <tr key={`${month.month}-${month.currency}`} className="border-b border-border">
443
468
  <td className={TD}>{month.month}</td>
444
469
  <td className={TD}>{month.charges}</td>
445
- <td className={TD}>{formatMinor(month.grossMinor, month.currency)}</td>
470
+ <td className={TD}>
471
+ {formatMinor(month.grossMinor, month.currency, context.locale)}
472
+ </td>
446
473
  <td className={TD}>
447
474
  {month.refundedMinor === 0
448
475
  ? '—'
449
- : formatMinor(month.refundedMinor, month.currency)}
476
+ : formatMinor(month.refundedMinor, month.currency, context.locale)}
450
477
  </td>
451
478
  </tr>
452
479
  ))}
@@ -454,16 +481,16 @@ export async function LedgerPage({
454
481
  </table>
455
482
  )}
456
483
  <p className="text-xs text-muted-foreground">
457
- Append-only, written as money moves: charges positive, refunds and chargebacks
458
- negative. Stripe&rsquo;s dashboard is the authority; this is the board&rsquo;s
459
- own copy in {config.currency.toUpperCase()}.
484
+ {context.t.t('dues.admin.ledger.appendOnly', { currency: config.currency.toUpperCase() })}
460
485
  </p>
461
486
  </section>
462
487
 
463
488
  <section className={CARD}>
464
- <h2 className="font-heading text-lg font-semibold">Latest entries</h2>
489
+ <h2 className="font-heading text-lg font-semibold">
490
+ {context.t.t('dues.admin.ledger.latest')}
491
+ </h2>
465
492
  {entries.length === 0 ? (
466
- <p className="text-sm text-muted-foreground">Empty.</p>
493
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.admin.ledger.empty')}</p>
467
494
  ) : (
468
495
  <ul className="flex flex-col divide-y divide-border text-sm">
469
496
  {entries.map((entry) => (
@@ -475,7 +502,8 @@ export async function LedgerPage({
475
502
  )}
476
503
  </span>
477
504
  <span className="text-xs text-muted-foreground">
478
- {fmt(entry.occurredAt)} · {formatMinor(entry.amountMinor, entry.currency)}
505
+ {fmt(entry.occurredAt, context)} ·{' '}
506
+ {formatMinor(entry.amountMinor, entry.currency, context.locale)}
479
507
  </span>
480
508
  </li>
481
509
  ))}
@@ -486,23 +514,24 @@ export async function LedgerPage({
486
514
  )
487
515
  }
488
516
 
489
- const CODE_ERRORS: Record<string, string> = {
490
- 'bad-code': 'Codes are 3 to 32 letters, digits and hyphens. Leave the box empty to have one invented.',
491
- 'bad-percent': 'The discount is a whole number from 1 to 100 percent.',
492
- 'bad-plan': 'That plan does not exist on this board.',
493
- 'bad-max': 'The redemption cap is a whole number, at least 1 — or empty for no cap.',
494
- 'bad-expiry': 'The expiry needs to be a date in the future, like 2027-01-31.',
495
- 'duplicate-code': 'A code by that name already exists. Every code is unique, spelt any case.',
496
- 'no-such-code': 'That code could not be found.',
497
- }
498
-
499
- function codeState(code: CodeRow, now: Date): string {
500
- if (code.disabled) return 'switched off'
501
- if (code.expiresAt !== null && code.expiresAt <= now) return 'expired'
517
+ const CODE_ERROR_KEYS = {
518
+ 'bad-code': 'dues.admin.codes.badCode',
519
+ 'bad-expiry': 'dues.admin.codes.badExpiry',
520
+ 'bad-max': 'dues.admin.codes.badMax',
521
+ 'bad-percent': 'dues.admin.codes.badPercent',
522
+ 'bad-plan': 'dues.admin.codes.badPlan',
523
+ 'duplicate-code': 'dues.admin.codes.duplicate',
524
+ 'no-such-code': 'dues.admin.codes.noSuch',
525
+ } as const
526
+
527
+ function codeState(code: CodeRow, now: Date, context: PluginAdminPageContext): string {
528
+ if (code.disabled) return context.t.t('dues.admin.codes.switchedOff')
529
+ if (code.expiresAt !== null && code.expiresAt <= now)
530
+ return context.t.t('dues.admin.codes.expired')
502
531
  if (code.maxRedemptions !== null && code.redeemedCount >= code.maxRedemptions) {
503
- return 'used up'
532
+ return context.t.t('dues.admin.codes.usedUp')
504
533
  }
505
- return 'live'
534
+ return context.t.t('dues.admin.codes.live')
506
535
  }
507
536
 
508
537
  export async function CodesPage({
@@ -517,34 +546,41 @@ export async function CodesPage({
517
546
  const now = new Date()
518
547
  const created = context.query.created
519
548
  const toggled = context.query.disabled ?? context.query.enabled
520
- const error = CODE_ERRORS[context.query.error ?? '']
549
+ const error = CODE_ERROR_KEYS[context.query.error as keyof typeof CODE_ERROR_KEYS]
550
+ const [createdLead, createdTail] = context.t
551
+ .t('dues.admin.codes.created', { code: '{code}' })
552
+ .split('{code}')
521
553
 
522
554
  return (
523
555
  <div className="flex flex-col gap-4">
524
556
  {created !== undefined && (
525
557
  <GoodNotice>
526
- The code is live: <code className="mx-1 font-mono text-base font-semibold">{created}</code>
527
- hand it out however you like. It is shown in full below whenever you need it
528
- again.
558
+ {createdLead}
559
+ <code className="mx-1 font-mono text-base font-semibold">{created}</code>
560
+ {createdTail}
529
561
  </GoodNotice>
530
562
  )}
531
563
  {toggled !== undefined && (
532
564
  <GoodNotice>
533
- <code className="font-mono">{toggled}</code>{' '}
534
565
  {context.query.disabled !== undefined
535
- ? 'is switched off. Anyone typing it now is told the code is not usable.'
536
- : 'is back on.'}
566
+ ? context.t.t('dues.admin.codes.toggledOff', { code: toggled })
567
+ : context.t.t('dues.admin.codes.toggledOn', { code: toggled })}
537
568
  </GoodNotice>
538
569
  )}
539
- {error !== undefined && <BadNotice>{error}</BadNotice>}
570
+ {error !== undefined && (
571
+ <BadNotice>
572
+ {context.t.t(
573
+ error,
574
+ context.query.error === 'too-long' ? { days: MAX_PLAN_DAYS } : undefined,
575
+ )}
576
+ </BadNotice>
577
+ )}
540
578
 
541
579
  <section className={CARD}>
542
- <h2 className="font-heading text-lg font-semibold">Mint a code</h2>
543
- <p className="text-sm text-muted-foreground">
544
- A code takes a percentage off at checkout: the whole price of a pass, the first
545
- payment of a subscription — renewals bill in full. A 100% code on a pass skips
546
- Stripe entirely, which is how you comp somebody.
547
- </p>
580
+ <h2 className="font-heading text-lg font-semibold">
581
+ {context.t.t('dues.admin.codes.mint')}
582
+ </h2>
583
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.admin.codes.intro')}</p>
548
584
  <form
549
585
  method="post"
550
586
  action="/admin/api/plugins/dues/codes/create"
@@ -552,25 +588,22 @@ export async function CodesPage({
552
588
  >
553
589
  <label className="flex flex-col gap-1 text-sm">
554
590
  <span className="text-xs text-muted-foreground">
555
- Code — leave empty to have one invented
591
+ {context.t.t('dues.admin.codes.codeHelp')}
556
592
  </span>
557
593
  <input name="code" autoComplete="off" placeholder="LAUNCH50" className={INPUT} />
558
594
  </label>
559
595
  <label className="flex flex-col gap-1 text-sm">
560
- <span className="text-xs text-muted-foreground">Percent off, 1–100</span>
561
- <input
562
- type="number"
563
- name="percent"
564
- min={1}
565
- max={100}
566
- required
567
- className={INPUT}
568
- />
596
+ <span className="text-xs text-muted-foreground">
597
+ {context.t.t('dues.admin.codes.percentHelp')}
598
+ </span>
599
+ <input type="number" name="percent" min={1} max={100} required className={INPUT} />
569
600
  </label>
570
601
  <label className="flex flex-col gap-1 text-sm">
571
- <span className="text-xs text-muted-foreground">Which plan it works on</span>
602
+ <span className="text-xs text-muted-foreground">
603
+ {context.t.t('dues.admin.codes.planHelp')}
604
+ </span>
572
605
  <select name="plan" className={INPUT}>
573
- <option value="">Any plan</option>
606
+ <option value="">{context.t.t('dues.admin.codes.anyPlan')}</option>
574
607
  {plans.map((plan) => (
575
608
  <option key={plan.key} value={plan.key}>
576
609
  {plan.name}
@@ -580,40 +613,42 @@ export async function CodesPage({
580
613
  </label>
581
614
  <label className="flex flex-col gap-1 text-sm">
582
615
  <span className="text-xs text-muted-foreground">
583
- Redemption cap — empty for unlimited
616
+ {context.t.t('dues.admin.codes.redemptionCap')}
584
617
  </span>
585
618
  <input type="number" name="max" min={1} className={INPUT} />
586
619
  </label>
587
620
  <label className="flex flex-col gap-1 text-sm">
588
621
  <span className="text-xs text-muted-foreground">
589
- Expires at end of day (UTC) — empty for never
622
+ {context.t.t('dues.admin.codes.expiresHelp')}
590
623
  </span>
591
624
  <input type="date" name="expires" className={INPUT} />
592
625
  </label>
593
626
  <div className="flex items-end">
594
627
  <button type="submit" className={ACT_BUTTON}>
595
- Mint the code
628
+ {context.t.t('dues.admin.codes.mintButton')}
596
629
  </button>
597
630
  </div>
598
631
  </form>
599
632
  </section>
600
633
 
601
634
  <section className={CARD}>
602
- <h2 className="font-heading text-lg font-semibold">Every code</h2>
635
+ <h2 className="font-heading text-lg font-semibold">
636
+ {context.t.t('dues.admin.codes.every')}
637
+ </h2>
603
638
  {codes.length === 0 ? (
604
- <p className="text-sm text-muted-foreground">None yet.</p>
639
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.admin.codes.empty')}</p>
605
640
  ) : (
606
641
  <div className="overflow-x-auto">
607
642
  <table className="w-full min-w-[40rem] border-collapse text-sm">
608
643
  <thead>
609
644
  <tr className="border-b border-border">
610
- <th className={TH}>Code</th>
611
- <th className={TH}>Off</th>
612
- <th className={TH}>Plan</th>
613
- <th className={TH}>Redeemed</th>
614
- <th className={TH}>Expires</th>
615
- <th className={TH}>State</th>
616
- <th className={TH}>Actions</th>
645
+ <th className={TH}>{context.t.t('dues.admin.codes.code')}</th>
646
+ <th className={TH}>{context.t.t('dues.admin.codes.off')}</th>
647
+ <th className={TH}>{context.t.t('dues.admin.codes.plan')}</th>
648
+ <th className={TH}>{context.t.t('dues.admin.codes.redeemed')}</th>
649
+ <th className={TH}>{context.t.t('dues.admin.codes.expires')}</th>
650
+ <th className={TH}>{context.t.t('dues.admin.codes.state')}</th>
651
+ <th className={TH}>{context.t.t('dues.admin.codes.actions')}</th>
617
652
  </tr>
618
653
  </thead>
619
654
  <tbody>
@@ -623,25 +658,27 @@ export async function CodesPage({
623
658
  <code className="font-mono">{code.code}</code>
624
659
  </td>
625
660
  <td className={TD}>{code.percentOff}%</td>
626
- <td className={TD}>{code.planKey ?? 'any'}</td>
661
+ <td className={TD}>{code.planKey ?? context.t.t('dues.admin.codes.any')}</td>
627
662
  <td className={TD}>
628
663
  {code.redeemedCount}
629
- {code.maxRedemptions !== null && ` of ${code.maxRedemptions}`}
664
+ {code.maxRedemptions !== null && ` / ${code.maxRedemptions}`}
630
665
  </td>
631
666
  <td className={TD}>
632
- {code.expiresAt === null ? 'never' : fmt(code.expiresAt)}
667
+ {code.expiresAt === null
668
+ ? context.t.t('dues.admin.codes.never')
669
+ : fmt(code.expiresAt, context)}
633
670
  </td>
634
- <td className={TD}>{codeState(code, now)}</td>
671
+ <td className={TD}>{codeState(code, now, context)}</td>
635
672
  <td className={TD}>
636
673
  <form method="post" action="/admin/api/plugins/dues/codes/disable">
637
674
  <input type="hidden" name="code" value={code.id} />
638
- <input
639
- type="hidden"
640
- name="disabled"
641
- value={code.disabled ? '0' : '1'}
642
- />
675
+ <input type="hidden" name="disabled" value={code.disabled ? '0' : '1'} />
643
676
  <button type="submit" className={QUIET_BUTTON}>
644
- {code.disabled ? 'Switch on' : 'Switch off'}
677
+ {context.t.t(
678
+ code.disabled
679
+ ? 'dues.admin.codes.switchOn'
680
+ : 'dues.admin.codes.switchOff',
681
+ )}
645
682
  </button>
646
683
  </form>
647
684
  </td>
@@ -652,44 +689,37 @@ export async function CodesPage({
652
689
  </div>
653
690
  )}
654
691
  <p className="text-xs text-muted-foreground">
655
- Redemptions count when a payment settles, not when a checkout starts. A code is
656
- never deleted — switching it off keeps the history of what it sold.
692
+ {context.t.t('dues.admin.codes.redemptions')}
657
693
  </p>
658
694
  </section>
659
695
  </div>
660
696
  )
661
697
  }
662
698
 
663
- const PLAN_ERRORS: Record<string, string> = {
664
- 'bad-key': 'Plan keys are lower-case letters, digits and hyphens, like day-pass.',
665
- 'bad-name': 'The plan needs a name members will read.',
666
- 'bad-group': 'That is not a valid group key.',
667
- 'bad-price': 'The price is a positive whole number of minor units — 500 is £5.00.',
668
- 'bad-currency': 'The currency is a three-letter ISO code, like gbp, usd or eur.',
669
- 'bad-mode': 'Pick how the plan bills.',
670
- 'bad-length': 'The length is a whole number of days, weeks, months or years — at least 1.',
671
- 'too-long': `A pass plus its grace window cannot reach past two years (${MAX_PLAN_DAYS} days) — that is the board's cap on a plugin grant. Sell lifetime instead.`,
672
- 'bad-interval': 'A subscription bills every month or every year.',
673
- 'auto-gift': 'A subscription cannot be giftable — it would bill the buyer forever.',
674
- 'duplicate-plan': 'A plan with that key already exists. Keys are forever; pick another.',
675
- 'no-such-plan': 'That plan could not be found.',
676
- 'bad-stripe-price': 'A pasted Stripe price id starts with price_.',
677
- unconfigured:
678
- 'Stripe is not configured, so a subscription price cannot be minted. Set the secret ' +
679
- 'key first, or paste a price id made in the Stripe dashboard.',
680
- 'stripe-error': 'Stripe could not be reached. Nothing was saved — try again shortly.',
681
- }
682
-
683
- const PLAN_NOTICES: Record<string, (key: string) => string> = {
684
- created: (key) => `The plan ${key} is on sale from this moment.`,
685
- updated: (key) =>
686
- `${key} is updated. Existing memberships and running subscriptions keep what they ` +
687
- 'bought; the change is for the next buyer.',
688
- archived: (key) =>
689
- `${key} is off sale. Everyone who holds it keeps it — archiving stops new purchases, ` +
690
- 'nothing else.',
691
- restored: (key) => `${key} is back on sale.`,
692
- }
699
+ const PLAN_ERROR_KEYS = {
700
+ 'auto-gift': 'dues.admin.plans.autoGift',
701
+ 'bad-currency': 'dues.admin.plans.badCurrency',
702
+ 'bad-group': 'dues.admin.plans.badGroup',
703
+ 'bad-interval': 'dues.admin.plans.badInterval',
704
+ 'bad-key': 'dues.admin.plans.badKey',
705
+ 'bad-length': 'dues.admin.plans.badLength',
706
+ 'bad-mode': 'dues.admin.plans.badMode',
707
+ 'bad-name': 'dues.admin.plans.badName',
708
+ 'bad-price': 'dues.admin.plans.badPrice',
709
+ 'bad-stripe-price': 'dues.admin.plans.badStripePrice',
710
+ 'duplicate-plan': 'dues.admin.plans.duplicate',
711
+ 'no-such-plan': 'dues.admin.plans.noSuch',
712
+ 'stripe-error': 'dues.admin.plans.stripeError',
713
+ 'too-long': 'dues.admin.plans.tooLong',
714
+ unconfigured: 'dues.admin.plans.stripeUnconfigured',
715
+ } as const
716
+
717
+ const PLAN_NOTICE_KEYS = {
718
+ archived: 'dues.admin.plans.archived',
719
+ created: 'dues.admin.plans.created',
720
+ restored: 'dues.admin.plans.restored',
721
+ updated: 'dues.admin.plans.updated',
722
+ } as const
693
723
 
694
724
  function planPeriodParts(plan: PlanRow): { length: number; unit: string } {
695
725
  const match = /^P(\d+)([YMWD])$/.exec(plan.periodSpec ?? '')
@@ -699,28 +729,32 @@ function planPeriodParts(plan: PlanRow): { length: number; unit: string } {
699
729
  return { length: Number(match[1]), unit }
700
730
  }
701
731
 
702
- function PlanFields({ plan }: { plan?: PlanRow }) {
732
+ function PlanFields({ plan, context }: { plan?: PlanRow; context: PluginAdminPageContext }) {
703
733
  const period = plan === undefined ? { length: 90, unit: 'days' } : planPeriodParts(plan)
704
734
  return (
705
735
  <>
706
736
  <label className="flex flex-col gap-1 text-sm">
707
- <span className="text-xs text-muted-foreground">Name</span>
737
+ <span className="text-xs text-muted-foreground">
738
+ {context.t.t('dues.admin.plans.name')}
739
+ </span>
708
740
  <input name="name" defaultValue={plan?.name ?? ''} required className={INPUT} />
709
741
  </label>
710
742
  <label className="flex flex-col gap-1 text-sm">
711
- <span className="text-xs text-muted-foreground">Description — optional</span>
743
+ <span className="text-xs text-muted-foreground">
744
+ {context.t.t('dues.admin.plans.description')}
745
+ </span>
712
746
  <input name="description" defaultValue={plan?.description ?? ''} className={INPUT} />
713
747
  </label>
714
748
  <label className="flex flex-col gap-1 text-sm">
715
749
  <span className="text-xs text-muted-foreground">
716
- Group it grants (marked plugin-grantable under Admin → Groups)
750
+ {context.t.t('dues.admin.plans.giftGroup')}
717
751
  </span>
718
752
  <input name="group" defaultValue={plan?.groupKey ?? ''} required className={INPUT} />
719
753
  </label>
720
754
  <div className="grid grid-cols-2 gap-3">
721
755
  <label className="flex flex-col gap-1 text-sm">
722
756
  <span className="text-xs text-muted-foreground">
723
- Price in minor units — 500 is £5.00
757
+ {context.t.t('dues.admin.plans.price')}
724
758
  </span>
725
759
  <input
726
760
  type="number"
@@ -732,7 +766,9 @@ function PlanFields({ plan }: { plan?: PlanRow }) {
732
766
  />
733
767
  </label>
734
768
  <label className="flex flex-col gap-1 text-sm">
735
- <span className="text-xs text-muted-foreground">Currency</span>
769
+ <span className="text-xs text-muted-foreground">
770
+ {context.t.t('dues.admin.plans.currency')}
771
+ </span>
736
772
  <input
737
773
  name="currency"
738
774
  defaultValue={plan?.currency ?? ''}
@@ -746,7 +782,9 @@ function PlanFields({ plan }: { plan?: PlanRow }) {
746
782
  {(plan === undefined || plan.mode === 'fixed') && (
747
783
  <div className="grid grid-cols-2 gap-3">
748
784
  <label className="flex flex-col gap-1 text-sm">
749
- <span className="text-xs text-muted-foreground">Pass length</span>
785
+ <span className="text-xs text-muted-foreground">
786
+ {context.t.t('dues.admin.plans.length')}
787
+ </span>
750
788
  <input
751
789
  type="number"
752
790
  name="length"
@@ -756,36 +794,35 @@ function PlanFields({ plan }: { plan?: PlanRow }) {
756
794
  />
757
795
  </label>
758
796
  <label className="flex flex-col gap-1 text-sm">
759
- <span className="text-xs text-muted-foreground">…counted in</span>
797
+ <span className="text-xs text-muted-foreground">
798
+ {context.t.t('dues.admin.plans.unit')}
799
+ </span>
760
800
  <select name="unit" defaultValue={period.unit} className={INPUT}>
761
- <option value="days">days</option>
762
- <option value="weeks">weeks</option>
763
- <option value="months">months</option>
764
- <option value="years">years</option>
801
+ <option value="days">{context.t.t('dues.admin.plans.days')}</option>
802
+ <option value="weeks">{context.t.t('dues.admin.plans.weeks')}</option>
803
+ <option value="months">{context.t.t('dues.admin.plans.months')}</option>
804
+ <option value="years">{context.t.t('dues.admin.plans.years')}</option>
765
805
  </select>
766
806
  </label>
767
807
  </div>
768
808
  )}
769
809
  {(plan === undefined || plan.mode === 'auto') && (
770
810
  <label className="flex flex-col gap-1 text-sm">
771
- <span className="text-xs text-muted-foreground">Subscription bills every</span>
811
+ <span className="text-xs text-muted-foreground">
812
+ {context.t.t('dues.admin.plans.interval')}
813
+ </span>
772
814
  <select name="interval" defaultValue={plan?.billingInterval ?? 'month'} className={INPUT}>
773
- <option value="month">month</option>
774
- <option value="year">year</option>
815
+ <option value="month">{context.t.t('dues.admin.plans.month')}</option>
816
+ <option value="year">{context.t.t('dues.admin.plans.year')}</option>
775
817
  </select>
776
818
  </label>
777
819
  )}
778
820
  {(plan === undefined || plan.mode === 'auto') && (
779
821
  <label className="flex flex-col gap-1 text-sm">
780
822
  <span className="text-xs text-muted-foreground">
781
- Stripe price id — leave empty and one is minted to match
823
+ {context.t.t('dues.admin.plans.stripePrice')}
782
824
  </span>
783
- <input
784
- name="stripe_price"
785
- placeholder="price_…"
786
- autoComplete="off"
787
- className={INPUT}
788
- />
825
+ <input name="stripe_price" placeholder="price_…" autoComplete="off" className={INPUT} />
789
826
  </label>
790
827
  )}
791
828
  <div className="flex flex-wrap gap-4 text-sm">
@@ -795,11 +832,11 @@ function PlanFields({ plan }: { plan?: PlanRow }) {
795
832
  name="giftable"
796
833
  defaultChecked={plan === undefined ? true : plan.giftable}
797
834
  />
798
- Can be bought for another member
835
+ {context.t.t('dues.admin.plans.canGift')}
799
836
  </label>
800
837
  <label className="flex items-center gap-2">
801
838
  <input type="checkbox" name="hidden" defaultChecked={plan?.hidden ?? false} />
802
- Hidden from the shop
839
+ {context.t.t('dues.admin.plans.hidden')}
803
840
  </label>
804
841
  </div>
805
842
  </>
@@ -814,26 +851,27 @@ export async function PlansAdminPage({
814
851
  context: PluginAdminPageContext
815
852
  }) {
816
853
  const plans = await loadPlans(context.data, config)
817
- const notice = Object.keys(PLAN_NOTICES).find((key) => context.query[key] !== undefined)
818
- const error = PLAN_ERRORS[context.query.error ?? '']
854
+ const notice = Object.keys(PLAN_NOTICE_KEYS).find((key) => context.query[key] !== undefined)
855
+ const error = PLAN_ERROR_KEYS[context.query.error as keyof typeof PLAN_ERROR_KEYS]
819
856
 
820
857
  return (
821
858
  <div className="flex flex-col gap-4">
822
859
  {notice !== undefined && (
823
- <GoodNotice>{PLAN_NOTICES[notice]!(context.query[notice]!)}</GoodNotice>
860
+ <GoodNotice>
861
+ {context.t.t(PLAN_NOTICE_KEYS[notice as keyof typeof PLAN_NOTICE_KEYS], {
862
+ key: context.query[notice],
863
+ })}
864
+ </GoodNotice>
824
865
  )}
825
866
  {error !== undefined && <BadNotice>{error}</BadNotice>}
826
867
 
827
- <p className="text-sm text-muted-foreground">
828
- Every purchase snapshots its plan — the name, the price, the currency, the length
829
- — so editing here never rewrites what anyone already bought. A subscription price
830
- change mints a new Stripe price: running subscriptions keep billing what they
831
- signed up for, and only the next buyer sees the new number.
832
- </p>
868
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.admin.plans.editing')}</p>
833
869
 
834
870
  {plans.length > 0 && (
835
871
  <section className={CARD}>
836
- <h2 className="font-heading text-lg font-semibold">The plans</h2>
872
+ <h2 className="font-heading text-lg font-semibold">
873
+ {context.t.t('dues.admin.plans.thePlans')}
874
+ </h2>
837
875
  <ul className="flex flex-col divide-y divide-border">
838
876
  {plans.map((plan) => (
839
877
  <li key={plan.id} className="flex flex-col gap-3 py-4">
@@ -843,20 +881,23 @@ export async function PlansAdminPage({
843
881
  {plan.name}
844
882
  </span>
845
883
  <span className="text-sm text-muted-foreground">
846
- {formatMinor(plan.priceMinor, plan.currency)} · {describeBilling(plan)}
847
- {plan.hidden && ' · hidden'}
848
- {plan.archived && ' · off sale'}
884
+ {formatMinor(plan.priceMinor, plan.currency, context.locale)} ·{' '}
885
+ {describeBilling(plan)}
886
+ {plan.hidden && ` · ${context.t.t('dues.admin.plans.hidden')}`}
887
+ {plan.archived && ` · ${context.t.t('dues.admin.plans.offSale')}`}
849
888
  </span>
850
889
  </div>
851
890
  {plan.mode === 'auto' && (
852
891
  <p className="text-xs text-muted-foreground">
853
- Billing against <code>{plan.stripePriceId ?? 'no price yet — not buyable'}</code>
892
+ {context.t.t('dues.admin.plans.billing', {
893
+ price: plan.stripePriceId ?? context.t.t('dues.admin.plans.missingPrice'),
894
+ })}
854
895
  </p>
855
896
  )}
856
897
  {!plan.archived && (
857
898
  <details>
858
899
  <summary className="cursor-pointer text-sm text-muted-foreground">
859
- Edit this plan
900
+ {context.t.t('dues.admin.plans.edit')}
860
901
  </summary>
861
902
  <form
862
903
  method="post"
@@ -864,10 +905,10 @@ export async function PlansAdminPage({
864
905
  className="mt-3 flex flex-col gap-3"
865
906
  >
866
907
  <input type="hidden" name="id" value={plan.id} />
867
- <PlanFields plan={plan} />
908
+ <PlanFields plan={plan} context={context} />
868
909
  <div>
869
910
  <button type="submit" className={ACT_BUTTON}>
870
- Save the plan
911
+ {context.t.t('dues.admin.plans.save')}
871
912
  </button>
872
913
  </div>
873
914
  </form>
@@ -877,7 +918,9 @@ export async function PlansAdminPage({
877
918
  <input type="hidden" name="id" value={plan.id} />
878
919
  <input type="hidden" name="archived" value={plan.archived ? '0' : '1'} />
879
920
  <button type="submit" className={QUIET_BUTTON}>
880
- {plan.archived ? 'Put it back on sale' : 'Take it off sale'}
921
+ {context.t.t(
922
+ plan.archived ? 'dues.admin.plans.putBack' : 'dues.admin.plans.takeOffSale',
923
+ )}
881
924
  </button>
882
925
  </form>
883
926
  </li>
@@ -887,13 +930,10 @@ export async function PlansAdminPage({
887
930
  )}
888
931
 
889
932
  <section className={CARD}>
890
- <h2 className="font-heading text-lg font-semibold">Add a plan</h2>
891
- <p className="text-sm text-muted-foreground">
892
- A <strong>pass</strong> is one payment for a fixed stretch — a day to two
893
- years. A <strong>subscription</strong> renews by itself until cancelled.{' '}
894
- <strong>Lifetime</strong> is one payment, forever. The key is the plan&rsquo;s
895
- permanent name in records and cannot be changed later; everything else can.
896
- </p>
933
+ <h2 className="font-heading text-lg font-semibold">
934
+ {context.t.t('dues.admin.plans.add')}
935
+ </h2>
936
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.admin.plans.addIntro')}</p>
897
937
  <form
898
938
  method="post"
899
939
  action="/admin/api/plugins/dues/plans/create"
@@ -901,29 +941,30 @@ export async function PlansAdminPage({
901
941
  >
902
942
  <div className="grid grid-cols-2 gap-3">
903
943
  <label className="flex flex-col gap-1 text-sm">
904
- <span className="text-xs text-muted-foreground">Key — permanent</span>
944
+ <span className="text-xs text-muted-foreground">
945
+ {context.t.t('dues.admin.plans.key')}
946
+ </span>
905
947
  <input name="key" placeholder="day-pass" required className={INPUT} />
906
948
  </label>
907
949
  <label className="flex flex-col gap-1 text-sm">
908
- <span className="text-xs text-muted-foreground">How it bills</span>
950
+ <span className="text-xs text-muted-foreground">
951
+ {context.t.t('dues.admin.plans.bills')}
952
+ </span>
909
953
  <select name="mode" className={INPUT}>
910
- <option value="fixed">a pass — one payment, fixed length</option>
911
- <option value="auto">a subscription — renews itself</option>
912
- <option value="lifetime">lifetime — one payment, forever</option>
954
+ <option value="fixed">{context.t.t('dues.admin.plans.fixed')}</option>
955
+ <option value="auto">{context.t.t('dues.admin.plans.subscription')}</option>
956
+ <option value="lifetime">{context.t.t('dues.admin.plans.lifetime')}</option>
913
957
  </select>
914
958
  </label>
915
959
  </div>
916
- <PlanFields />
960
+ <PlanFields context={context} />
917
961
  <div>
918
962
  <button type="submit" className={ACT_BUTTON}>
919
- Put it on sale
963
+ {context.t.t('dues.admin.plans.onSale')}
920
964
  </button>
921
965
  </div>
922
966
  </form>
923
- <p className="text-xs text-muted-foreground">
924
- Pass length and billing interval apply to the mode that uses them; the others
925
- ignore them. A subscription needs Stripe configured, or a pasted price id.
926
- </p>
967
+ <p className="text-xs text-muted-foreground">{context.t.t('dues.admin.plans.footer')}</p>
927
968
  </section>
928
969
  </div>
929
970
  )