@kernhq/module-billing 0.2.0 → 0.3.1
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 +25 -5
- package/src/client/admin/PlansAdmin.svelte +404 -0
- package/src/client/admin/SubscriptionsAdmin.svelte +317 -0
- package/src/client/api-instance.ts +36 -0
- package/src/client/format.test.ts +1 -1
- package/src/client/format.ts +66 -0
- package/src/client/i18n.ts +36 -0
- package/src/client/index.ts +17 -62
- package/src/client/mock.ts +222 -0
- package/src/client/module.ts +60 -0
- package/src/client/settings/PlanSettings.svelte +356 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-billing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Kern billing module: plans, entitlements, subscriptions and Stripe — the mechanism that lets any Kern instance sell seats on itself",
|
|
5
5
|
"homepage": "https://kernaio.com",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"./migrations": "./migrations"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@kernhq/contracts": "^0.
|
|
47
|
-
"@kernhq/kernel": "^0.
|
|
48
|
-
"@kernhq/sdk": "^0.1.
|
|
46
|
+
"@kernhq/contracts": "^0.5.1",
|
|
47
|
+
"@kernhq/kernel": "^0.7.0",
|
|
48
|
+
"@kernhq/sdk": "^0.1.5",
|
|
49
49
|
"@orpc/contract": "^1.15.0",
|
|
50
50
|
"@orpc/server": "^1.15.0",
|
|
51
51
|
"drizzle-orm": "^0.45.0",
|
|
@@ -54,17 +54,37 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@kernhq/tsconfig": "^0.1.0",
|
|
57
|
+
"@kernhq/ui": "^0.8.0",
|
|
58
|
+
"@tanstack/svelte-query": "^6.1.0",
|
|
57
59
|
"@types/node": "^24.0.0",
|
|
58
60
|
"@types/pg": "^8.15.0",
|
|
59
61
|
"drizzle-kit": "^0.31.0",
|
|
60
62
|
"pg": "^8.16.0",
|
|
63
|
+
"svelte": "^5.46.0",
|
|
64
|
+
"svelte-check": "^4.0.0",
|
|
61
65
|
"typescript": "~5.9.3",
|
|
62
66
|
"vitest": "^4.0.0"
|
|
63
67
|
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"@kernhq/ui": "^0.8.0",
|
|
70
|
+
"@tanstack/svelte-query": "^6.1.0",
|
|
71
|
+
"svelte": "^5.46.0"
|
|
72
|
+
},
|
|
73
|
+
"peerDependenciesMeta": {
|
|
74
|
+
"@kernhq/ui": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"@tanstack/svelte-query": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"svelte": {
|
|
81
|
+
"optional": true
|
|
82
|
+
}
|
|
83
|
+
},
|
|
64
84
|
"scripts": {
|
|
65
85
|
"build": "tsc -p tsconfig.json",
|
|
66
86
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
67
|
-
"typecheck": "tsc -p tsconfig.json --noEmit &&
|
|
87
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
|
|
68
88
|
"lint": "biome check .",
|
|
69
89
|
"test": "vitest run",
|
|
70
90
|
"db:generate": "drizzle-kit generate",
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
Dialog,
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
EmptyState,
|
|
9
|
+
Field,
|
|
10
|
+
IconButton,
|
|
11
|
+
Input,
|
|
12
|
+
type MenuItem,
|
|
13
|
+
messageLocale,
|
|
14
|
+
Select,
|
|
15
|
+
Skeleton,
|
|
16
|
+
Table,
|
|
17
|
+
TableCell,
|
|
18
|
+
TableHeader,
|
|
19
|
+
TableRow,
|
|
20
|
+
Textarea,
|
|
21
|
+
toast,
|
|
22
|
+
} from '@kernhq/ui'
|
|
23
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
24
|
+
import { getBillingApi } from '../api-instance.js'
|
|
25
|
+
import { t } from '../i18n.js'
|
|
26
|
+
import type { Plan } from '../index.js'
|
|
27
|
+
import { formatMoney } from '../index.js'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The catalogue: what this instance sells.
|
|
31
|
+
*
|
|
32
|
+
* A plan is data rather than code precisely so that this screen exists — the price an instance
|
|
33
|
+
* charges, what it includes, and what its marketing page shows are one row, edited here, instead of
|
|
34
|
+
* a constant in a repository and a second copy on a website that drift apart.
|
|
35
|
+
*
|
|
36
|
+
* A limit left empty means unlimited. The set of limits is fixed by the contract: an administrator
|
|
37
|
+
* decides the values, never the keys, because a key nothing enforces is a promise nobody keeps.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const api = getBillingApi()
|
|
41
|
+
const queryClient = useQueryClient()
|
|
42
|
+
const locale = $derived(messageLocale())
|
|
43
|
+
const nf = $derived(new Intl.NumberFormat(locale))
|
|
44
|
+
|
|
45
|
+
const GB = 1024 ** 3
|
|
46
|
+
|
|
47
|
+
interface Draft {
|
|
48
|
+
id?: string
|
|
49
|
+
name: string
|
|
50
|
+
slug: string
|
|
51
|
+
description: string
|
|
52
|
+
price: string
|
|
53
|
+
currency: string
|
|
54
|
+
interval: 'month' | 'year'
|
|
55
|
+
perSeat: boolean
|
|
56
|
+
trialDays: string
|
|
57
|
+
seats: string
|
|
58
|
+
storageGb: string
|
|
59
|
+
sso: boolean
|
|
60
|
+
auditDays: string
|
|
61
|
+
stripePriceId: string
|
|
62
|
+
order: string
|
|
63
|
+
published: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const emptyDraft = (): Draft => ({
|
|
67
|
+
name: '',
|
|
68
|
+
slug: '',
|
|
69
|
+
description: '',
|
|
70
|
+
price: '0',
|
|
71
|
+
currency: 'usd',
|
|
72
|
+
interval: 'month',
|
|
73
|
+
perSeat: true,
|
|
74
|
+
trialDays: '14',
|
|
75
|
+
seats: '',
|
|
76
|
+
storageGb: '',
|
|
77
|
+
sso: true,
|
|
78
|
+
auditDays: '',
|
|
79
|
+
stripePriceId: '',
|
|
80
|
+
order: '100',
|
|
81
|
+
published: false,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const toDraft = (p: Plan): Draft => ({
|
|
85
|
+
id: p.id,
|
|
86
|
+
name: p.name,
|
|
87
|
+
slug: p.slug,
|
|
88
|
+
description: p.description,
|
|
89
|
+
price: String(p.priceMinor / 100),
|
|
90
|
+
currency: p.currency,
|
|
91
|
+
interval: p.interval,
|
|
92
|
+
perSeat: p.perSeat,
|
|
93
|
+
trialDays: String(p.trialDays),
|
|
94
|
+
seats: p.limits.seats === null ? '' : String(p.limits.seats),
|
|
95
|
+
storageGb: p.limits.storageBytes === null ? '' : String(Math.round(p.limits.storageBytes / GB)),
|
|
96
|
+
sso: p.limits.sso,
|
|
97
|
+
auditDays: p.limits.auditRetentionDays === null ? '' : String(p.limits.auditRetentionDays),
|
|
98
|
+
stripePriceId: p.stripePriceId ?? '',
|
|
99
|
+
order: String(p.order),
|
|
100
|
+
published: p.published,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** An empty field means "no limit", which is not the same as zero. */
|
|
104
|
+
const numOrNull = (v: string) => (v.trim() === '' ? null : Number(v))
|
|
105
|
+
|
|
106
|
+
let editing = $state<Draft | null>(null)
|
|
107
|
+
let archiving = $state<Plan | null>(null)
|
|
108
|
+
|
|
109
|
+
const plans = createQuery(() => ({
|
|
110
|
+
queryKey: ['billing', 'plan', 'all'],
|
|
111
|
+
queryFn: () => api.plans.list({ includeUnpublished: true }),
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: ['billing'] })
|
|
115
|
+
|
|
116
|
+
const save = createMutation(() => ({
|
|
117
|
+
mutationFn: (d: Draft) =>
|
|
118
|
+
api.plans.upsert({
|
|
119
|
+
...(d.id ? { id: d.id } : {}),
|
|
120
|
+
slug: d.slug.trim(),
|
|
121
|
+
name: d.name.trim(),
|
|
122
|
+
description: d.description.trim(),
|
|
123
|
+
priceMinor: Math.round(Number(d.price || 0) * 100),
|
|
124
|
+
currency: d.currency.trim().toLowerCase(),
|
|
125
|
+
interval: d.interval,
|
|
126
|
+
perSeat: d.perSeat,
|
|
127
|
+
trialDays: Number(d.trialDays || 0),
|
|
128
|
+
limits: {
|
|
129
|
+
seats: numOrNull(d.seats),
|
|
130
|
+
storageBytes: d.storageGb.trim() === '' ? null : Number(d.storageGb) * GB,
|
|
131
|
+
modules: null,
|
|
132
|
+
sso: d.sso,
|
|
133
|
+
auditRetentionDays: numOrNull(d.auditDays),
|
|
134
|
+
apiRateLimit: null,
|
|
135
|
+
},
|
|
136
|
+
stripePriceId: d.stripePriceId.trim() || null,
|
|
137
|
+
highlights: [],
|
|
138
|
+
published: d.published,
|
|
139
|
+
order: Number(d.order || 100),
|
|
140
|
+
}),
|
|
141
|
+
onSuccess: () => {
|
|
142
|
+
toast.success(t('admin_plan_saved'))
|
|
143
|
+
editing = null
|
|
144
|
+
void refresh()
|
|
145
|
+
},
|
|
146
|
+
onError: (e: Error) => toast.error(e.message),
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
const setPublished = createMutation(() => ({
|
|
150
|
+
mutationFn: (v: { id: string; published: boolean }) => api.plans.setPublished(v),
|
|
151
|
+
onSuccess: (plan) => {
|
|
152
|
+
toast.success(
|
|
153
|
+
plan.published
|
|
154
|
+
? t('admin_plan_published_toast', { name: plan.name })
|
|
155
|
+
: t('admin_plan_unpublished_toast', { name: plan.name }),
|
|
156
|
+
)
|
|
157
|
+
void refresh()
|
|
158
|
+
},
|
|
159
|
+
onError: (e: Error) => toast.error(e.message),
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
const archive = createMutation(() => ({
|
|
163
|
+
mutationFn: (id: string) => api.plans.archive({ id }),
|
|
164
|
+
onSuccess: () => {
|
|
165
|
+
toast.success(t('admin_plan_archived_toast', { name: archiving?.name ?? '' }))
|
|
166
|
+
archiving = null
|
|
167
|
+
void refresh()
|
|
168
|
+
},
|
|
169
|
+
onError: (e: Error) => toast.error(e.message),
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
function actionsFor(plan: Plan): MenuItem[] {
|
|
173
|
+
return [
|
|
174
|
+
{ label: t('admin_plan_edit'), icon: 'pencil', onSelect: () => (editing = toDraft(plan)) },
|
|
175
|
+
{
|
|
176
|
+
label: plan.published ? t('admin_plan_unpublish') : t('admin_plan_publish'),
|
|
177
|
+
icon: plan.published ? 'eye-off' : 'eye',
|
|
178
|
+
// A plan with no Stripe price can still be published — it just cannot be bought, only
|
|
179
|
+
// assigned. The form says so rather than the menu refusing.
|
|
180
|
+
onSelect: () => setPublished.mutate({ id: plan.id, published: !plan.published }),
|
|
181
|
+
},
|
|
182
|
+
{ type: 'separator' },
|
|
183
|
+
{
|
|
184
|
+
label: t('admin_plan_archive'),
|
|
185
|
+
icon: 'archive',
|
|
186
|
+
danger: true,
|
|
187
|
+
onSelect: () => (archiving = plan),
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const intervalOptions = $derived([
|
|
193
|
+
{ value: 'month', label: t('admin_plan_interval_month') },
|
|
194
|
+
{ value: 'year', label: t('admin_plan_interval_year') },
|
|
195
|
+
])
|
|
196
|
+
</script>
|
|
197
|
+
|
|
198
|
+
<svelte:head><title>{t('admin_plans_title')} · {t('common.admin')}</title></svelte:head>
|
|
199
|
+
|
|
200
|
+
<div class="grid gap-5">
|
|
201
|
+
<header class="flex flex-wrap items-start justify-between gap-3">
|
|
202
|
+
<div class="grid gap-1">
|
|
203
|
+
<h1 class="text-[20px] font-medium text-[var(--kern-ink-900)]">{t('admin_plans_title')}</h1>
|
|
204
|
+
<p class="text-[13px] text-[var(--kern-ink-400)]">{t('admin_plans_subtitle')}</p>
|
|
205
|
+
</div>
|
|
206
|
+
<Button onclick={() => (editing = emptyDraft())}>{t('admin_plan_new')}</Button>
|
|
207
|
+
</header>
|
|
208
|
+
|
|
209
|
+
{#if plans.isPending}
|
|
210
|
+
<Skeleton class="h-[200px] w-full rounded-[var(--kern-r-md)]" />
|
|
211
|
+
{:else if plans.isError}
|
|
212
|
+
<EmptyState title={t('admin_error')} icon="triangle-alert">
|
|
213
|
+
{#snippet actions()}
|
|
214
|
+
<Button variant="secondary" onclick={() => void refresh()}>{t('retry')}</Button>
|
|
215
|
+
{/snippet}
|
|
216
|
+
</EmptyState>
|
|
217
|
+
{:else if (plans.data ?? []).length === 0}
|
|
218
|
+
<EmptyState title={t('admin_plans_empty')} description={t('admin_plans_empty_hint')} icon="tag">
|
|
219
|
+
{#snippet actions()}
|
|
220
|
+
<Button onclick={() => (editing = emptyDraft())}>{t('admin_plan_new')}</Button>
|
|
221
|
+
{/snippet}
|
|
222
|
+
</EmptyState>
|
|
223
|
+
{:else}
|
|
224
|
+
<div class="overflow-x-auto">
|
|
225
|
+
<Table columns="minmax(160px,2fr) minmax(120px,1fr) minmax(110px,auto) minmax(90px,auto) minmax(110px,auto) 40px">
|
|
226
|
+
<TableHeader>
|
|
227
|
+
<TableCell header>{t('admin_plan_name')}</TableCell>
|
|
228
|
+
<TableCell header>{t('admin_plan_price')}</TableCell>
|
|
229
|
+
<TableCell header end>{t('admin_plan_seats')}</TableCell>
|
|
230
|
+
<TableCell header end>{t('admin_plan_storage_gb')}</TableCell>
|
|
231
|
+
<TableCell header>{t('status')}</TableCell>
|
|
232
|
+
<TableCell header end></TableCell>
|
|
233
|
+
</TableHeader>
|
|
234
|
+
{#each plans.data ?? [] as plan (plan.id)}
|
|
235
|
+
<TableRow>
|
|
236
|
+
<TableCell>
|
|
237
|
+
<div class="grid">
|
|
238
|
+
<span class="truncate text-[13px] text-[var(--kern-ink-900)]">{plan.name}</span>
|
|
239
|
+
<span class="truncate font-[var(--kern-font-mono)] text-[11px] text-[var(--kern-ink-400)]">
|
|
240
|
+
{plan.slug}
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
</TableCell>
|
|
244
|
+
<TableCell>
|
|
245
|
+
{formatMoney(plan.priceMinor, plan.currency, locale)}
|
|
246
|
+
<span class="text-[11px] text-[var(--kern-ink-400)]">
|
|
247
|
+
{plan.interval === 'year' ? t('per_year') : t('per_month')}
|
|
248
|
+
</span>
|
|
249
|
+
</TableCell>
|
|
250
|
+
<TableCell end>{plan.limits.seats == null ? t('unlimited') : nf.format(plan.limits.seats)}</TableCell>
|
|
251
|
+
<TableCell end>
|
|
252
|
+
{plan.limits.storageBytes === null
|
|
253
|
+
? t('unlimited')
|
|
254
|
+
: nf.format(Math.round(plan.limits.storageBytes / GB))}
|
|
255
|
+
</TableCell>
|
|
256
|
+
<TableCell>
|
|
257
|
+
<Badge tone={plan.published ? 'success' : 'grey'}>
|
|
258
|
+
{plan.published ? t('admin_plan_published') : t('admin_plan_draft')}
|
|
259
|
+
</Badge>
|
|
260
|
+
</TableCell>
|
|
261
|
+
<TableCell end>
|
|
262
|
+
<DropdownMenu items={actionsFor(plan)} align="end">
|
|
263
|
+
{#snippet trigger(props)}
|
|
264
|
+
<IconButton {...props} icon="ellipsis" size={28} label={t('actions')} />
|
|
265
|
+
{/snippet}
|
|
266
|
+
</DropdownMenu>
|
|
267
|
+
</TableCell>
|
|
268
|
+
</TableRow>
|
|
269
|
+
{/each}
|
|
270
|
+
</Table>
|
|
271
|
+
</div>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<Dialog
|
|
276
|
+
open={editing !== null}
|
|
277
|
+
size="lg"
|
|
278
|
+
title={editing?.id ? t('admin_plan_edit') : t('admin_plan_new')}
|
|
279
|
+
onOpenChange={(o) => {
|
|
280
|
+
if (!o) editing = null
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
{#snippet children()}
|
|
284
|
+
{#if editing}
|
|
285
|
+
<div class="grid gap-4">
|
|
286
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
287
|
+
<Field label={t('admin_plan_name')}>
|
|
288
|
+
{#snippet children(id)}
|
|
289
|
+
<Input {id} bind:value={editing!.name} />
|
|
290
|
+
{/snippet}
|
|
291
|
+
</Field>
|
|
292
|
+
<Field label={t('admin_plan_slug')} hint={t('admin_plan_slug_hint')}>
|
|
293
|
+
{#snippet children(id)}
|
|
294
|
+
<Input {id} bind:value={editing!.slug} />
|
|
295
|
+
{/snippet}
|
|
296
|
+
</Field>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<Field label={t('admin_plan_description')}>
|
|
300
|
+
{#snippet children(id)}
|
|
301
|
+
<Textarea {id} bind:value={editing!.description} rows={2} />
|
|
302
|
+
{/snippet}
|
|
303
|
+
</Field>
|
|
304
|
+
|
|
305
|
+
<div class="grid gap-4 sm:grid-cols-3">
|
|
306
|
+
<Field label={t('admin_plan_price')} hint={t('admin_plan_price_hint')}>
|
|
307
|
+
{#snippet children(id)}
|
|
308
|
+
<Input {id} type="number" min="0" step="1" bind:value={editing!.price} />
|
|
309
|
+
{/snippet}
|
|
310
|
+
</Field>
|
|
311
|
+
<Field label={t('admin_plan_currency')}>
|
|
312
|
+
{#snippet children(id)}
|
|
313
|
+
<Input {id} bind:value={editing!.currency} />
|
|
314
|
+
{/snippet}
|
|
315
|
+
</Field>
|
|
316
|
+
<Field label={t('admin_plan_interval')}>
|
|
317
|
+
{#snippet children(id)}
|
|
318
|
+
<Select {id} bind:value={editing!.interval} options={intervalOptions} />
|
|
319
|
+
{/snippet}
|
|
320
|
+
</Field>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
324
|
+
<Checkbox
|
|
325
|
+
bind:checked={editing!.perSeat}
|
|
326
|
+
label={t('admin_plan_per_seat')}
|
|
327
|
+
description={t('admin_plan_per_seat_hint')}
|
|
328
|
+
/>
|
|
329
|
+
<Field label={t('admin_plan_trial_days')}>
|
|
330
|
+
{#snippet children(id)}
|
|
331
|
+
<Input {id} type="number" min="0" bind:value={editing!.trialDays} />
|
|
332
|
+
{/snippet}
|
|
333
|
+
</Field>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<div class="grid gap-2">
|
|
337
|
+
<span class="text-[13px] font-medium text-[var(--kern-ink-900)]">{t('admin_plan_limits')}</span>
|
|
338
|
+
<span class="text-[12px] text-[var(--kern-ink-400)]">{t('admin_plan_limits_hint')}</span>
|
|
339
|
+
<div class="mt-1 grid gap-4 sm:grid-cols-3">
|
|
340
|
+
<Field label={t('admin_plan_seats')}>
|
|
341
|
+
{#snippet children(id)}
|
|
342
|
+
<Input {id} type="number" min="1" bind:value={editing!.seats} />
|
|
343
|
+
{/snippet}
|
|
344
|
+
</Field>
|
|
345
|
+
<Field label={t('admin_plan_storage_gb')}>
|
|
346
|
+
{#snippet children(id)}
|
|
347
|
+
<Input {id} type="number" min="0" bind:value={editing!.storageGb} />
|
|
348
|
+
{/snippet}
|
|
349
|
+
</Field>
|
|
350
|
+
<Field label={t('admin_plan_audit_days')}>
|
|
351
|
+
{#snippet children(id)}
|
|
352
|
+
<Input {id} type="number" min="1" bind:value={editing!.auditDays} />
|
|
353
|
+
{/snippet}
|
|
354
|
+
</Field>
|
|
355
|
+
</div>
|
|
356
|
+
<Checkbox bind:checked={editing!.sso} label={t('admin_plan_sso')} />
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
360
|
+
<Field label={t('admin_plan_stripe_price')} hint={t('admin_plan_stripe_price_hint')}>
|
|
361
|
+
{#snippet children(id)}
|
|
362
|
+
<Input {id} bind:value={editing!.stripePriceId} placeholder="price_…" />
|
|
363
|
+
{/snippet}
|
|
364
|
+
</Field>
|
|
365
|
+
<Field label={t('admin_plan_order')}>
|
|
366
|
+
{#snippet children(id)}
|
|
367
|
+
<Input {id} type="number" bind:value={editing!.order} />
|
|
368
|
+
{/snippet}
|
|
369
|
+
</Field>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<Checkbox bind:checked={editing!.published} label={t('admin_plan_published')} />
|
|
373
|
+
</div>
|
|
374
|
+
{/if}
|
|
375
|
+
{/snippet}
|
|
376
|
+
{#snippet footer()}
|
|
377
|
+
<Button variant="secondary" onclick={() => (editing = null)}>{t('admin_cancel')}</Button>
|
|
378
|
+
<Button
|
|
379
|
+
loading={save.isPending}
|
|
380
|
+
disabled={!editing?.name.trim() || !editing?.slug.trim()}
|
|
381
|
+
onclick={() => editing && save.mutate($state.snapshot(editing))}
|
|
382
|
+
>
|
|
383
|
+
{t('admin_save')}
|
|
384
|
+
</Button>
|
|
385
|
+
{/snippet}
|
|
386
|
+
</Dialog>
|
|
387
|
+
|
|
388
|
+
<!-- Archiving withdraws a plan from sale; whoever is on it stays on it, and the dialog says so. -->
|
|
389
|
+
<Dialog
|
|
390
|
+
open={archiving !== null}
|
|
391
|
+
title={archiving ? t('admin_archive_title', { name: archiving.name }) : ''}
|
|
392
|
+
description={t('admin_archive_body')}
|
|
393
|
+
onOpenChange={(o) => {
|
|
394
|
+
if (!o) archiving = null
|
|
395
|
+
}}
|
|
396
|
+
>
|
|
397
|
+
{#snippet children()}{/snippet}
|
|
398
|
+
{#snippet footer()}
|
|
399
|
+
<Button variant="secondary" onclick={() => (archiving = null)}>{t('admin_cancel')}</Button>
|
|
400
|
+
<Button variant="danger" loading={archive.isPending} onclick={() => archiving && archive.mutate(archiving.id)}>
|
|
401
|
+
{t('admin_archive_confirm')}
|
|
402
|
+
</Button>
|
|
403
|
+
{/snippet}
|
|
404
|
+
</Dialog>
|