@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/pages.tsx CHANGED
@@ -1,21 +1,21 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
3
  import type { PluginPageContext } from '@meith/plugin-kit'
4
+ import type { Translator } from '@meith/theme-kit'
4
5
 
5
6
  import type { DuesConfig } from '../config'
6
7
  import { formatMinor } from '../money'
7
8
  import { describeBilling, shopPlans } from '../plans'
8
9
  import {
10
+ type MembershipRow,
9
11
  membershipsFor,
12
+ type OrderRow,
10
13
  orderById,
11
14
  ordersBoughtBy,
12
- type MembershipRow,
13
- type OrderRow,
14
15
  type PlanRow,
15
16
  } from '../store'
16
17
 
17
- const QUIET_PANEL =
18
- 'rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground'
18
+ const QUIET_PANEL = 'rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground'
19
19
 
20
20
  const CARD =
21
21
  'flex flex-col gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground ' +
@@ -29,8 +29,8 @@ const BUY_BUTTON =
29
29
  const QUIET_BUTTON =
30
30
  'inline-flex h-9 items-center justify-center rounded-md border border-border px-3 text-sm'
31
31
 
32
- function fmtDate(date: Date): ReactNode {
33
- const label = date.toLocaleDateString('en-GB', {
32
+ function fmtDate(date: Date, t: Translator): ReactNode {
33
+ const label = t.parts(date, {
34
34
  day: 'numeric',
35
35
  month: 'long',
36
36
  year: 'numeric',
@@ -39,76 +39,68 @@ function fmtDate(date: Date): ReactNode {
39
39
  return <time dateTime={date.toISOString()}>{label}</time>
40
40
  }
41
41
 
42
- function priceLine(plan: PlanRow): string {
43
- const price = formatMinor(plan.priceMinor, plan.currency)
44
- if (plan.mode === 'auto') return `${price} every ${plan.billingInterval ?? 'month'}`
45
- if (plan.mode === 'lifetime') return `${price} · once, for good`
42
+ function priceLine(plan: PlanRow, t: Translator): string {
43
+ const price = formatMinor(plan.priceMinor, plan.currency, t.locale)
44
+ if (plan.mode === 'auto') return `${price} ${describeBilling(plan)}`
45
+ if (plan.mode === 'lifetime') return `${price} · ${describeBilling(plan)}`
46
46
  return `${price} · ${describeBilling(plan)}`
47
47
  }
48
48
 
49
- const NOTICES: Record<string, string> = {
50
- 'unknown-plan': 'That plan is not on offer. The ones below are.',
51
- unconfigured:
52
- 'Payments are not set up on this board yet. An administrator needs to finish the ' +
53
- 'Stripe configuration before anything can be bought.',
54
- 'unknown-recipient':
55
- 'No member goes by that name. Check the spelling — the gift needs somewhere to go.',
56
- 'gift-not-allowed':
57
- 'That plan cannot be bought for someone else. Fixed-term passes can.',
58
- 'already-member':
59
- 'There is already an active subscription for that membership, so a second one ' +
60
- 'would just charge twice for the same thing.',
61
- 'try-again': 'That did not go through cleanly. Nothing was charged — try once more.',
62
- 'stripe-error':
63
- 'Stripe could not be reached, so nothing was charged. Try again shortly.',
64
- 'sign-in': 'Sign in first, so the membership has an account to attach to.',
65
- 'unknown-code': 'That discount code does not exist. Check the spelling.',
66
- 'code-disabled': 'That discount code has been switched off.',
67
- 'code-expired': 'That discount code has expired.',
68
- 'code-exhausted': 'That discount code has been used as many times as it allows.',
69
- 'code-wrong-plan': 'That discount code is for a different plan.',
70
- 'already-forever':
71
- 'You hold this membership for good already — there is nothing left to buy for it.',
72
- 'plan-not-ready':
73
- 'That plan is not finished being set up — its Stripe price is missing. An ' +
74
- 'administrator needs to complete it.',
75
- 'cancel-first':
76
- 'There is an active subscription for that membership. Cancel its renewal first, ' +
77
- 'then buy the lifetime plan — you keep everything already paid for.',
78
- }
79
-
80
- function Notice({ query }: { query: Readonly<Record<string, string>> }) {
49
+ const NOTICE_KEYS = new Set([
50
+ 'dues.notice.already-forever',
51
+ 'dues.notice.already-member',
52
+ 'dues.notice.cancel-first',
53
+ 'dues.notice.code-disabled',
54
+ 'dues.notice.code-exhausted',
55
+ 'dues.notice.code-expired',
56
+ 'dues.notice.code-wrong-plan',
57
+ 'dues.notice.gift-not-allowed',
58
+ 'dues.notice.plan-not-ready',
59
+ 'dues.notice.sign-in',
60
+ 'dues.notice.stripe-error',
61
+ 'dues.notice.try-again',
62
+ 'dues.notice.unconfigured',
63
+ 'dues.notice.unknown-code',
64
+ 'dues.notice.unknown-plan',
65
+ 'dues.notice.unknown-recipient',
66
+ ])
67
+
68
+ function Notice({ query, t }: { query: Readonly<Record<string, string>>; t: Translator }) {
81
69
  if (query.cancelled === '1') {
82
70
  return (
83
71
  <p className="rounded-lg border border-border bg-muted px-4 py-3 text-sm">
84
- Checkout was cancelled. Nothing was charged.
72
+ {t.t('dues.notice.cancelled')}
85
73
  </p>
86
74
  )
87
75
  }
88
- const message = NOTICES[query.error ?? '']
89
- if (message === undefined) return null
76
+ const key = `dues.notice.${query.error ?? ''}`
77
+ if (!NOTICE_KEYS.has(key)) return null
90
78
  return (
91
79
  <p
92
80
  role="status"
93
81
  className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm"
94
82
  >
95
- {message}
83
+ {t.t(key)}
96
84
  </p>
97
85
  )
98
86
  }
99
87
 
100
- function membershipLine(membership: MembershipRow): string {
88
+ function membershipLine(membership: MembershipRow, t: Translator): string {
101
89
  switch (membership.status) {
102
90
  case 'active':
103
- return membership.renewalMode === 'auto' ? 'renews on' : 'yours until'
91
+ return t.t(
92
+ membership.renewalMode === 'auto'
93
+ ? 'dues.membership.activeAuto'
94
+ : 'dues.membership.activeFixed',
95
+ )
104
96
  case 'grace':
105
- return 'payment failed — access until'
97
+ return t.t('dues.membership.grace')
106
98
  case 'closing':
107
- return 'will not renew — yours until'
99
+ return t.t('dues.membership.closing')
108
100
  case 'expired':
109
- return 'ended on'
101
+ return t.t('dues.membership.expired')
110
102
  case 'revoked':
111
- return 'refunded and ended on'
103
+ return t.t('dues.membership.revoked')
112
104
  }
113
105
  }
114
106
 
@@ -116,18 +108,18 @@ function membershipDate(membership: MembershipRow): Date {
116
108
  return membership.status === 'grace' ? membership.graceUntil : membership.currentPeriodEnd
117
109
  }
118
110
 
119
- function membershipWhen(membership: MembershipRow): ReactNode {
111
+ function membershipWhen(membership: MembershipRow, t: Translator): ReactNode {
120
112
  if (membership.renewalMode === 'lifetime' && membership.status === 'active') {
121
- return 'yours for good'
113
+ return t.t('dues.membership.lifetime')
122
114
  }
123
115
  return (
124
116
  <>
125
- {membershipLine(membership)} {fmtDate(membershipDate(membership))}
117
+ {membershipLine(membership, t)} {fmtDate(membershipDate(membership), t)}
126
118
  </>
127
119
  )
128
120
  }
129
121
 
130
- function HeldCard({ memberships }: { memberships: readonly MembershipRow[] }) {
122
+ function HeldCard({ memberships, t }: { memberships: readonly MembershipRow[]; t: Translator }) {
131
123
  const live = memberships.filter(
132
124
  (row) => row.status === 'active' || row.status === 'grace' || row.status === 'closing',
133
125
  )
@@ -136,16 +128,14 @@ function HeldCard({ memberships }: { memberships: readonly MembershipRow[] }) {
136
128
  return (
137
129
  <section className={CARD} aria-labelledby="dues-held">
138
130
  <h2 id="dues-held" className="font-heading text-lg font-semibold">
139
- What you hold
131
+ {t.t('dues.held.heading')}
140
132
  </h2>
141
133
  <ul className="flex flex-col gap-2 text-sm">
142
134
  {live.map((row) => (
143
135
  <li key={row.id} className="flex flex-wrap items-baseline justify-between gap-2">
144
136
  <span className="font-medium">{row.planKey}</span>
145
- <span
146
- className={row.status === 'grace' ? '' : 'text-muted-foreground'}
147
- >
148
- {membershipWhen(row)}
137
+ <span className={row.status === 'grace' ? '' : 'text-muted-foreground'}>
138
+ {membershipWhen(row, t)}
149
139
  </span>
150
140
  </li>
151
141
  ))}
@@ -155,7 +145,7 @@ function HeldCard({ memberships }: { memberships: readonly MembershipRow[] }) {
155
145
  href="/plugins/dues/manage"
156
146
  className="font-medium underline decoration-border underline-offset-2 hover:decoration-current"
157
147
  >
158
- Manage your membership
148
+ {t.t('dues.held.manage')}
159
149
  </a>
160
150
  </p>
161
151
  </section>
@@ -167,45 +157,36 @@ function PlanCard({
167
157
  viewerSignedIn,
168
158
  defaultRecipient,
169
159
  defaultCode,
160
+ t,
170
161
  }: {
171
162
  plan: PlanRow
172
163
  viewerSignedIn: boolean
173
164
  defaultRecipient: string
174
165
  defaultCode: string
166
+ t: Translator
175
167
  }) {
176
168
  return (
177
169
  <section className={CARD} aria-label={plan.name}>
178
170
  <div className="flex flex-col gap-1">
179
171
  <h3 className="font-heading text-lg font-semibold">{plan.name}</h3>
180
- <p className="text-sm font-medium">{priceLine(plan)}</p>
172
+ <p className="text-sm font-medium">{priceLine(plan, t)}</p>
181
173
  {plan.description !== null && (
182
174
  <p className="text-sm text-muted-foreground">{plan.description}</p>
183
175
  )}
184
176
  {plan.mode === 'auto' && (
185
- <p className="text-xs text-muted-foreground">
186
- Renews automatically. Cancel any time and keep what you paid for until the
187
- period ends.
188
- </p>
177
+ <p className="text-xs text-muted-foreground">{t.t('dues.plan.auto')}</p>
189
178
  )}
190
179
  {plan.mode === 'lifetime' && (
191
- <p className="text-xs text-muted-foreground">
192
- One payment, no renewal, no end date.
193
- </p>
180
+ <p className="text-xs text-muted-foreground">{t.t('dues.plan.lifetime')}</p>
194
181
  )}
195
182
  </div>
196
183
 
197
184
  {viewerSignedIn ? (
198
- <form
199
- method="post"
200
- action="/api/plugins/dues/checkout"
201
- className="flex flex-col gap-3"
202
- >
185
+ <form method="post" action="/api/plugins/dues/checkout" className="flex flex-col gap-3">
203
186
  <input type="hidden" name="plan" value={plan.key} />
204
187
  {plan.giftable && (
205
188
  <label className="flex flex-col gap-1 text-sm">
206
- <span className="text-xs text-muted-foreground">
207
- Buying for another member? Their username — leave empty for yourself.
208
- </span>
189
+ <span className="text-xs text-muted-foreground">{t.t('dues.plan.gift')}</span>
209
190
  <input
210
191
  name="recipient"
211
192
  defaultValue={defaultRecipient}
@@ -215,37 +196,27 @@ function PlanCard({
215
196
  </label>
216
197
  )}
217
198
  <label className="flex flex-col gap-1 text-sm">
218
- <span className="text-xs text-muted-foreground">
219
- Discount code, if you have one.
220
- </span>
221
- <input
222
- name="code"
223
- defaultValue={defaultCode}
224
- autoComplete="off"
225
- className={INPUT}
226
- />
199
+ <span className="text-xs text-muted-foreground">{t.t('dues.plan.discountCode')}</span>
200
+ <input name="code" defaultValue={defaultCode} autoComplete="off" className={INPUT} />
227
201
  </label>
228
202
  <div>
229
203
  <button type="submit" className={BUY_BUTTON}>
230
204
  {plan.mode === 'auto'
231
- ? 'Subscribe'
205
+ ? t.t('dues.plan.buySubscription')
232
206
  : plan.mode === 'lifetime'
233
- ? 'Buy lifetime membership'
234
- : 'Buy this pass'}
207
+ ? t.t('dues.plan.buyLifetime')
208
+ : t.t('dues.plan.buyFixed')}
235
209
  </button>
236
210
  </div>
237
211
  </form>
238
212
  ) : (
239
213
  <p className="text-sm">
240
214
  <a href="/login?next=%2Fplugins%2Fdues" className={QUIET_BUTTON}>
241
- Sign in to join
215
+ {t.t('dues.plan.signIn')}
242
216
  </a>
243
217
  </p>
244
218
  )}
245
- <p className="text-xs text-muted-foreground">
246
- Payment is taken by Stripe on their own checkout page — no card number ever
247
- touches this board.
248
- </p>
219
+ <p className="text-xs text-muted-foreground">{t.t('dues.plan.payment')}</p>
249
220
  </section>
250
221
  )
251
222
  }
@@ -265,13 +236,9 @@ export async function PlansPage({
265
236
 
266
237
  return (
267
238
  <div className="flex flex-col gap-6">
268
- <Notice query={context.query} />
269
- <HeldCard memberships={memberships} />
270
- {plans.length === 0 && (
271
- <p className={QUIET_PANEL}>
272
- Nothing is on sale just now.
273
- </p>
274
- )}
239
+ <Notice query={context.query} t={context.t} />
240
+ <HeldCard memberships={memberships} t={context.t} />
241
+ {plans.length === 0 && <p className={QUIET_PANEL}>{context.t.t('dues.plan.empty')}</p>}
275
242
  <div className="grid gap-4 sm:grid-cols-2">
276
243
  {plans.map((plan) => (
277
244
  <PlanCard
@@ -280,6 +247,7 @@ export async function PlansPage({
280
247
  viewerSignedIn={signedIn}
281
248
  defaultRecipient={bounced === plan.key ? (context.query.recipient ?? '') : ''}
282
249
  defaultCode={bounced === plan.key ? (context.query.code ?? '') : ''}
250
+ t={context.t}
283
251
  />
284
252
  ))}
285
253
  </div>
@@ -303,8 +271,7 @@ export function GoPage({
303
271
  try {
304
272
  const url = new URL(to)
305
273
  const hostname = url.hostname.toLowerCase()
306
- const loopback =
307
- hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
274
+ const loopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
308
275
  return (
309
276
  (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) &&
310
277
  allowedHosts.includes(hostname)
@@ -317,9 +284,9 @@ export function GoPage({
317
284
  if (!allowed) {
318
285
  return (
319
286
  <p className={QUIET_PANEL}>
320
- That is not somewhere this board sends people.{' '}
287
+ {context.t.t('dues.go.notAllowed')}{' '}
321
288
  <a href="/plugins/dues" className="font-medium underline underline-offset-2">
322
- Back to safety.
289
+ {context.t.t('dues.go.back')}
323
290
  </a>
324
291
  </p>
325
292
  )
@@ -328,14 +295,13 @@ export function GoPage({
328
295
  return (
329
296
  <section className={CARD}>
330
297
  <meta httpEquiv="refresh" content={`0;url=${to}`} />
331
- <h2 className="font-heading text-lg font-semibold">Over to Stripe…</h2>
298
+ <h2 className="font-heading text-lg font-semibold">{context.t.t('dues.go.heading')}</h2>
332
299
  <p className="text-sm text-muted-foreground">
333
- Payment happens on Stripe&rsquo;s own page — no card number ever touches this
334
- board. You should be there already;{' '}
300
+ {context.t.t('dues.go.paymentLead')}{' '}
335
301
  <a href={to} className="font-medium underline underline-offset-2">
336
- continue by hand
302
+ {context.t.t('dues.go.continue')}
337
303
  </a>{' '}
338
- if not.
304
+ {context.t.t('dues.go.paymentTail')}
339
305
  </p>
340
306
  </section>
341
307
  )
@@ -351,9 +317,7 @@ export async function ReturnPage({
351
317
  const viewerId = context.viewer.userId
352
318
  const orderId = Number(context.query.order ?? '')
353
319
  const order =
354
- Number.isSafeInteger(orderId) && orderId > 0
355
- ? await orderById(context.data, orderId)
356
- : null
320
+ Number.isSafeInteger(orderId) && orderId > 0 ? await orderById(context.data, orderId) : null
357
321
 
358
322
  if (
359
323
  order === null ||
@@ -362,9 +326,9 @@ export async function ReturnPage({
362
326
  ) {
363
327
  return (
364
328
  <p className={QUIET_PANEL}>
365
- There is no order of yours here.{' '}
329
+ {context.t.t('dues.return.noOrder')}{' '}
366
330
  <a href="/plugins/dues" className="font-medium underline underline-offset-2">
367
- Back to {config.label.toLowerCase()}
331
+ {context.t.t('dues.return.backToShop', { label: config.label.toLowerCase() })}
368
332
  </a>
369
333
  </p>
370
334
  )
@@ -375,16 +339,18 @@ export async function ReturnPage({
375
339
  if (order.status === 'paid') {
376
340
  return (
377
341
  <section className={CARD}>
378
- <h2 className="font-heading text-lg font-semibold">Paid, and done</h2>
342
+ <h2 className="font-heading text-lg font-semibold">{context.t.t('dues.return.paid')}</h2>
379
343
  <p className="text-sm">
380
344
  {gift
381
- ? `Your gift of ${order.planName} has been delivered. They hold it from this moment.`
382
- : `${order.planName} is yours from this moment.`}{' '}
383
- {formatMinor(order.amountMinor, order.currency)} — Stripe sends the receipt.
345
+ ? context.t.t('dues.return.giftPaid', { plan: order.planName })
346
+ : context.t.t('dues.return.paidPlan', { plan: order.planName })}{' '}
347
+ {context.t.t('dues.return.payment', {
348
+ amount: formatMinor(order.amountMinor, order.currency, context.locale),
349
+ })}
384
350
  </p>
385
351
  <p className="text-sm">
386
352
  <a href="/plugins/dues/manage" className="font-medium underline underline-offset-2">
387
- See your membership
353
+ {context.t.t('dues.return.seeMembership')}
388
354
  </a>
389
355
  </p>
390
356
  </section>
@@ -394,13 +360,19 @@ export async function ReturnPage({
394
360
  if (order.status === 'failed' || order.status === 'cancelled') {
395
361
  return (
396
362
  <section className={CARD}>
397
- <h2 className="font-heading text-lg font-semibold">Nothing was charged</h2>
363
+ <h2 className="font-heading text-lg font-semibold">
364
+ {context.t.t('dues.return.nothingCharged')}
365
+ </h2>
398
366
  <p className="text-sm text-muted-foreground">
399
- This checkout {order.status === 'failed' ? 'did not go through' : 'was cancelled'}.
367
+ {context.t.t('dues.return.checkout', {
368
+ status: context.t.t(
369
+ order.status === 'failed' ? 'dues.return.failed' : 'dues.return.wasCancelled',
370
+ ),
371
+ })}
400
372
  </p>
401
373
  <p className="text-sm">
402
374
  <a href="/plugins/dues" className="font-medium underline underline-offset-2">
403
- Back to the plans
375
+ {context.t.t('dues.return.backToPlans')}
404
376
  </a>
405
377
  </p>
406
378
  </section>
@@ -409,41 +381,39 @@ export async function ReturnPage({
409
381
 
410
382
  return (
411
383
  <section className={CARD}>
412
- <h2 className="font-heading text-lg font-semibold">Confirming your payment…</h2>
413
- <p className="text-sm text-muted-foreground">
414
- Stripe is telling the board about your payment right now. This page does not
415
- grant anything by itself — the confirmation does, and it usually lands within
416
- seconds.
417
- </p>
384
+ <h2 className="font-heading text-lg font-semibold">
385
+ {context.t.t('dues.return.confirming')}
386
+ </h2>
387
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.return.confirmingText')}</p>
418
388
  <p className="text-sm">
419
389
  <a href={`/plugins/dues/return?order=${order.id}`} className={QUIET_BUTTON}>
420
- Check again
390
+ {context.t.t('dues.return.checkAgain')}
421
391
  </a>
422
392
  </p>
423
393
  </section>
424
394
  )
425
395
  }
426
396
 
427
- function GiftList({ orders }: { orders: readonly OrderRow[] }) {
397
+ function GiftList({ orders, t }: { orders: readonly OrderRow[]; t: Translator }) {
428
398
  const gifts = orders.filter((order) => order.buyerUserId !== order.recipientUserId)
429
399
  if (gifts.length === 0) return null
430
400
 
431
401
  return (
432
402
  <section className={CARD} aria-labelledby="dues-gifts">
433
403
  <h2 id="dues-gifts" className="font-heading text-lg font-semibold">
434
- Gifts you bought
404
+ {t.t('dues.gifts.heading')}
435
405
  </h2>
436
406
  <ul className="flex flex-col divide-y divide-border text-sm">
437
407
  {gifts.map((order) => (
438
408
  <li key={order.id} className="flex flex-wrap justify-between gap-2 py-2">
439
409
  <span>
440
- {order.planName} — {formatMinor(order.amountMinor, order.currency)}
410
+ {order.planName} — {formatMinor(order.amountMinor, order.currency, t.locale)}
441
411
  </span>
442
412
  <span className="text-muted-foreground">
443
413
  {order.status === 'paid'
444
- ? 'delivered'
414
+ ? t.t('dues.gifts.delivered')
445
415
  : order.status === 'pending' || order.status === 'created'
446
- ? 'awaiting payment'
416
+ ? t.t('dues.gifts.awaitingPayment')
447
417
  : order.status}
448
418
  </span>
449
419
  </li>
@@ -453,15 +423,13 @@ function GiftList({ orders }: { orders: readonly OrderRow[] }) {
453
423
  )
454
424
  }
455
425
 
456
- const MANAGE_NOTICES: Record<string, string> = {
457
- 'cancel-failed': 'That could not be cancelled. If it keeps failing, contact a moderator.',
458
- 'no-customer':
459
- 'Stripe has no record for your account yet — the portal exists once you have bought ' +
460
- 'something.',
461
- 'stripe-error': 'Stripe could not be reached. Try again shortly.',
462
- unconfigured: 'Payments are not fully set up on this board.',
463
- 'sign-in': 'Sign in first.',
464
- }
426
+ const MANAGE_NOTICE_KEYS = new Set([
427
+ 'dues.manage.cancel-failed',
428
+ 'dues.manage.no-customer',
429
+ 'dues.manage.sign-in',
430
+ 'dues.manage.stripe-error',
431
+ 'dues.manage.unconfigured',
432
+ ])
465
433
 
466
434
  export async function ManagePage({
467
435
  config,
@@ -480,59 +448,52 @@ export async function ManagePage({
480
448
  <div className="flex flex-col gap-6">
481
449
  {context.query.cancelled === '1' && (
482
450
  <p role="status" className="rounded-lg border border-border bg-muted px-4 py-3 text-sm">
483
- Renewal cancelled. You keep everything you paid for until the period ends —
484
- nothing changes before then.
451
+ {context.t.t('dues.manage.cancelled')}
485
452
  </p>
486
453
  )}
487
- {MANAGE_NOTICES[context.query.error ?? ''] !== undefined && (
454
+ {MANAGE_NOTICE_KEYS.has(`dues.manage.${context.query.error ?? ''}`) && (
488
455
  <p
489
456
  role="status"
490
457
  className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm"
491
458
  >
492
- {MANAGE_NOTICES[context.query.error ?? '']}
459
+ {context.t.t(`dues.manage.${context.query.error ?? ''}`)}
493
460
  </p>
494
461
  )}
495
462
 
496
463
  {memberships.length === 0 ? (
497
464
  <p className={QUIET_PANEL}>
498
- You hold no {config.label.toLowerCase()} yet.{' '}
465
+ {context.t.t('dues.manage.empty', { label: config.label.toLowerCase() })}{' '}
499
466
  <a href="/plugins/dues" className="font-medium underline underline-offset-2">
500
- The plans are here.
467
+ {context.t.t('dues.manage.plans')}
501
468
  </a>
502
469
  </p>
503
470
  ) : (
504
471
  <section className={CARD} aria-labelledby="dues-manage">
505
472
  <h2 id="dues-manage" className="font-heading text-lg font-semibold">
506
- Your {config.label.toLowerCase()}
473
+ {context.t.t('dues.manage.title', { label: config.label.toLowerCase() })}
507
474
  </h2>
508
475
  <ul className="flex flex-col divide-y divide-border">
509
476
  {memberships.map((membership) => (
510
477
  <li key={membership.id} className="flex flex-col gap-2 py-3 text-sm">
511
478
  <div className="flex flex-wrap items-baseline justify-between gap-2">
512
479
  <span className="font-medium">{membership.planKey}</span>
513
- <span
514
- className={
515
- membership.status === 'grace' ? '' : 'text-muted-foreground'
516
- }
517
- >
518
- {membershipWhen(membership)}
480
+ <span className={membership.status === 'grace' ? '' : 'text-muted-foreground'}>
481
+ {membershipWhen(membership, context.t)}
519
482
  </span>
520
483
  </div>
521
484
  {membership.status === 'grace' && (
522
- <p className="text-sm">
523
- A renewal payment failed. Stripe retries on its own; updating your
524
- card below usually settles it.
525
- </p>
485
+ <p className="text-sm">{context.t.t('dues.manage.grace')}</p>
526
486
  )}
527
487
  {membership.status === 'active' && membership.renewalMode === 'auto' && (
528
488
  <form method="post" action="/api/plugins/dues/cancel">
529
489
  <input type="hidden" name="membership" value={membership.id} />
530
490
  <button type="submit" className={QUIET_BUTTON}>
531
- Cancel renewal — keep access until {' '}
532
- {membershipDate(membership).toLocaleDateString('en-GB', {
533
- day: 'numeric',
534
- month: 'long',
535
- timeZone: 'UTC',
491
+ {context.t.t('dues.manage.cancel', {
492
+ date: context.t.parts(membershipDate(membership), {
493
+ day: 'numeric',
494
+ month: 'long',
495
+ timeZone: 'UTC',
496
+ }),
536
497
  })}
537
498
  </button>
538
499
  </form>
@@ -544,19 +505,18 @@ export async function ManagePage({
544
505
  )}
545
506
 
546
507
  <section className={CARD}>
547
- <h2 className="font-heading text-lg font-semibold">Card and receipts</h2>
548
- <p className="text-sm text-muted-foreground">
549
- Cards, invoices and receipts live on Stripe, where the payment did. The portal
550
- is theirs; it comes back here when you are done.
551
- </p>
508
+ <h2 className="font-heading text-lg font-semibold">
509
+ {context.t.t('dues.manage.cardHeading')}
510
+ </h2>
511
+ <p className="text-sm text-muted-foreground">{context.t.t('dues.manage.cardText')}</p>
552
512
  <form method="post" action="/api/plugins/dues/portal">
553
513
  <button type="submit" className={QUIET_BUTTON}>
554
- Open the billing portal
514
+ {context.t.t('dues.manage.openPortal')}
555
515
  </button>
556
516
  </form>
557
517
  </section>
558
518
 
559
- <GiftList orders={orders} />
519
+ <GiftList orders={orders} t={context.t} />
560
520
  </div>
561
521
  )
562
522
  }