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