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