@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.
- package/LICENSE.md +165 -0
- package/README.md +182 -0
- package/package.json +31 -0
- package/src/codes.ts +47 -0
- package/src/config.ts +153 -0
- package/src/definition.tsx +292 -0
- package/src/demo.ts +526 -0
- package/src/entitlement.ts +371 -0
- package/src/handlers-admin.ts +405 -0
- package/src/handlers.ts +397 -0
- package/src/index.ts +18 -0
- package/src/money.ts +42 -0
- package/src/period.ts +76 -0
- package/src/plans.ts +183 -0
- package/src/schema.ts +153 -0
- package/src/store.ts +966 -0
- package/src/stripe/client.ts +303 -0
- package/src/stripe/events.ts +189 -0
- package/src/stripe/webhook.ts +64 -0
- package/src/tasks.ts +156 -0
- package/src/ui/admin.tsx +925 -0
- package/src/ui/pages.tsx +557 -0
package/src/ui/pages.tsx
ADDED
|
@@ -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’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
|
+
}
|