@growth-labs/cms 0.5.2 → 0.5.4

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.
Files changed (39) hide show
  1. package/README.md +10 -2
  2. package/dist/providers/null.d.ts.map +1 -1
  3. package/dist/providers/null.js +20 -3
  4. package/dist/providers/null.js.map +1 -1
  5. package/dist/providers/types.d.ts +93 -6
  6. package/dist/providers/types.d.ts.map +1 -1
  7. package/dist/providers/types.js +20 -1
  8. package/dist/providers/types.js.map +1 -1
  9. package/dist/routes/authz-matrix.d.ts.map +1 -1
  10. package/dist/routes/authz-matrix.js +4 -0
  11. package/dist/routes/authz-matrix.js.map +1 -1
  12. package/dist/routes/context.d.ts +1 -1
  13. package/dist/routes/context.d.ts.map +1 -1
  14. package/dist/routes/context.js.map +1 -1
  15. package/dist/routes/subscriptions.d.ts +9 -2
  16. package/dist/routes/subscriptions.d.ts.map +1 -1
  17. package/dist/routes/subscriptions.js +202 -21
  18. package/dist/routes/subscriptions.js.map +1 -1
  19. package/dist/ui/api/subscriptions.d.ts +1 -1
  20. package/dist/ui/api/subscriptions.d.ts.map +1 -1
  21. package/dist/ui/api/subscriptions.js +27 -5
  22. package/dist/ui/api/subscriptions.js.map +1 -1
  23. package/dist/ui/screens/SubscriptionsScreen.d.ts +44 -0
  24. package/dist/ui/screens/SubscriptionsScreen.d.ts.map +1 -1
  25. package/dist/ui/screens/SubscriptionsScreen.js +262 -54
  26. package/dist/ui/screens/SubscriptionsScreen.js.map +1 -1
  27. package/dist/ui/screens/subscriptions-data.d.ts +33 -0
  28. package/dist/ui/screens/subscriptions-data.d.ts.map +1 -0
  29. package/dist/ui/screens/subscriptions-data.js +143 -0
  30. package/dist/ui/screens/subscriptions-data.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/providers/null.ts +32 -3
  33. package/src/providers/types.ts +131 -4
  34. package/src/routes/authz-matrix.ts +4 -0
  35. package/src/routes/context.ts +3 -0
  36. package/src/routes/subscriptions.ts +269 -27
  37. package/src/ui/api/subscriptions.ts +27 -5
  38. package/src/ui/screens/SubscriptionsScreen.tsx +631 -116
  39. package/src/ui/screens/subscriptions-data.ts +193 -0
@@ -1,9 +1,8 @@
1
1
  // src/ui/screens/SubscriptionsScreen.tsx — Subscriptions screen (P5 Task 11).
2
2
  //
3
3
  // Renders the member list + filters, 4 KPI cards (Active/MRR/Churn/LTV),
4
- // the plan-mix bar, export, and gift subscription backed entirely by
5
- // GET /admin/api/subscriptions?type=* (members / kpis / planMix / export)
6
- // and POST /admin/api/subscriptions (gift).
4
+ // the plan-mix bar, export, paid gift-order tracking, and complimentary access
5
+ // administration — backed entirely by GET/POST /admin/api/subscriptions?type=*.
7
6
  //
8
7
  // Honesty conventions (two-level per grounding fact 10):
9
8
  // - result.status === 'empty' → honest empty state, no mock numbers
@@ -16,10 +15,15 @@
16
15
  // Presentation: Broadsheet idiom — page-head/page-title, .grid-stats KPI cards
17
16
  // with serif .tnum numbers, .seg filter, the .tbl members table with .pill.pdot
18
17
  // plan/status chips, the .masthead-rule hairline under card titles, and the
19
- // gift dialog on a .scrim + .panel.panel-pad with .field-label/.input/.btn.
18
+ // complimentary grant dialog on a .scrim + .panel.panel-pad with .field-label/.input/.btn.
20
19
 
21
20
  import { useCallback, useEffect, useReducer, useState } from 'react'
22
21
  import type {
22
+ ComplimentaryAccessPage,
23
+ ComplimentaryEmailRetryResult,
24
+ ComplimentaryEntitlementRow,
25
+ ComplimentaryGrantResult,
26
+ ComplimentaryRevokeResult,
23
27
  DuplicateAccountsReport,
24
28
  GiftOrdersPage,
25
29
  LsImportSummary,
@@ -34,12 +38,31 @@ import {
34
38
  planMixToBars,
35
39
  selectKpiState,
36
40
  } from './render-state.js'
41
+ import {
42
+ buildComplimentaryGrantPayload,
43
+ buildComplimentaryRetryPayload,
44
+ buildComplimentaryRevokePayload,
45
+ complimentaryGrantSubmitDisabled,
46
+ complimentaryPageLabel,
47
+ complimentaryRowActions,
48
+ describeComplimentaryGrantResult,
49
+ describeComplimentaryRetryResult,
50
+ describeComplimentaryRevokeResult,
51
+ formatTimestamp,
52
+ type ResultCopy,
53
+ } from './subscriptions-data.js'
37
54
 
38
55
  // ---------------------------------------------------------------------------
39
56
  // Filter options
40
57
  // ---------------------------------------------------------------------------
41
58
 
42
59
  type FilterOption = 'all' | 'paid' | 'past_due' | 'canceled'
60
+ export type ComplimentaryStatusFilter = 'active' | 'revoked' | 'all'
61
+
62
+ export interface ComplimentaryQuery {
63
+ q: string
64
+ status: ComplimentaryStatusFilter
65
+ }
43
66
 
44
67
  const FILTER_LABELS: Array<{ value: FilterOption; label: string }> = [
45
68
  { value: 'all', label: 'All' },
@@ -48,16 +71,32 @@ const FILTER_LABELS: Array<{ value: FilterOption; label: string }> = [
48
71
  { value: 'canceled', label: 'Canceled' },
49
72
  ]
50
73
 
74
+ export function buildComplimentaryListUrl(query: ComplimentaryQuery, page: number): string {
75
+ const params = new URLSearchParams()
76
+ params.set('type', 'complimentary')
77
+ const trimmedQuery = query.q.trim()
78
+ if (trimmedQuery.length > 0) params.set('q', trimmedQuery)
79
+ params.set('status', query.status)
80
+ params.set('page', String(page))
81
+ return `/admin/api/subscriptions?${params.toString()}`
82
+ }
83
+
51
84
  // ---------------------------------------------------------------------------
52
85
  // Data shapes
53
86
  // ---------------------------------------------------------------------------
54
87
 
55
- interface SubscriptionsData {
88
+ export type ComplimentaryPanelState =
89
+ | { kind: 'available'; result: ProviderResult<ComplimentaryAccessPage> }
90
+ | { kind: 'unavailable'; reason: 'forbidden' }
91
+ | { kind: 'error'; reason: 'request_failed' }
92
+
93
+ export interface SubscriptionsData {
56
94
  members: ProviderResult<SubscriptionMember[]>
57
95
  kpis: ProviderResult<Record<string, number | null>>
58
96
  planMix: ProviderResult<PlanMixBucket[]>
59
97
  duplicates: ProviderResult<DuplicateAccountsReport>
60
98
  gifts: ProviderResult<GiftOrdersPage>
99
+ complimentary: ComplimentaryPanelState
61
100
  }
62
101
 
63
102
  // ---------------------------------------------------------------------------
@@ -69,6 +108,7 @@ interface SubscriptionsState {
69
108
  error: string | null
70
109
  data: SubscriptionsData | null
71
110
  page: number
111
+ complimentaryPage: number
72
112
  }
73
113
 
74
114
  type SubscriptionsAction =
@@ -76,9 +116,10 @@ type SubscriptionsAction =
76
116
  | { type: 'fetch-ok'; data: SubscriptionsData }
77
117
  | { type: 'fetch-error'; error: string }
78
118
  | { type: 'set-page'; page: number }
119
+ | { type: 'set-complimentary-page'; page: number }
79
120
 
80
121
  function init(): SubscriptionsState {
81
- return { loading: true, error: null, data: null, page: 1 }
122
+ return { loading: true, error: null, data: null, page: 1, complimentaryPage: 1 }
82
123
  }
83
124
 
84
125
  function reducer(state: SubscriptionsState, action: SubscriptionsAction): SubscriptionsState {
@@ -91,21 +132,23 @@ function reducer(state: SubscriptionsState, action: SubscriptionsAction): Subscr
91
132
  return { ...state, loading: false, error: action.error, data: null }
92
133
  case 'set-page':
93
134
  return { ...state, page: action.page }
135
+ case 'set-complimentary-page':
136
+ return { ...state, complimentaryPage: action.page }
94
137
  default:
95
138
  return state
96
139
  }
97
140
  }
98
141
 
99
142
  // ---------------------------------------------------------------------------
100
- // Gift form state
143
+ // Complimentary grant form state
101
144
  // ---------------------------------------------------------------------------
102
145
 
103
- interface GiftState {
146
+ interface ComplimentaryGrantState {
104
147
  open: boolean
105
148
  email: string
106
- planVariantId: string
149
+ reason: string
107
150
  submitting: boolean
108
- result: string | null
151
+ result: ResultCopy | null
109
152
  }
110
153
 
111
154
  // ---------------------------------------------------------------------------
@@ -115,50 +158,69 @@ interface GiftState {
115
158
  export function SubscriptionsScreen() {
116
159
  const [filter, setFilter] = useState<FilterOption>('all')
117
160
  const [s, dispatch] = useReducer(reducer, undefined, init)
118
- const [gift, setGift] = useState<GiftState>({
161
+ const [complimentaryGrant, setComplimentaryGrant] = useState<ComplimentaryGrantState>({
119
162
  open: false,
120
163
  email: '',
121
- planVariantId: '',
164
+ reason: '',
122
165
  submitting: false,
123
166
  result: null,
124
167
  })
168
+ const [complimentaryNotice, setComplimentaryNotice] = useState<ResultCopy | null>(null)
169
+ const [complimentaryQuery, setComplimentaryQuery] = useState<ComplimentaryQuery>({
170
+ q: '',
171
+ status: 'all',
172
+ })
125
173
  const [exporting, setExporting] = useState(false)
126
174
 
127
- const fetchData = useCallback(async (f: FilterOption, page: number) => {
128
- dispatch({ type: 'fetch-start' })
129
- try {
130
- const filterParam = f === 'all' ? '' : `&filter=${f}`
131
- const [membersRes, kpisRes, planMixRes, duplicatesRes, giftsRes] = await Promise.all([
132
- fetch(`/admin/api/subscriptions?type=members${filterParam}&page=${page}`),
133
- fetch('/admin/api/subscriptions?type=kpis'),
134
- fetch('/admin/api/subscriptions?type=planMix'),
135
- fetch('/admin/api/subscriptions?type=duplicates'),
136
- fetch('/admin/api/subscriptions?type=gifts'),
137
- ])
138
-
139
- if (!membersRes.ok || !kpisRes.ok || !planMixRes.ok || !duplicatesRes.ok || !giftsRes.ok) {
140
- const bad = [membersRes, kpisRes, planMixRes, duplicatesRes, giftsRes].find((r) => !r.ok)!
141
- dispatch({ type: 'fetch-error', error: `${bad.status} ${bad.statusText}` })
142
- return
175
+ const fetchData = useCallback(
176
+ async (f: FilterOption, page: number, query: ComplimentaryQuery, complimentaryPage: number) => {
177
+ dispatch({ type: 'fetch-start' })
178
+ try {
179
+ const filterParam = f === 'all' ? '' : `&filter=${f}`
180
+ const paidRequest = Promise.all([
181
+ fetch(`/admin/api/subscriptions?type=members${filterParam}&page=${page}`),
182
+ fetch('/admin/api/subscriptions?type=kpis'),
183
+ fetch('/admin/api/subscriptions?type=planMix'),
184
+ fetch('/admin/api/subscriptions?type=duplicates'),
185
+ fetch('/admin/api/subscriptions?type=gifts'),
186
+ ])
187
+ const complimentaryRequest = fetchComplimentaryPanel(query, complimentaryPage)
188
+ const [[membersRes, kpisRes, planMixRes, duplicatesRes, giftsRes], complimentary] =
189
+ await Promise.all([paidRequest, complimentaryRequest])
190
+
191
+ if (!membersRes.ok || !kpisRes.ok || !planMixRes.ok || !duplicatesRes.ok || !giftsRes.ok) {
192
+ dispatch({
193
+ type: 'fetch-error',
194
+ error: 'Subscription data is temporarily unavailable. Please try again.',
195
+ })
196
+ return
197
+ }
198
+
199
+ const [members, kpis, planMix, duplicates, gifts] = await Promise.all([
200
+ membersRes.json() as Promise<ProviderResult<SubscriptionMember[]>>,
201
+ kpisRes.json() as Promise<ProviderResult<Record<string, number | null>>>,
202
+ planMixRes.json() as Promise<ProviderResult<PlanMixBucket[]>>,
203
+ duplicatesRes.json() as Promise<ProviderResult<DuplicateAccountsReport>>,
204
+ giftsRes.json() as Promise<ProviderResult<GiftOrdersPage>>,
205
+ ])
206
+
207
+ dispatch({
208
+ type: 'fetch-ok',
209
+ data: { members, kpis, planMix, duplicates, gifts, complimentary },
210
+ })
211
+ } catch {
212
+ dispatch({
213
+ type: 'fetch-error',
214
+ error: 'Subscription data is temporarily unavailable. Please try again.',
215
+ })
143
216
  }
144
-
145
- const [members, kpis, planMix, duplicates, gifts] = await Promise.all([
146
- membersRes.json() as Promise<ProviderResult<SubscriptionMember[]>>,
147
- kpisRes.json() as Promise<ProviderResult<Record<string, number | null>>>,
148
- planMixRes.json() as Promise<ProviderResult<PlanMixBucket[]>>,
149
- duplicatesRes.json() as Promise<ProviderResult<DuplicateAccountsReport>>,
150
- giftsRes.json() as Promise<ProviderResult<GiftOrdersPage>>,
151
- ])
152
-
153
- dispatch({ type: 'fetch-ok', data: { members, kpis, planMix, duplicates, gifts } })
154
- } catch (err) {
155
- dispatch({ type: 'fetch-error', error: String(err) })
156
- }
157
- }, [])
217
+ },
218
+ [],
219
+ )
158
220
 
159
221
  useEffect(() => {
160
- void fetchData(filter, s.page)
161
- }, [fetchData, filter, s.page])
222
+ void fetchData(filter, s.page, complimentaryQuery, s.complimentaryPage)
223
+ }, [fetchData, filter, s.page, complimentaryQuery, s.complimentaryPage])
162
224
 
163
225
  const handleFilterChange = (f: FilterOption) => {
164
226
  setFilter(f)
@@ -184,30 +246,92 @@ export function SubscriptionsScreen() {
184
246
  }
185
247
  }
186
248
 
187
- const handleGiftSubmit = async () => {
188
- if (!gift.email || !gift.planVariantId) return
189
- setGift((g) => ({ ...g, submitting: true, result: null }))
249
+ const handleComplimentaryGrantSubmit = async () => {
250
+ if (complimentaryGrantSubmitDisabled(complimentaryGrant, false)) return
251
+ setComplimentaryGrant((g) => ({ ...g, submitting: true, result: null }))
190
252
  try {
191
- const res = await fetch('/admin/api/subscriptions', {
253
+ const res = await fetch('/admin/api/subscriptions?type=complimentary&action=grant', {
192
254
  method: 'POST',
193
255
  headers: { 'Content-Type': 'application/json' },
194
- body: JSON.stringify({ email: gift.email, planVariantId: gift.planVariantId }),
256
+ body: JSON.stringify(buildComplimentaryGrantPayload(complimentaryGrant)),
195
257
  })
196
- const body = (await res.json()) as ProviderResult<{ id: string }>
197
- if (body.status === 'empty' || !body.data.id) {
198
- setGift((g) => ({ ...g, submitting: false, result: 'Provider not configured.' }))
199
- } else {
200
- setGift((g) => ({
258
+ if (!res.ok) {
259
+ const message = await readErrorMessage(res)
260
+ setComplimentaryGrant((g) => ({
201
261
  ...g,
202
262
  submitting: false,
203
- result: `Gift created: ${body.data.id}`,
204
- email: '',
205
- planVariantId: '',
263
+ result: { tone: 'error', message },
206
264
  }))
207
- void fetchData(filter, s.page)
265
+ return
266
+ }
267
+ const body = (await res.json()) as ProviderResult<ComplimentaryGrantResult>
268
+ const result = describeComplimentaryGrantResult(body)
269
+ setComplimentaryGrant((g) => ({
270
+ ...g,
271
+ submitting: false,
272
+ result,
273
+ email: result.tone === 'error' ? g.email : '',
274
+ reason: result.tone === 'error' ? g.reason : '',
275
+ }))
276
+ setComplimentaryNotice(result)
277
+ void fetchData(filter, s.page, complimentaryQuery, s.complimentaryPage)
278
+ } catch {
279
+ setComplimentaryGrant((g) => ({
280
+ ...g,
281
+ submitting: false,
282
+ result: {
283
+ tone: 'error',
284
+ message: 'Complimentary access could not be granted. Please try again.',
285
+ },
286
+ }))
287
+ }
288
+ }
289
+
290
+ const handleComplimentaryRevoke = async (entitlement: ComplimentaryEntitlementRow) => {
291
+ const reason = window.prompt('Reason for revoking complimentary access')
292
+ if (!reason?.trim()) return
293
+ try {
294
+ const res = await fetch('/admin/api/subscriptions?type=complimentary&action=revoke', {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify(buildComplimentaryRevokePayload(entitlement, reason)),
298
+ })
299
+ if (!res.ok) {
300
+ setComplimentaryNotice({ tone: 'error', message: await readErrorMessage(res) })
301
+ return
208
302
  }
209
- } catch (err) {
210
- setGift((g) => ({ ...g, submitting: false, result: String(err) }))
303
+ const body = (await res.json()) as ProviderResult<ComplimentaryRevokeResult>
304
+ setComplimentaryNotice(describeComplimentaryRevokeResult(body))
305
+ void fetchData(filter, s.page, complimentaryQuery, s.complimentaryPage)
306
+ } catch {
307
+ setComplimentaryNotice({
308
+ tone: 'error',
309
+ message: 'Complimentary access could not be revoked. Please try again.',
310
+ })
311
+ }
312
+ }
313
+
314
+ const handleComplimentaryRetry = async (entitlement: ComplimentaryEntitlementRow) => {
315
+ const reason = window.prompt('Reason for retrying the complimentary access email')
316
+ if (!reason?.trim()) return
317
+ try {
318
+ const res = await fetch('/admin/api/subscriptions?type=complimentary&action=retry-email', {
319
+ method: 'POST',
320
+ headers: { 'Content-Type': 'application/json' },
321
+ body: JSON.stringify(buildComplimentaryRetryPayload(entitlement, reason)),
322
+ })
323
+ if (!res.ok) {
324
+ setComplimentaryNotice({ tone: 'error', message: await readErrorMessage(res) })
325
+ return
326
+ }
327
+ const body = (await res.json()) as ProviderResult<ComplimentaryEmailRetryResult>
328
+ setComplimentaryNotice(describeComplimentaryRetryResult(body))
329
+ void fetchData(filter, s.page, complimentaryQuery, s.complimentaryPage)
330
+ } catch {
331
+ setComplimentaryNotice({
332
+ tone: 'error',
333
+ message: 'The email could not be retried. Access was not changed.',
334
+ })
211
335
  }
212
336
  }
213
337
 
@@ -230,14 +354,16 @@ export function SubscriptionsScreen() {
230
354
  <Icon name="download" size={16} />
231
355
  {exporting ? 'Exporting…' : 'Export'}
232
356
  </button>
233
- <button
234
- type="button"
235
- className="btn primary"
236
- onClick={() => setGift((g) => ({ ...g, open: true }))}
237
- >
238
- <Icon name="sparkle" size={16} />
239
- Gift subscription
240
- </button>
357
+ {s.data?.complimentary.kind === 'available' && (
358
+ <button
359
+ type="button"
360
+ className="btn primary"
361
+ onClick={() => setComplimentaryGrant((g) => ({ ...g, open: true, result: null }))}
362
+ >
363
+ <Icon name="sparkle" size={16} />
364
+ Grant complimentary access
365
+ </button>
366
+ )}
241
367
  </div>
242
368
  </div>
243
369
 
@@ -252,7 +378,7 @@ export function SubscriptionsScreen() {
252
378
  type="button"
253
379
  className="btn sm"
254
380
  style={{ marginLeft: 12 }}
255
- onClick={() => void fetchData(filter, s.page)}
381
+ onClick={() => void fetchData(filter, s.page, complimentaryQuery, s.complimentaryPage)}
256
382
  >
257
383
  Retry
258
384
  </button>
@@ -267,16 +393,25 @@ export function SubscriptionsScreen() {
267
393
  onFilterChange={handleFilterChange}
268
394
  page={s.page}
269
395
  onPageChange={(p) => dispatch({ type: 'set-page', page: p })}
396
+ complimentaryNotice={complimentaryNotice}
397
+ complimentaryQuery={complimentaryQuery}
398
+ onComplimentaryQueryChange={(query) => {
399
+ setComplimentaryQuery(query)
400
+ dispatch({ type: 'set-complimentary-page', page: 1 })
401
+ }}
402
+ complimentaryPage={s.complimentaryPage}
403
+ onComplimentaryPageChange={(p) => dispatch({ type: 'set-complimentary-page', page: p })}
404
+ onComplimentaryRevoke={(row) => void handleComplimentaryRevoke(row)}
405
+ onComplimentaryRetry={(row) => void handleComplimentaryRetry(row)}
270
406
  />
271
407
  )}
272
408
 
273
- {/* Gift modal */}
274
- {gift.open && (
275
- <GiftModal
276
- gift={gift}
277
- onChange={(field, value) => setGift((g) => ({ ...g, [field]: value }))}
278
- onSubmit={() => void handleGiftSubmit()}
279
- onClose={() => setGift((g) => ({ ...g, open: false, result: null }))}
409
+ {complimentaryGrant.open && (
410
+ <ComplimentaryGrantModal
411
+ grant={complimentaryGrant}
412
+ onChange={(field, value) => setComplimentaryGrant((g) => ({ ...g, [field]: value }))}
413
+ onSubmit={() => void handleComplimentaryGrantSubmit()}
414
+ onClose={() => setComplimentaryGrant((g) => ({ ...g, open: false, result: null }))}
280
415
  />
281
416
  )}
282
417
  </div>
@@ -287,13 +422,20 @@ export function SubscriptionsScreen() {
287
422
  // SubscriptionsBody
288
423
  // ---------------------------------------------------------------------------
289
424
 
290
- function SubscriptionsBody({
425
+ export function SubscriptionsBody({
291
426
  data,
292
427
  isPartial,
293
428
  filter,
294
429
  onFilterChange,
295
430
  page,
296
431
  onPageChange,
432
+ complimentaryNotice,
433
+ complimentaryQuery,
434
+ onComplimentaryQueryChange,
435
+ complimentaryPage,
436
+ onComplimentaryPageChange,
437
+ onComplimentaryRevoke,
438
+ onComplimentaryRetry,
297
439
  }: {
298
440
  data: SubscriptionsData
299
441
  isPartial: boolean
@@ -301,6 +443,13 @@ function SubscriptionsBody({
301
443
  onFilterChange: (f: FilterOption) => void
302
444
  page: number
303
445
  onPageChange: (p: number) => void
446
+ complimentaryNotice: ResultCopy | null
447
+ complimentaryQuery: ComplimentaryQuery
448
+ onComplimentaryQueryChange: (query: ComplimentaryQuery) => void
449
+ complimentaryPage: number
450
+ onComplimentaryPageChange: (p: number) => void
451
+ onComplimentaryRevoke: (row: ComplimentaryEntitlementRow) => void
452
+ onComplimentaryRetry: (row: ComplimentaryEntitlementRow) => void
304
453
  }) {
305
454
  // resolvePlanName: null impl echoes the id — the screen receives plan-mix from the
306
455
  // provider and resolves names via the plan-mix data directly (the provider already
@@ -357,7 +506,18 @@ function SubscriptionsBody({
357
506
  )}
358
507
  </div>
359
508
 
360
- {/* Admin-parity panels: Duplicate Accounts, Gift Orders, LemonSqueezy Sync */}
509
+ <ComplimentaryAccessPanel
510
+ state={data.complimentary}
511
+ notice={complimentaryNotice}
512
+ query={complimentaryQuery}
513
+ onQueryChange={onComplimentaryQueryChange}
514
+ page={complimentaryPage}
515
+ onPageChange={onComplimentaryPageChange}
516
+ onRevoke={onComplimentaryRevoke}
517
+ onRetry={onComplimentaryRetry}
518
+ />
519
+
520
+ {/* Admin-parity panels: Duplicate Accounts, paid Gift Orders, LemonSqueezy Sync */}
361
521
  <DuplicateAccountsPanel result={data.duplicates} />
362
522
  <GiftOrdersPanel result={data.gifts} />
363
523
  <LemonSqueezySyncPanel />
@@ -365,6 +525,372 @@ function SubscriptionsBody({
365
525
  )
366
526
  }
367
527
 
528
+ // ---------------------------------------------------------------------------
529
+ // ComplimentaryAccessPanel — current + historical manual access grants
530
+ // ---------------------------------------------------------------------------
531
+
532
+ function ComplimentaryAccessPanel({
533
+ state,
534
+ notice,
535
+ query,
536
+ onQueryChange,
537
+ page,
538
+ onPageChange,
539
+ onRevoke,
540
+ onRetry,
541
+ }: {
542
+ state: ComplimentaryPanelState
543
+ notice: ResultCopy | null
544
+ query: ComplimentaryQuery
545
+ onQueryChange: (query: ComplimentaryQuery) => void
546
+ page: number
547
+ onPageChange: (p: number) => void
548
+ onRevoke: (row: ComplimentaryEntitlementRow) => void
549
+ onRetry: (row: ComplimentaryEntitlementRow) => void
550
+ }) {
551
+ if (state.kind === 'unavailable') {
552
+ return (
553
+ <ComplimentaryPanelShell>
554
+ <EmptyPanel icon="users" message="Complimentary access is unavailable for your role." />
555
+ </ComplimentaryPanelShell>
556
+ )
557
+ }
558
+
559
+ if (state.kind === 'error') {
560
+ return (
561
+ <ComplimentaryPanelShell>
562
+ <ResultNotice
563
+ result={{
564
+ tone: 'error',
565
+ message:
566
+ 'Complimentary access data is temporarily unavailable. Paid subscription data remains available.',
567
+ }}
568
+ />
569
+ </ComplimentaryPanelShell>
570
+ )
571
+ }
572
+
573
+ const result = state.result
574
+ const data = result.data
575
+ const rows = data?.rows ?? []
576
+
577
+ return (
578
+ <ComplimentaryPanelShell>
579
+ {notice && <ResultNotice result={notice} />}
580
+
581
+ <div
582
+ style={{
583
+ display: 'flex',
584
+ flexWrap: 'wrap',
585
+ gap: 8,
586
+ alignItems: 'center',
587
+ marginBottom: 14,
588
+ }}
589
+ >
590
+ <input
591
+ type="search"
592
+ className="input"
593
+ placeholder="Search recipient email"
594
+ aria-label="Search recipient email"
595
+ maxLength={254}
596
+ value={query.q}
597
+ onChange={(event) => onQueryChange({ ...query, q: event.currentTarget.value })}
598
+ style={{ maxWidth: 320 }}
599
+ />
600
+ <select
601
+ className="input"
602
+ aria-label="Complimentary access status"
603
+ value={query.status}
604
+ onChange={(event) =>
605
+ onQueryChange({
606
+ ...query,
607
+ status: event.currentTarget.value as ComplimentaryStatusFilter,
608
+ })
609
+ }
610
+ style={{ maxWidth: 150 }}
611
+ >
612
+ <option value="all">All</option>
613
+ <option value="active">Active</option>
614
+ <option value="revoked">Revoked</option>
615
+ </select>
616
+ </div>
617
+
618
+ {data && (
619
+ <div
620
+ style={{
621
+ display: 'flex',
622
+ flexWrap: 'wrap',
623
+ gap: '6px 18px',
624
+ marginBottom: 14,
625
+ fontSize: 13,
626
+ color: 'var(--ink-soft)',
627
+ }}
628
+ >
629
+ <span>
630
+ <strong className="tnum">{data.summary.active}</strong> active
631
+ </span>
632
+ <span>
633
+ <strong className="tnum">{data.summary.revoked}</strong> revoked
634
+ </span>
635
+ <span>
636
+ <strong className="tnum">{data.summary.failedEmail}</strong> failed email
637
+ </span>
638
+ <span>
639
+ <strong className="tnum">{data.summary.unknownEmail}</strong> unknown email
640
+ </span>
641
+ <span>
642
+ <strong className="tnum">{data.summary.notAttempted}</strong> not attempted
643
+ </span>
644
+ </div>
645
+ )}
646
+
647
+ {result.status === 'partial' && (
648
+ <div className="kicker" style={{ color: 'var(--amber)', marginBottom: 12 }}>
649
+ Partial data
650
+ </div>
651
+ )}
652
+
653
+ {result.status === 'empty' || rows.length === 0 ? (
654
+ <EmptyPanel
655
+ icon="users"
656
+ message={
657
+ result.status === 'empty'
658
+ ? 'Complimentary access appears once the provider is configured.'
659
+ : 'No complimentary access records found.'
660
+ }
661
+ />
662
+ ) : (
663
+ <>
664
+ <div style={{ overflowX: 'auto' }}>
665
+ <table className="tbl" style={{ minWidth: 980 }}>
666
+ <thead>
667
+ <tr>
668
+ <th>Recipient</th>
669
+ <th>Access</th>
670
+ <th>Granted</th>
671
+ <th>Grant reason</th>
672
+ <th>Revocation</th>
673
+ <th>Email</th>
674
+ <th>Actions</th>
675
+ </tr>
676
+ </thead>
677
+ <tbody>
678
+ {rows.map((row) => {
679
+ const actions = complimentaryRowActions(row)
680
+ return (
681
+ <tr key={row.id}>
682
+ <td>
683
+ <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>
684
+ {row.email}
685
+ </div>
686
+ </td>
687
+ <td>
688
+ <AccessStatusBadge revokedAt={row.revokedAt} />
689
+ </td>
690
+ <td>
691
+ <div className="tnum" style={{ fontSize: 12.5, color: 'var(--ink)' }}>
692
+ {formatTimestamp(row.grantedAt)}
693
+ </div>
694
+ </td>
695
+ <td style={{ fontSize: 12.5, color: 'var(--ink-muted)' }}>
696
+ {row.grantReason}
697
+ </td>
698
+ <td style={{ fontSize: 12.5, color: 'var(--ink-muted)' }}>
699
+ {row.revokedAt === null ? (
700
+ '—'
701
+ ) : (
702
+ <>
703
+ <div>{formatTimestamp(row.revokedAt)}</div>
704
+ <div>{row.revokeReason}</div>
705
+ </>
706
+ )}
707
+ </td>
708
+ <td>
709
+ <EmailStatusBadge row={row} />
710
+ </td>
711
+ <td>
712
+ <div style={{ display: 'flex', gap: 8 }}>
713
+ <button
714
+ type="button"
715
+ className="btn sm"
716
+ disabled={!actions.canRevoke}
717
+ onClick={() => onRevoke(row)}
718
+ style={{ opacity: actions.canRevoke ? 1 : 0.45 }}
719
+ >
720
+ Revoke
721
+ </button>
722
+ <button
723
+ type="button"
724
+ className="btn sm"
725
+ disabled={!actions.canRetryEmail}
726
+ onClick={() => onRetry(row)}
727
+ title={actions.retryLabel}
728
+ style={{ opacity: actions.canRetryEmail ? 1 : 0.45 }}
729
+ >
730
+ {actions.retryLabel === 'Retry email' ? 'Retry' : 'Retry later'}
731
+ </button>
732
+ </div>
733
+ </td>
734
+ </tr>
735
+ )
736
+ })}
737
+ </tbody>
738
+ </table>
739
+ </div>
740
+ {data && (
741
+ <div
742
+ style={{
743
+ display: 'flex',
744
+ justifyContent: 'flex-end',
745
+ alignItems: 'center',
746
+ gap: 10,
747
+ paddingTop: 14,
748
+ }}
749
+ >
750
+ <button
751
+ type="button"
752
+ className="btn sm"
753
+ disabled={page <= 1}
754
+ onClick={() => onPageChange(page - 1)}
755
+ style={{ opacity: page <= 1 ? 0.4 : 1 }}
756
+ >
757
+ <Icon name="chevLeft" size={14} />
758
+ Prev
759
+ </button>
760
+ <span className="kicker">{complimentaryPageLabel(data)}</span>
761
+ <button
762
+ type="button"
763
+ className="btn sm"
764
+ disabled={data.totalPages <= page}
765
+ onClick={() => onPageChange(page + 1)}
766
+ style={{ opacity: data.totalPages <= page ? 0.4 : 1 }}
767
+ >
768
+ Next
769
+ <Icon name="chevRight" size={14} />
770
+ </button>
771
+ </div>
772
+ )}
773
+ </>
774
+ )}
775
+ </ComplimentaryPanelShell>
776
+ )
777
+ }
778
+
779
+ function ComplimentaryPanelShell({ children }: { children: React.ReactNode }) {
780
+ return (
781
+ <div className="panel panel-pad" style={{ marginBottom: 'var(--gap)' }}>
782
+ <div className="card-title">Complimentary access</div>
783
+ <div className="page-sub" style={{ margin: '4px 0 0' }}>
784
+ Manual access grants, revocations, and delivery history.
785
+ </div>
786
+ <hr className="masthead-rule" style={{ margin: '12px 0 16px' }} />
787
+ {children}
788
+ </div>
789
+ )
790
+ }
791
+
792
+ function AccessStatusBadge({ revokedAt }: { revokedAt: number | null }) {
793
+ const active = revokedAt === null
794
+ return (
795
+ <span className={`pill ${active ? 'published' : 'draft'}`}>
796
+ <span className="pdot" />
797
+ {active ? 'Active' : 'Revoked'}
798
+ </span>
799
+ )
800
+ }
801
+
802
+ function EmailStatusBadge({ row }: { row: ComplimentaryEntitlementRow }) {
803
+ const status = row.emailStatus
804
+ const cls =
805
+ status === 'sent'
806
+ ? 'published'
807
+ : status === 'failed' || status === 'unknown'
808
+ ? 'review'
809
+ : 'draft'
810
+ return (
811
+ <div>
812
+ <span className={`pill ${cls}`}>
813
+ <span className="pdot" />
814
+ {status.replace('_', ' ')}
815
+ </span>
816
+ <div style={{ marginTop: 4, fontSize: 12, color: 'var(--ink-muted)' }}>
817
+ {row.emailAttemptCount} attempts
818
+ </div>
819
+ </div>
820
+ )
821
+ }
822
+
823
+ function ResultNotice({ result }: { result: ResultCopy }) {
824
+ const color =
825
+ result.tone === 'success'
826
+ ? 'var(--green)'
827
+ : result.tone === 'warning'
828
+ ? 'var(--amber)'
829
+ : result.tone === 'info'
830
+ ? 'var(--accent)'
831
+ : 'var(--red)'
832
+ return (
833
+ <div
834
+ style={{
835
+ fontSize: 12.5,
836
+ color,
837
+ padding: '7px 10px',
838
+ background: `color-mix(in srgb, ${color} 8%, transparent)`,
839
+ borderRadius: 'var(--radius-sm)',
840
+ marginBottom: 14,
841
+ }}
842
+ >
843
+ {result.message}
844
+ </div>
845
+ )
846
+ }
847
+
848
+ async function readErrorMessage(res: Response): Promise<string> {
849
+ let nextEligibleAt: number | null = null
850
+ try {
851
+ const body = (await res.json()) as { error?: unknown; code?: unknown; nextEligibleAt?: unknown }
852
+ nextEligibleAt = typeof body.nextEligibleAt === 'number' ? body.nextEligibleAt : null
853
+ } catch {
854
+ // The status code is still enough to return bounded public copy.
855
+ }
856
+ const base =
857
+ res.status === 400
858
+ ? 'The request could not be processed.'
859
+ : res.status === 401 || res.status === 403
860
+ ? 'You do not have permission to manage complimentary access.'
861
+ : res.status === 409
862
+ ? 'The request conflicts with the current complimentary access state.'
863
+ : res.status === 429
864
+ ? 'Please wait before retrying the email.'
865
+ : res.status >= 500
866
+ ? 'The complimentary access service is temporarily unavailable.'
867
+ : 'The request failed. Please try again.'
868
+ if (nextEligibleAt !== null) {
869
+ const formatted = formatTimestamp(nextEligibleAt)
870
+ return formatted === 'Date unavailable' ? base : `${base} Try again after ${formatted}.`
871
+ }
872
+ return base
873
+ }
874
+
875
+ async function fetchComplimentaryPanel(
876
+ query: ComplimentaryQuery,
877
+ page: number,
878
+ ): Promise<ComplimentaryPanelState> {
879
+ try {
880
+ const response = await fetch(buildComplimentaryListUrl(query, page))
881
+ if (response.status === 401 || response.status === 403) {
882
+ return { kind: 'unavailable', reason: 'forbidden' }
883
+ }
884
+ if (!response.ok) return { kind: 'error', reason: 'request_failed' }
885
+ return {
886
+ kind: 'available',
887
+ result: (await response.json()) as ProviderResult<ComplimentaryAccessPage>,
888
+ }
889
+ } catch {
890
+ return { kind: 'error', reason: 'request_failed' }
891
+ }
892
+ }
893
+
368
894
  // ---------------------------------------------------------------------------
369
895
  // DuplicateAccountsPanel — billing-email + canonical-email conflict groups
370
896
  // ---------------------------------------------------------------------------
@@ -594,8 +1120,12 @@ function LemonSqueezySyncPanel() {
594
1120
  }
595
1121
  const body = (await res.json()) as ProviderResult<LsImportSummary>
596
1122
  setS({ running: false, summary: body.data, error: null })
597
- } catch (err) {
598
- setS({ running: false, summary: null, error: String(err) })
1123
+ } catch {
1124
+ setS({
1125
+ running: false,
1126
+ summary: null,
1127
+ error: 'LemonSqueezy sync could not be completed. Please try again.',
1128
+ })
599
1129
  }
600
1130
  }, [])
601
1131
 
@@ -1057,28 +1587,27 @@ function FilterSegment({
1057
1587
  }
1058
1588
 
1059
1589
  // ---------------------------------------------------------------------------
1060
- // GiftModal — .scrim + .panel.panel-pad dialog
1590
+ // ComplimentaryGrantModal — .scrim + .panel.panel-pad dialog
1061
1591
  // ---------------------------------------------------------------------------
1062
1592
 
1063
- function GiftModal({
1064
- gift,
1593
+ function ComplimentaryGrantModal({
1594
+ grant,
1065
1595
  onChange,
1066
1596
  onSubmit,
1067
1597
  onClose,
1068
1598
  }: {
1069
- gift: GiftState
1070
- onChange: (field: 'email' | 'planVariantId', value: string) => void
1599
+ grant: ComplimentaryGrantState
1600
+ onChange: (field: 'email' | 'reason', value: string) => void
1071
1601
  onSubmit: () => void
1072
1602
  onClose: () => void
1073
1603
  }) {
1074
- const ok = gift.result?.startsWith('Gift created')
1075
1604
  return (
1076
1605
  <div
1077
1606
  className="scrim"
1078
1607
  style={{ placeItems: 'center', zIndex: 100 }}
1079
1608
  role="dialog"
1080
1609
  aria-modal="true"
1081
- aria-label="Gift subscription"
1610
+ aria-label="Grant complimentary access"
1082
1611
  onClick={onClose}
1083
1612
  onKeyUp={(e) => {
1084
1613
  if (e.key === 'Escape') onClose()
@@ -1088,12 +1617,12 @@ function GiftModal({
1088
1617
  <div
1089
1618
  className="panel panel-pad fade-only"
1090
1619
  role="document"
1091
- style={{ width: 420, boxShadow: 'var(--shadow-lg)' }}
1620
+ style={{ width: 460, boxShadow: 'var(--shadow-lg)' }}
1092
1621
  onClick={(e) => e.stopPropagation()}
1093
1622
  >
1094
1623
  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
1095
1624
  <Icon name="sparkle" size={18} style={{ color: 'var(--accent)' }} />
1096
- <div className="card-title">Gift subscription</div>
1625
+ <div className="card-title">Grant complimentary access</div>
1097
1626
  <button
1098
1627
  type="button"
1099
1628
  className="icon-btn"
@@ -1110,39 +1639,25 @@ function GiftModal({
1110
1639
  <input
1111
1640
  className="input"
1112
1641
  type="email"
1113
- value={gift.email}
1642
+ value={grant.email}
1114
1643
  onChange={(e) => onChange('email', e.target.value)}
1115
- placeholder="subscriber@example.com"
1644
+ placeholder="recipient@example.com"
1116
1645
  />
1117
1646
  </label>
1118
1647
 
1119
1648
  <label style={{ display: 'block', marginBottom: 16 }}>
1120
- <span className="field-label">Plan variant ID</span>
1121
- <input
1649
+ <span className="field-label">Grant reason</span>
1650
+ <textarea
1122
1651
  className="input"
1123
- type="text"
1124
- value={gift.planVariantId}
1125
- onChange={(e) => onChange('planVariantId', e.target.value)}
1126
- placeholder="e.g. variant_123"
1652
+ value={grant.reason}
1653
+ onChange={(e) => onChange('reason', e.target.value)}
1654
+ placeholder="Required audit reason"
1655
+ rows={4}
1656
+ style={{ resize: 'vertical' }}
1127
1657
  />
1128
1658
  </label>
1129
1659
 
1130
- {gift.result && (
1131
- <div
1132
- style={{
1133
- fontSize: 12,
1134
- color: ok ? 'var(--green)' : 'var(--red)',
1135
- padding: '6px 10px',
1136
- background: ok
1137
- ? 'color-mix(in srgb, var(--green) 8%, transparent)'
1138
- : 'color-mix(in srgb, var(--red) 8%, transparent)',
1139
- borderRadius: 'var(--radius-sm)',
1140
- marginBottom: 16,
1141
- }}
1142
- >
1143
- {gift.result}
1144
- </div>
1145
- )}
1660
+ {grant.result && <ResultNotice result={grant.result} />}
1146
1661
 
1147
1662
  <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
1148
1663
  <button type="button" className="btn" onClick={onClose}>
@@ -1152,12 +1667,12 @@ function GiftModal({
1152
1667
  type="button"
1153
1668
  className="btn primary"
1154
1669
  onClick={onSubmit}
1155
- disabled={gift.submitting || !gift.email || !gift.planVariantId}
1670
+ disabled={complimentaryGrantSubmitDisabled(grant, grant.submitting)}
1156
1671
  style={{
1157
- opacity: gift.submitting || !gift.email || !gift.planVariantId ? 0.5 : 1,
1672
+ opacity: complimentaryGrantSubmitDisabled(grant, grant.submitting) ? 0.5 : 1,
1158
1673
  }}
1159
1674
  >
1160
- {gift.submitting ? 'Gifting…' : 'Send gift'}
1675
+ {grant.submitting ? 'Granting…' : 'Grant access'}
1161
1676
  </button>
1162
1677
  </div>
1163
1678
  </div>