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