@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.
- package/README.md +10 -2
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +20 -3
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +93 -6
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +20 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +4 -0
- package/dist/routes/authz-matrix.js.map +1 -1
- package/dist/routes/context.d.ts +1 -1
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/subscriptions.d.ts +9 -2
- package/dist/routes/subscriptions.d.ts.map +1 -1
- package/dist/routes/subscriptions.js +202 -21
- package/dist/routes/subscriptions.js.map +1 -1
- package/dist/ui/api/subscriptions.d.ts +1 -1
- package/dist/ui/api/subscriptions.d.ts.map +1 -1
- package/dist/ui/api/subscriptions.js +27 -5
- package/dist/ui/api/subscriptions.js.map +1 -1
- package/dist/ui/screens/SubscriptionsScreen.d.ts +44 -0
- package/dist/ui/screens/SubscriptionsScreen.d.ts.map +1 -1
- package/dist/ui/screens/SubscriptionsScreen.js +262 -54
- package/dist/ui/screens/SubscriptionsScreen.js.map +1 -1
- package/dist/ui/screens/subscriptions-data.d.ts +33 -0
- package/dist/ui/screens/subscriptions-data.d.ts.map +1 -0
- package/dist/ui/screens/subscriptions-data.js +143 -0
- package/dist/ui/screens/subscriptions-data.js.map +1 -0
- package/package.json +1 -1
- package/src/providers/null.ts +32 -3
- package/src/providers/types.ts +131 -4
- package/src/routes/authz-matrix.ts +4 -0
- package/src/routes/context.ts +3 -0
- package/src/routes/subscriptions.ts +269 -27
- package/src/ui/api/subscriptions.ts +27 -5
- package/src/ui/screens/SubscriptionsScreen.tsx +631 -116
- 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,
|
|
5
|
-
// GET /admin/api/subscriptions?type
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
143
|
+
// Complimentary grant form state
|
|
101
144
|
// ---------------------------------------------------------------------------
|
|
102
145
|
|
|
103
|
-
interface
|
|
146
|
+
interface ComplimentaryGrantState {
|
|
104
147
|
open: boolean
|
|
105
148
|
email: string
|
|
106
|
-
|
|
149
|
+
reason: string
|
|
107
150
|
submitting: boolean
|
|
108
|
-
result:
|
|
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 [
|
|
161
|
+
const [complimentaryGrant, setComplimentaryGrant] = useState<ComplimentaryGrantState>({
|
|
119
162
|
open: false,
|
|
120
163
|
email: '',
|
|
121
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
188
|
-
if (
|
|
189
|
-
|
|
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(
|
|
256
|
+
body: JSON.stringify(buildComplimentaryGrantPayload(complimentaryGrant)),
|
|
195
257
|
})
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
204
|
-
email: '',
|
|
205
|
-
planVariantId: '',
|
|
263
|
+
result: { tone: 'error', message },
|
|
206
264
|
}))
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
{
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
|
598
|
-
setS({
|
|
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
|
-
//
|
|
1590
|
+
// ComplimentaryGrantModal — .scrim + .panel.panel-pad dialog
|
|
1061
1591
|
// ---------------------------------------------------------------------------
|
|
1062
1592
|
|
|
1063
|
-
function
|
|
1064
|
-
|
|
1593
|
+
function ComplimentaryGrantModal({
|
|
1594
|
+
grant,
|
|
1065
1595
|
onChange,
|
|
1066
1596
|
onSubmit,
|
|
1067
1597
|
onClose,
|
|
1068
1598
|
}: {
|
|
1069
|
-
|
|
1070
|
-
onChange: (field: 'email' | '
|
|
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="
|
|
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:
|
|
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">
|
|
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={
|
|
1642
|
+
value={grant.email}
|
|
1114
1643
|
onChange={(e) => onChange('email', e.target.value)}
|
|
1115
|
-
placeholder="
|
|
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">
|
|
1121
|
-
<
|
|
1649
|
+
<span className="field-label">Grant reason</span>
|
|
1650
|
+
<textarea
|
|
1122
1651
|
className="input"
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
{
|
|
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={
|
|
1670
|
+
disabled={complimentaryGrantSubmitDisabled(grant, grant.submitting)}
|
|
1156
1671
|
style={{
|
|
1157
|
-
opacity:
|
|
1672
|
+
opacity: complimentaryGrantSubmitDisabled(grant, grant.submitting) ? 0.5 : 1,
|
|
1158
1673
|
}}
|
|
1159
1674
|
>
|
|
1160
|
-
{
|
|
1675
|
+
{grant.submitting ? 'Granting…' : 'Grant access'}
|
|
1161
1676
|
</button>
|
|
1162
1677
|
</div>
|
|
1163
1678
|
</div>
|