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