@odla-ai/chapter 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,781 @@
1
+ // src/ui/admin.tsx
2
+ import { useEffect as useEffect3, useMemo, useState as useState3 } from "react";
3
+ import { ClerkGate, SignedIn, SignedOut, SignIn, useClerkAuth, clerkAppearanceFromTokens } from "@odla-ai/auth-clerk";
4
+ import { CrmClient } from "@odla-ai/crm";
5
+
6
+ // src/ui/chrome.tsx
7
+ import { useCallback, useEffect as useEffect2, useState as useState2 } from "react";
8
+ import { TopBar, TopBarLink } from "@odla-ai/ui/components";
9
+
10
+ // src/ui/admin-layout.tsx
11
+ import { useEffect, useState } from "react";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ var PAGE = { maxWidth: 1120, margin: "0 auto", padding: "28px 20px 64px", display: "flex", flexDirection: "column", gap: 20 };
14
+ var HEAD = { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 16, flexWrap: "wrap" };
15
+ function AdminPage({ children }) {
16
+ return /* @__PURE__ */ jsx("div", { style: PAGE, children });
17
+ }
18
+ function Panel({ title, actions, children }) {
19
+ return /* @__PURE__ */ jsxs("section", { className: "panel", style: { display: "flex", flexDirection: "column", gap: 14 }, children: [
20
+ title || actions ? /* @__PURE__ */ jsxs("header", { style: HEAD, children: [
21
+ title ? /* @__PURE__ */ jsx("h2", { style: { margin: 0, fontSize: 18 }, children: title }) : /* @__PURE__ */ jsx("span", {}),
22
+ actions ? /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: actions }) : null
23
+ ] }) : null,
24
+ children
25
+ ] });
26
+ }
27
+ function themeLooksMissing() {
28
+ if (typeof document === "undefined") return false;
29
+ const probe = document.createElement("div");
30
+ probe.className = "panel";
31
+ probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none";
32
+ document.body.appendChild(probe);
33
+ const bg = getComputedStyle(probe).backgroundColor;
34
+ probe.remove();
35
+ return !bg || bg === "transparent" || bg === "rgba(0, 0, 0, 0)";
36
+ }
37
+ function ThemeWarning() {
38
+ const [missing, setMissing] = useState(false);
39
+ useEffect(() => setMissing(themeLooksMissing()), []);
40
+ if (!missing) return null;
41
+ return /* @__PURE__ */ jsxs(
42
+ "div",
43
+ {
44
+ role: "alert",
45
+ style: { background: "#7f1d1d", color: "#fff", padding: "12px 20px", fontSize: 14, lineHeight: 1.5, textAlign: "center" },
46
+ children: [
47
+ /* @__PURE__ */ jsx("strong", { children: "odla-ui theme tokens are missing." }),
48
+ " The console reads colors from a theme layer, not",
49
+ " ",
50
+ /* @__PURE__ */ jsx("code", { children: "@odla-ai/ui/index.css" }),
51
+ " alone. Import a theme before it \u2014 e.g.",
52
+ " ",
53
+ /* @__PURE__ */ jsx("code", { children: "@odla-ai/ui/themes/salt/tokens.css" }),
54
+ " and ",
55
+ /* @__PURE__ */ jsx("code", { children: "@odla-ai/ui/themes/salt/ui.css" }),
56
+ " \u2014 then",
57
+ " ",
58
+ /* @__PURE__ */ jsx("code", { children: "@odla-ai/ui/index.css" }),
59
+ ". See the @odla-ai/chapter README (\u201CTheme tokens\u201D)."
60
+ ]
61
+ }
62
+ );
63
+ }
64
+ function AdminNote({ children }) {
65
+ return /* @__PURE__ */ jsx(AdminPage, { children: /* @__PURE__ */ jsx("section", { className: "panel", children: /* @__PURE__ */ jsx("p", { className: "muted", style: { margin: 0 }, children }) }) });
66
+ }
67
+
68
+ // src/ui/chrome.tsx
69
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
70
+ var badgeText = (brand) => brand.badge ?? brand.name.slice(0, 3).toUpperCase();
71
+ var brandDisplay = (brand) => brand.wordmark ?? brand.name;
72
+ var S = {
73
+ gate: {
74
+ minHeight: "100vh",
75
+ display: "grid",
76
+ placeItems: "center",
77
+ padding: 24,
78
+ background: "radial-gradient(900px 480px at 50% -8%, var(--ui-accent-soft), transparent 70%), radial-gradient(700px 500px at 110% 10%, var(--ui-good-soft), transparent 60%)"
79
+ },
80
+ gateInner: { width: "100%", maxWidth: 400, display: "flex", flexDirection: "column", alignItems: "center" },
81
+ brandBox: { textAlign: "center", marginBottom: 22 },
82
+ badge: {
83
+ width: 52,
84
+ height: 52,
85
+ margin: "0 auto 14px",
86
+ display: "grid",
87
+ placeItems: "center",
88
+ fontSize: 15,
89
+ fontWeight: 700,
90
+ color: "var(--ui-on-accent)",
91
+ background: "linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))",
92
+ borderRadius: 14,
93
+ boxShadow: "0 10px 28px var(--ui-accent-soft)"
94
+ },
95
+ badgeSm: {
96
+ width: 26,
97
+ height: 26,
98
+ display: "grid",
99
+ placeItems: "center",
100
+ fontSize: 10,
101
+ fontWeight: 700,
102
+ color: "var(--ui-on-accent)",
103
+ background: "linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))",
104
+ borderRadius: 7,
105
+ marginRight: 9
106
+ },
107
+ h1: { margin: 0, fontSize: 28, letterSpacing: "-0.02em", fontWeight: 700 },
108
+ muted: { color: "var(--ui-text-muted)" },
109
+ role: {
110
+ fontFamily: "var(--ui-font-mono)",
111
+ fontSize: 11,
112
+ textTransform: "uppercase",
113
+ letterSpacing: "0.04em",
114
+ padding: "2px 8px",
115
+ borderRadius: 999,
116
+ background: "var(--ui-accent-soft)",
117
+ border: "1px solid var(--ui-accent)",
118
+ color: "var(--ui-accent-strong)"
119
+ },
120
+ whoami: { display: "flex", alignItems: "center", gap: 8, fontSize: 12 },
121
+ // Sections own their own width/padding (e.g. a .wrap container).
122
+ main: { minHeight: "calc(100vh - 61px)" },
123
+ card: { width: "100%", textAlign: "center" }
124
+ };
125
+ function Gate(props) {
126
+ const { brand, tagline, children } = props;
127
+ return /* @__PURE__ */ jsx2("div", { style: S.gate, children: /* @__PURE__ */ jsxs2("div", { style: S.gateInner, children: [
128
+ /* @__PURE__ */ jsxs2("div", { style: S.brandBox, children: [
129
+ /* @__PURE__ */ jsx2("div", { style: S.badge, children: badgeText(brand) }),
130
+ /* @__PURE__ */ jsx2("h1", { style: S.h1, children: brandDisplay(brand) }),
131
+ tagline ? /* @__PURE__ */ jsx2("p", { style: { ...S.muted, margin: "8px 0 0", fontSize: 14 }, children: tagline }) : null
132
+ ] }),
133
+ children
134
+ ] }) });
135
+ }
136
+ function AdminShell(props) {
137
+ const { sections, basePath, brand, client, getToken, signOut, email } = props;
138
+ const sectionFromPath = useCallback(() => {
139
+ const fallback = sections[0]?.id ?? "";
140
+ if (typeof window === "undefined") return fallback;
141
+ const rest = window.location.pathname.slice(basePath.length).replace(/^\//, "");
142
+ const id = rest.split("/")[0] ?? "";
143
+ return sections.some((s) => s.id === id) ? id : fallback;
144
+ }, [sections, basePath]);
145
+ const [section, setSection] = useState2(sectionFromPath);
146
+ const go = useCallback(
147
+ (id) => {
148
+ window.history.pushState(null, "", `${basePath}/${id}`);
149
+ setSection(id);
150
+ },
151
+ [basePath]
152
+ );
153
+ useEffect2(() => {
154
+ const onPop = () => setSection(sectionFromPath());
155
+ window.addEventListener("popstate", onPop);
156
+ return () => window.removeEventListener("popstate", onPop);
157
+ }, [sectionFromPath]);
158
+ const active = sections.find((s) => s.id === section) ?? sections[0];
159
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
160
+ /* @__PURE__ */ jsxs2(TopBar, { children: [
161
+ /* @__PURE__ */ jsxs2("a", { className: "topbar-brand", href: "/", style: { display: "inline-flex", alignItems: "center" }, children: [
162
+ /* @__PURE__ */ jsx2("span", { style: S.badgeSm, children: badgeText(brand) }),
163
+ brandDisplay(brand)
164
+ ] }),
165
+ /* @__PURE__ */ jsx2("nav", { className: "topbar-nav", "aria-label": "Admin navigation", children: sections.map((s) => /* @__PURE__ */ jsx2(TopBarLink, { active: section === s.id, onClick: () => go(s.id), children: s.label }, s.id)) }),
166
+ /* @__PURE__ */ jsx2("div", { className: "topbar-spacer" }),
167
+ /* @__PURE__ */ jsxs2("div", { className: "topbar-actions", children: [
168
+ /* @__PURE__ */ jsxs2("span", { style: S.whoami, children: [
169
+ /* @__PURE__ */ jsx2("span", { style: S.role, children: "admin" }),
170
+ email ? /* @__PURE__ */ jsx2("span", { style: S.muted, children: email }) : null
171
+ ] }),
172
+ /* @__PURE__ */ jsx2("button", { className: "btn secondary mini", onClick: () => signOut(), children: "Sign out" })
173
+ ] })
174
+ ] }),
175
+ /* @__PURE__ */ jsxs2("main", { className: "shell-main", style: S.main, children: [
176
+ /* @__PURE__ */ jsx2(ThemeWarning, {}),
177
+ active ? active.render({ client, getToken, navigate: go }) : null
178
+ ] })
179
+ ] });
180
+ }
181
+
182
+ // src/ui/admin.tsx
183
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
184
+ function Authed(props) {
185
+ const { sections, basePath, brand, crmBasePath, apiBase } = props;
186
+ const { getToken, signOut } = useClerkAuth();
187
+ const client = useMemo(
188
+ () => new CrmClient({
189
+ basePath: crmBasePath,
190
+ headers: async () => {
191
+ const t = await getToken();
192
+ return t ? { authorization: `Bearer ${t}` } : {};
193
+ }
194
+ }),
195
+ [getToken, crmBasePath]
196
+ );
197
+ const [state, setState] = useState3({
198
+ status: "checking"
199
+ });
200
+ useEffect3(() => {
201
+ let live = true;
202
+ void (async () => {
203
+ try {
204
+ const t = await getToken();
205
+ const res = await fetch(`${apiBase}/api/me`, { headers: t ? { authorization: `Bearer ${t}` } : {} });
206
+ const body = await res.json().catch(() => ({}));
207
+ if (live) setState({ status: body.authorized ? "ok" : "denied", email: body.email ?? null });
208
+ } catch {
209
+ if (live) setState({ status: "denied" });
210
+ }
211
+ })();
212
+ return () => {
213
+ live = false;
214
+ };
215
+ }, [getToken, apiBase]);
216
+ if (state.status === "checking") {
217
+ return /* @__PURE__ */ jsx3(Gate, { brand, children: /* @__PURE__ */ jsx3("p", { style: S.muted, children: "Checking access\u2026" }) });
218
+ }
219
+ if (state.status === "denied") {
220
+ return /* @__PURE__ */ jsx3(Gate, { brand, children: /* @__PURE__ */ jsxs3("div", { className: "card", style: S.card, children: [
221
+ /* @__PURE__ */ jsx3("h2", { style: { marginTop: 0 }, children: "Not authorized" }),
222
+ /* @__PURE__ */ jsxs3("p", { style: S.muted, children: [
223
+ state.email ? `${state.email} isn't` : "This account isn't",
224
+ " on the admin list. Ask an existing admin to add you in odla Studio."
225
+ ] }),
226
+ /* @__PURE__ */ jsx3("button", { className: "btn secondary", onClick: () => signOut(), style: { marginTop: 12 }, children: "Sign out" })
227
+ ] }) });
228
+ }
229
+ return /* @__PURE__ */ jsx3(
230
+ AdminShell,
231
+ {
232
+ sections,
233
+ basePath,
234
+ brand,
235
+ client,
236
+ getToken,
237
+ signOut,
238
+ email: state.email ?? null
239
+ }
240
+ );
241
+ }
242
+ function ChapterAdmin(props) {
243
+ const sections = props.sections;
244
+ const basePath = props.basePath ?? "/admin";
245
+ const brand = props.brand ?? { name: "Admin" };
246
+ const crmBasePath = props.crmBasePath ?? "/api/crm";
247
+ const apiBase = props.apiBase ?? "";
248
+ const [pk, setPk] = useState3(void 0);
249
+ useEffect3(() => {
250
+ let live = true;
251
+ void (async () => {
252
+ try {
253
+ const res = await fetch(`${apiBase}/api/config`);
254
+ const body = await res.json();
255
+ if (live) setPk(body.clerkPublishableKey ?? null);
256
+ } catch {
257
+ if (live) setPk(null);
258
+ }
259
+ })();
260
+ return () => {
261
+ live = false;
262
+ };
263
+ }, [apiBase]);
264
+ if (pk === void 0) {
265
+ return /* @__PURE__ */ jsx3(Gate, { brand, children: /* @__PURE__ */ jsx3("p", { style: S.muted, children: "Loading\u2026" }) });
266
+ }
267
+ if (!pk) {
268
+ return /* @__PURE__ */ jsx3(Gate, { brand, children: /* @__PURE__ */ jsxs3("div", { className: "card", style: S.card, children: [
269
+ /* @__PURE__ */ jsx3("h2", { style: { marginTop: 0 }, children: "Sign-in not configured" }),
270
+ /* @__PURE__ */ jsx3("p", { style: S.muted, children: "No Clerk publishable key is set for this environment yet." })
271
+ ] }) });
272
+ }
273
+ return /* @__PURE__ */ jsxs3(ClerkGate, { publishableKey: pk, appearance: clerkAppearanceFromTokens(), afterSignOutUrl: "/", children: [
274
+ /* @__PURE__ */ jsx3(SignedOut, { children: /* @__PURE__ */ jsx3(Gate, { brand, tagline: "Admin sign-in \u2014 invite only", children: /* @__PURE__ */ jsx3(SignIn, { routing: "hash", forceRedirectUrl: basePath, signUpForceRedirectUrl: basePath }) }) }),
275
+ /* @__PURE__ */ jsx3(SignedIn, { children: /* @__PURE__ */ jsx3(Authed, { sections, basePath, brand, crmBasePath, apiBase }) })
276
+ ] });
277
+ }
278
+
279
+ // src/ui/admin-people.tsx
280
+ import { useState as useState6 } from "react";
281
+ import { CrmList, RecordPanel, useCrmQuery, useCrmRecord } from "@odla-ai/crm/ui";
282
+
283
+ // src/ui/admin-record-actions.tsx
284
+ import { useEffect as useEffect5, useState as useState5 } from "react";
285
+ import { Button, Field, Select } from "@odla-ai/ui/components";
286
+
287
+ // src/ui/admin-api.ts
288
+ import { useCallback as useCallback2, useEffect as useEffect4, useState as useState4 } from "react";
289
+ async function adminFetch(getToken, path, init) {
290
+ const token = await getToken();
291
+ const headers = new Headers(init?.headers);
292
+ if (token) headers.set("authorization", `Bearer ${token}`);
293
+ if (init?.body) headers.set("content-type", "application/json");
294
+ const res = await fetch(path, { ...init, headers });
295
+ const body = await res.json().catch(() => ({}));
296
+ if (!res.ok) throw new Error(typeof body.error === "string" ? body.error : `request failed (${res.status})`);
297
+ return body;
298
+ }
299
+ function useAdminResource(getToken, path) {
300
+ const [data, setData] = useState4(null);
301
+ const [loading, setLoading] = useState4(true);
302
+ const [error, setError] = useState4(null);
303
+ const [nonce, setNonce] = useState4(0);
304
+ useEffect4(() => {
305
+ let live = true;
306
+ setLoading(true);
307
+ setError(null);
308
+ adminFetch(getToken, path).then((d) => live && setData(d)).catch((e) => live && setError(e instanceof Error ? e.message : String(e))).finally(() => live && setLoading(false));
309
+ return () => {
310
+ live = false;
311
+ };
312
+ }, [getToken, path, nonce]);
313
+ const refresh = useCallback2(() => setNonce((n) => n + 1), []);
314
+ return { data, loading, error, refresh };
315
+ }
316
+
317
+ // src/ui/admin-record-actions.tsx
318
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
319
+ function RecordActions({ getToken, record, roles, onChanged }) {
320
+ const userId = typeof record.clerkUserId === "string" ? record.clerkUserId : null;
321
+ const applicationId = typeof record.fields?.applicationId === "string" ? record.fields.applicationId : null;
322
+ const [access, setAccess] = useState5(null);
323
+ const [busy, setBusy] = useState5(null);
324
+ const [note, setNote] = useState5(null);
325
+ useEffect5(() => {
326
+ if (!userId) {
327
+ setAccess(null);
328
+ return;
329
+ }
330
+ let live = true;
331
+ adminFetch(getToken, `/api/admin/people/access?userId=${encodeURIComponent(userId)}`).then((a) => live && setAccess(a)).catch(() => live && setAccess(null));
332
+ return () => {
333
+ live = false;
334
+ };
335
+ }, [getToken, userId]);
336
+ const act = async (key, run, ok) => {
337
+ setBusy(key);
338
+ setNote(null);
339
+ try {
340
+ await run();
341
+ setNote(ok);
342
+ onChanged();
343
+ } catch (e) {
344
+ setNote(e instanceof Error ? e.message : String(e));
345
+ } finally {
346
+ setBusy(null);
347
+ }
348
+ };
349
+ const setRole = (role) => act("role", () => adminFetch(getToken, "/api/admin/people/role", { method: "POST", body: JSON.stringify({ userId, role }) }), `Role set to ${role}.`);
350
+ const approve = () => act("approve", () => adminFetch(getToken, `/api/admin/applications/${applicationId}/approve`, { method: "POST" }), "Approved.");
351
+ const refund = () => act("refund", () => adminFetch(getToken, `/api/admin/applications/${applicationId}/refund`, { method: "POST" }), "Refund issued.");
352
+ if (!userId && !applicationId) return null;
353
+ return /* @__PURE__ */ jsxs4("section", { className: "panel", style: { display: "flex", flexDirection: "column", gap: 14, marginTop: 16 }, children: [
354
+ /* @__PURE__ */ jsx4("h3", { style: { margin: 0, fontSize: 16 }, children: "Access & lifecycle" }),
355
+ userId ? /* @__PURE__ */ jsx4(Field, { label: "Role", hint: access?.superAdmin ? "Super-admin \u2014 managed in odla Studio." : void 0, children: /* @__PURE__ */ jsx4(
356
+ Select,
357
+ {
358
+ value: access?.role ?? "",
359
+ disabled: busy === "role" || access?.superAdmin,
360
+ options: roles.map((r) => ({ value: r, label: r })),
361
+ onChange: (e) => void setRole(e.target.value)
362
+ }
363
+ ) }) : /* @__PURE__ */ jsx4("p", { className: "muted", style: { margin: 0 }, children: "No linked account yet \u2014 role is set once they sign in." }),
364
+ applicationId ? /* @__PURE__ */ jsxs4("div", { style: { display: "flex", gap: 8 }, children: [
365
+ /* @__PURE__ */ jsx4(Button, { variant: "secondary", mini: true, disabled: busy === "approve", onClick: () => void approve(), children: "Approve" }),
366
+ /* @__PURE__ */ jsx4(Button, { variant: "danger", mini: true, disabled: busy === "refund", onClick: () => void refund(), children: "Refund" })
367
+ ] }) : null,
368
+ note ? /* @__PURE__ */ jsx4("p", { className: "muted", style: { margin: 0 }, children: note }) : null
369
+ ] });
370
+ }
371
+
372
+ // src/ui/admin-people.tsx
373
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
374
+ var DEFAULT_ROLES = ["provisional", "member", "admin"];
375
+ function PeopleBody(props) {
376
+ const { crm, client, type, getToken, roles, lifecycle } = props;
377
+ const hookClient = client;
378
+ const query = useCrmQuery(hookClient, type);
379
+ const [openId, setOpenId] = useState6(null);
380
+ const record = useCrmRecord(hookClient, openId);
381
+ const [saving, setSaving] = useState6(false);
382
+ const saveFields = async (input) => {
383
+ if (!openId) return;
384
+ setSaving(true);
385
+ try {
386
+ await client.updateRecord(openId, { input });
387
+ record.refresh();
388
+ query.refresh();
389
+ } finally {
390
+ setSaving(false);
391
+ }
392
+ };
393
+ const moveStage = async (to) => {
394
+ if (!openId) return;
395
+ await client.setStage(openId, to);
396
+ record.refresh();
397
+ query.refresh();
398
+ };
399
+ const addTag = async (tag) => {
400
+ if (openId) {
401
+ await client.addTag(openId, tag);
402
+ record.refresh();
403
+ }
404
+ };
405
+ const removeTag = async (tag) => {
406
+ if (openId) {
407
+ await client.removeTag(openId, tag);
408
+ record.refresh();
409
+ }
410
+ };
411
+ return /* @__PURE__ */ jsxs5(AdminPage, { children: [
412
+ /* @__PURE__ */ jsx5(CrmList, { crm, type, query, onOpenRecord: (r) => setOpenId(r.id) }),
413
+ record.detail ? /* @__PURE__ */ jsxs5("div", { children: [
414
+ /* @__PURE__ */ jsx5(
415
+ RecordPanel,
416
+ {
417
+ crm,
418
+ detail: record.detail,
419
+ onSaveFields: saveFields,
420
+ onMoveStage: moveStage,
421
+ onAddTag: addTag,
422
+ onRemoveTag: removeTag,
423
+ saving
424
+ }
425
+ ),
426
+ lifecycle ? /* @__PURE__ */ jsx5(
427
+ RecordActions,
428
+ {
429
+ getToken,
430
+ record: record.detail.record,
431
+ roles,
432
+ onChanged: () => {
433
+ record.refresh();
434
+ query.refresh();
435
+ }
436
+ }
437
+ ) : null
438
+ ] }) : null
439
+ ] });
440
+ }
441
+ function collectionSection(options) {
442
+ const { crm, type } = options;
443
+ let fallbackLabel = type;
444
+ try {
445
+ fallbackLabel = crm.type(type).labelPlural ?? crm.type(type).label ?? type;
446
+ } catch {
447
+ }
448
+ const { id = type, label = fallbackLabel, lifecycle = type === "person", roles = DEFAULT_ROLES } = options;
449
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx5(PeopleBody, { crm, client: ctx.client, type, getToken: ctx.getToken, roles, lifecycle }) };
450
+ }
451
+ function peopleSection(options) {
452
+ const { crm, id = "people", label = "People", type = "person", roles } = options;
453
+ return collectionSection({ crm, type, id, label, ...roles ? { roles } : {} });
454
+ }
455
+
456
+ // src/ui/admin-dashboard.tsx
457
+ import { StatBand } from "@odla-ai/ui/components";
458
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
459
+ var money = (cents) => `$${Math.round(cents / 100).toLocaleString()}`;
460
+ var when = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
461
+ function DashboardBody({ ctx }) {
462
+ const { data, loading, error } = useAdminResource(ctx.getToken, "/api/admin/dashboard");
463
+ if (loading) return /* @__PURE__ */ jsx6(AdminNote, { children: "Loading\u2026" });
464
+ if (error || !data) return /* @__PURE__ */ jsxs6(AdminNote, { children: [
465
+ "Couldn\u2019t load the dashboard",
466
+ error ? `: ${error}` : "",
467
+ "."
468
+ ] });
469
+ const kpis = [
470
+ { value: data.applications.total, label: "Applications", description: `+${data.applications.last7} in the last 7 days` },
471
+ { value: data.calls.upcoming, label: "Upcoming calls", description: data.calls.needsAttention ? `${data.calls.needsAttention} need attention` : "all on track" }
472
+ ];
473
+ if (data.revenue.billingReady) {
474
+ kpis.push({ value: data.revenue.activeCount ?? 0, label: "Active members", description: "paid subscriptions" });
475
+ kpis.push({ value: money(data.revenue.annualRunRateCents ?? 0), label: "Annual run rate", description: "from active subscriptions" });
476
+ }
477
+ const stages = Object.entries(data.pipeline).map(([stage, count]) => ({
478
+ value: count,
479
+ label: stage,
480
+ description: data.pipelineDelta[stage] ? `+${data.pipelineDelta[stage]} this week` : void 0
481
+ }));
482
+ return /* @__PURE__ */ jsxs6(AdminPage, { children: [
483
+ /* @__PURE__ */ jsx6(StatBand, { stats: kpis }),
484
+ /* @__PURE__ */ jsx6(Panel, { title: "Pipeline", children: /* @__PURE__ */ jsx6(StatBand, { stats: stages }) }),
485
+ /* @__PURE__ */ jsx6(Panel, { title: /* @__PURE__ */ jsxs6(Fragment2, { children: [
486
+ "Upcoming calls ",
487
+ /* @__PURE__ */ jsxs6("span", { className: "muted", style: { fontWeight: 400 }, children: [
488
+ "\xB7 ",
489
+ data.timezone
490
+ ] })
491
+ ] }), children: data.agenda.length === 0 ? /* @__PURE__ */ jsx6("p", { className: "muted", style: { margin: 0 }, children: "No calls scheduled." }) : /* @__PURE__ */ jsx6("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 10 }, children: data.agenda.map((m) => /* @__PURE__ */ jsxs6("li", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }, children: [
492
+ /* @__PURE__ */ jsx6("strong", { style: { minWidth: 170 }, children: when(m.startAt) }),
493
+ /* @__PURE__ */ jsxs6("span", { children: [
494
+ m.name,
495
+ m.email ? ` \xB7 ${m.email}` : ""
496
+ ] }),
497
+ m.drift && m.drift !== "none" ? /* @__PURE__ */ jsxs6("span", { className: "badge", children: [
498
+ "drift: ",
499
+ m.drift
500
+ ] }) : null,
501
+ m.meetUrl ? /* @__PURE__ */ jsx6("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: "Meet" }) : null
502
+ ] }, m.id)) }) })
503
+ ] });
504
+ }
505
+ function dashboardSection(options = {}) {
506
+ const { id = "dashboard", label = "Dashboard" } = options;
507
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx6(DashboardBody, { ctx }) };
508
+ }
509
+
510
+ // src/ui/admin-billing.tsx
511
+ import { DataTable, StatBand as StatBand2 } from "@odla-ai/ui/components";
512
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
513
+ var money2 = (cents) => `$${(cents / 100).toLocaleString()}`;
514
+ var date = (t) => t ? new Date(t).toLocaleDateString([], { dateStyle: "medium" }) : "\u2014";
515
+ var COLUMNS = [
516
+ { key: "name", header: "Name", cell: (r) => r.name, sortAs: "string" },
517
+ { key: "email", header: "Email", cell: (r) => r.email },
518
+ { key: "applicationStatus", header: "Application", cell: (r) => r.applicationStatus },
519
+ { key: "subscriptionStatus", header: "Subscription", cell: (r) => r.subscriptionStatus ? `${r.subscriptionStatus}${r.cancelAtPeriodEnd ? " \xB7 cancelling" : ""}` : "\u2014" },
520
+ { key: "amountCents", header: "Amount", cell: (r) => `${money2(r.amountCents)}/${r.interval === "month" ? "mo" : "yr"}`, sortAs: "number", sortValue: (r) => r.amountCents },
521
+ { key: "renewalAt", header: "Renews", cell: (r) => date(r.renewalAt), sortAs: "number", sortValue: (r) => r.renewalAt ?? 0 }
522
+ ];
523
+ function BillingBody({ ctx }) {
524
+ const { data, loading, error } = useAdminResource(ctx.getToken, "/api/admin/billing");
525
+ if (loading) return /* @__PURE__ */ jsx7(AdminNote, { children: "Loading\u2026" });
526
+ if (error || !data) return /* @__PURE__ */ jsxs7(AdminNote, { children: [
527
+ "Couldn\u2019t load billing",
528
+ error ? `: ${error}` : "",
529
+ "."
530
+ ] });
531
+ if (!data.billingReady) return /* @__PURE__ */ jsx7(AdminNote, { children: "Billing isn\u2019t configured \u2014 no Stripe key is vaulted for this group." });
532
+ const s = data.summary;
533
+ const stats = s ? [
534
+ { value: s.activeCount, label: "Active" },
535
+ { value: money2(s.annualizedCents), label: "Annualized" },
536
+ { value: s.renewingSoonCount, label: "Renewing \u226460d" },
537
+ { value: s.pastDueCount, label: "Past due" }
538
+ ] : [];
539
+ return /* @__PURE__ */ jsxs7(AdminPage, { children: [
540
+ s ? /* @__PURE__ */ jsx7(StatBand2, { stats }) : null,
541
+ /* @__PURE__ */ jsxs7(Panel, { title: "Subscriptions", actions: data.testMode ? /* @__PURE__ */ jsx7("span", { className: "badge", children: "Stripe test mode" }) : void 0, children: [
542
+ data.truncated ? /* @__PURE__ */ jsx7("p", { className: "badge", children: "Showing the first 100 subscriptions \u2014 the list is truncated." }) : null,
543
+ /* @__PURE__ */ jsx7(DataTable, { rows: data.rows, columns: COLUMNS, rowKey: (r) => r.id })
544
+ ] })
545
+ ] });
546
+ }
547
+ function billingSection(options = {}) {
548
+ const { id = "billing", label = "Billing" } = options;
549
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx7(BillingBody, { ctx }) };
550
+ }
551
+
552
+ // src/ui/admin-email.tsx
553
+ import { useState as useState7 } from "react";
554
+ import { Button as Button2, Checkbox, DataTable as DataTable2, Field as Field2, Input, Textarea } from "@odla-ai/ui/components";
555
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
556
+ var LOG_COLUMNS = [
557
+ { key: "sentAt", header: "Sent", cell: (r) => new Date(r.sentAt).toLocaleString(), sortAs: "number", sortValue: (r) => r.sentAt },
558
+ { key: "template", header: "Template", cell: (r) => r.template },
559
+ { key: "to", header: "To", cell: (r) => r.to },
560
+ { key: "status", header: "Status", cell: (r) => r.error ? /* @__PURE__ */ jsx8("span", { className: "badge", children: "failed" }) : r.redirected ? /* @__PURE__ */ jsx8("span", { className: "badge", children: "redirected" }) : "delivered" }
561
+ ];
562
+ function EmailBody({ ctx }) {
563
+ const cfg = useAdminResource(ctx.getToken, "/api/admin/group/email");
564
+ const log = useAdminResource(ctx.getToken, "/api/admin/email/log");
565
+ const [draft, setDraft] = useState7(null);
566
+ const [busy, setBusy] = useState7(null);
567
+ const [note, setNote] = useState7(null);
568
+ const data = draft ?? cfg.data;
569
+ if (cfg.loading) return /* @__PURE__ */ jsx8(AdminNote, { children: "Loading\u2026" });
570
+ if (cfg.error || !data) return /* @__PURE__ */ jsxs8(AdminNote, { children: [
571
+ "Couldn\u2019t load email config",
572
+ cfg.error ? `: ${cfg.error}` : "",
573
+ "."
574
+ ] });
575
+ const edit = (patch) => setDraft({ ...data, ...patch });
576
+ const editTemplate = (key, patch) => edit({ emailTemplates: { ...data.emailTemplates, [key]: { ...data.emailTemplates[key], ...patch } } });
577
+ const run = async (key, fn) => {
578
+ setBusy(key);
579
+ setNote(null);
580
+ try {
581
+ await fn();
582
+ } catch (e) {
583
+ setNote(e instanceof Error ? e.message : String(e));
584
+ } finally {
585
+ setBusy(null);
586
+ }
587
+ };
588
+ const save = () => run("save", async () => {
589
+ await adminFetch(ctx.getToken, "/api/admin/group/email", { method: "PUT", body: JSON.stringify(data) });
590
+ setDraft(null);
591
+ cfg.refresh();
592
+ setNote("Saved.");
593
+ });
594
+ const test = (template) => run(`test:${template}`, async () => {
595
+ const r = await adminFetch(ctx.getToken, "/api/admin/email/test", { method: "POST", body: JSON.stringify({ template }) });
596
+ setNote(r.ok ? `Sent ${template} to ${r.to}${r.redirected ? " (dev-redirected)" : ""}.` : "Test send did not deliver.");
597
+ log.refresh();
598
+ });
599
+ return /* @__PURE__ */ jsxs8(AdminPage, { children: [
600
+ /* @__PURE__ */ jsxs8(
601
+ Panel,
602
+ {
603
+ title: "Delivery",
604
+ actions: /* @__PURE__ */ jsxs8("span", { className: "muted", children: [
605
+ data.envName,
606
+ " \xB7 ",
607
+ data.transport,
608
+ data.fromEmail ? ` \xB7 ${data.fromEmail}` : ""
609
+ ] }),
610
+ children: [
611
+ /* @__PURE__ */ jsx8(Field2, { label: "Notification address", children: /* @__PURE__ */ jsx8(Input, { value: data.notificationEmail, onChange: (e) => edit({ notificationEmail: e.target.value }) }) }),
612
+ /* @__PURE__ */ jsx8(Field2, { label: "Reply-to", children: /* @__PURE__ */ jsx8(Input, { value: data.replyTo, onChange: (e) => edit({ replyTo: e.target.value }) }) }),
613
+ /* @__PURE__ */ jsx8(Field2, { label: "Debug inbox", hint: "Non-prod sends redirect here.", children: /* @__PURE__ */ jsx8(Input, { value: data.debugEmail, onChange: (e) => edit({ debugEmail: e.target.value }) }) })
614
+ ]
615
+ }
616
+ ),
617
+ Object.entries(data.emailTemplates).map(([key, t]) => /* @__PURE__ */ jsxs8(Panel, { title: key, actions: /* @__PURE__ */ jsx8(Button2, { variant: "secondary", mini: true, disabled: busy === `test:${key}`, onClick: () => void test(key), children: "Send test" }), children: [
618
+ /* @__PURE__ */ jsx8(Checkbox, { label: "Enabled", checked: t.enabled, onChange: (e) => editTemplate(key, { enabled: e.target.checked }) }),
619
+ /* @__PURE__ */ jsx8(Field2, { label: "Subject", children: /* @__PURE__ */ jsx8(Input, { value: t.subject, onChange: (e) => editTemplate(key, { subject: e.target.value }) }) }),
620
+ /* @__PURE__ */ jsx8(Field2, { label: "Body", children: /* @__PURE__ */ jsx8(Textarea, { prose: true, rows: 5, value: t.text, onChange: (e) => editTemplate(key, { text: e.target.value }) }) })
621
+ ] }, key)),
622
+ /* @__PURE__ */ jsxs8("div", { style: { display: "flex", gap: 12, alignItems: "center" }, children: [
623
+ /* @__PURE__ */ jsx8(Button2, { disabled: !draft || busy === "save", onClick: () => void save(), children: "Save changes" }),
624
+ note ? /* @__PURE__ */ jsx8("span", { className: "muted", children: note }) : null
625
+ ] }),
626
+ /* @__PURE__ */ jsx8(Panel, { title: "Send log", children: /* @__PURE__ */ jsx8(DataTable2, { rows: log.data?.sends ?? [], columns: LOG_COLUMNS, rowKey: (r) => r.id }) })
627
+ ] });
628
+ }
629
+ function emailSection(options = {}) {
630
+ const { id = "email", label = "Email" } = options;
631
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx8(EmailBody, { ctx }) };
632
+ }
633
+
634
+ // src/ui/admin-meetings.tsx
635
+ import { useState as useState8 } from "react";
636
+ import { Button as Button3 } from "@odla-ai/ui/components";
637
+ import { Fragment as Fragment3, jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
638
+ var when2 = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
639
+ function MeetingsBody({ ctx }) {
640
+ const { data, loading, error, refresh } = useAdminResource(ctx.getToken, "/api/admin/meetings?all=1");
641
+ const [busy, setBusy] = useState8(null);
642
+ const [note, setNote] = useState8(null);
643
+ if (loading) return /* @__PURE__ */ jsx9(AdminNote, { children: "Loading\u2026" });
644
+ if (error || !data) return /* @__PURE__ */ jsxs9(AdminNote, { children: [
645
+ "Couldn\u2019t load meetings",
646
+ error ? `: ${error}` : "",
647
+ "."
648
+ ] });
649
+ const run = async (id, fn) => {
650
+ setBusy(id);
651
+ setNote(null);
652
+ try {
653
+ await fn();
654
+ refresh();
655
+ } catch (e) {
656
+ setNote(e instanceof Error ? e.message : String(e));
657
+ } finally {
658
+ setBusy(null);
659
+ }
660
+ };
661
+ const cancel = (id) => {
662
+ if (!globalThis.confirm?.("Cancel this call? Google notifies the attendee.")) return;
663
+ void run(id, () => adminFetch(ctx.getToken, `/api/admin/meetings/${id}/cancel`, { method: "POST" }));
664
+ };
665
+ const reschedule = (id) => {
666
+ const input = globalThis.prompt?.("New start time (e.g. 2026-08-01 14:00):");
667
+ if (!input) return;
668
+ const startAt = Date.parse(input);
669
+ if (!Number.isFinite(startAt)) {
670
+ setNote("Couldn\u2019t parse that time.");
671
+ return;
672
+ }
673
+ void run(id, () => adminFetch(ctx.getToken, `/api/admin/meetings/${id}/reschedule`, { method: "POST", body: JSON.stringify({ startAt }) }));
674
+ };
675
+ return /* @__PURE__ */ jsx9(AdminPage, { children: /* @__PURE__ */ jsx9(Panel, { title: /* @__PURE__ */ jsxs9(Fragment3, { children: [
676
+ "Agenda ",
677
+ /* @__PURE__ */ jsxs9("span", { className: "muted", style: { fontWeight: 400 }, children: [
678
+ "\xB7 ",
679
+ data.timezone
680
+ ] })
681
+ ] }), actions: note ? /* @__PURE__ */ jsx9("span", { className: "muted", children: note }) : void 0, children: data.meetings.length === 0 ? /* @__PURE__ */ jsx9("p", { className: "muted", style: { margin: 0 }, children: "No meetings." }) : /* @__PURE__ */ jsx9("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 12 }, children: data.meetings.map((m) => /* @__PURE__ */ jsxs9("li", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", opacity: m.status === "cancelled" ? 0.55 : 1 }, children: [
682
+ /* @__PURE__ */ jsx9("strong", { style: { minWidth: 170 }, children: when2(m.startAt) }),
683
+ /* @__PURE__ */ jsx9("span", { style: { flex: 1 }, children: m.applicant ? `${m.applicant.name} \xB7 ${m.applicant.email}` : "(unknown)" }),
684
+ /* @__PURE__ */ jsx9("span", { className: "badge", children: m.status }),
685
+ m.drift && m.drift !== "none" ? /* @__PURE__ */ jsxs9("span", { className: "badge", children: [
686
+ "drift: ",
687
+ m.drift
688
+ ] }) : null,
689
+ m.meetUrl ? /* @__PURE__ */ jsx9("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: "Meet" }) : null,
690
+ m.status === "scheduled" ? /* @__PURE__ */ jsxs9(Fragment3, { children: [
691
+ /* @__PURE__ */ jsx9(Button3, { variant: "secondary", mini: true, disabled: busy === m.id, onClick: () => reschedule(m.id), children: "Reschedule" }),
692
+ /* @__PURE__ */ jsx9(Button3, { variant: "danger", mini: true, disabled: busy === m.id, onClick: () => cancel(m.id), children: "Cancel" })
693
+ ] }) : null
694
+ ] }, m.id)) }) }) });
695
+ }
696
+ function meetingsSection(options = {}) {
697
+ const { id = "meetings", label = "Meetings" } = options;
698
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx9(MeetingsBody, { ctx }) };
699
+ }
700
+
701
+ // src/ui/admin-availability.tsx
702
+ import { useState as useState9 } from "react";
703
+ import { Button as Button4, Checkbox as Checkbox2, Field as Field3, Input as Input2 } from "@odla-ai/ui/components";
704
+ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
705
+ var DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
706
+ function AvailabilityBody({ ctx }) {
707
+ const { data, loading, error, refresh } = useAdminResource(ctx.getToken, "/api/admin/scheduling");
708
+ const [draft, setDraft] = useState9(null);
709
+ const [busy, setBusy] = useState9(false);
710
+ const [note, setNote] = useState9(null);
711
+ const [errors, setErrors] = useState9({});
712
+ const cfg = draft ?? data?.scheduling ?? null;
713
+ if (loading) return /* @__PURE__ */ jsx10(AdminNote, { children: "Loading\u2026" });
714
+ if (error || !cfg) return /* @__PURE__ */ jsxs10(AdminNote, { children: [
715
+ "Couldn\u2019t load availability",
716
+ error ? `: ${error}` : "",
717
+ "."
718
+ ] });
719
+ const edit = (patch) => setDraft({ ...cfg, ...patch });
720
+ const toggleDay = (d) => edit({ days: cfg.days.includes(d) ? cfg.days.filter((x) => x !== d) : [...cfg.days, d].sort() });
721
+ const num = (v, fallback) => Number.isFinite(Number(v)) ? Number(v) : fallback;
722
+ const save = async () => {
723
+ setBusy(true);
724
+ setNote(null);
725
+ setErrors({});
726
+ const token = await ctx.getToken();
727
+ try {
728
+ const r = await fetch("/api/admin/scheduling", { method: "PUT", headers: { authorization: `Bearer ${token ?? ""}`, "content-type": "application/json" }, body: JSON.stringify(cfg) });
729
+ const body = await r.json().catch(() => ({}));
730
+ if (!r.ok) {
731
+ setErrors(body.errors ?? {});
732
+ setNote(body.error ?? `save failed (${r.status})`);
733
+ return;
734
+ }
735
+ setDraft(null);
736
+ refresh();
737
+ setNote("Saved.");
738
+ } catch (e) {
739
+ setNote(e instanceof Error ? e.message : String(e));
740
+ } finally {
741
+ setBusy(false);
742
+ }
743
+ };
744
+ const numField = (key, label) => /* @__PURE__ */ jsx10(Field3, { label, error: errors[key], children: /* @__PURE__ */ jsx10(Input2, { type: "number", value: cfg[key], onChange: (e) => edit({ [key]: num(e.target.value, cfg[key]) }) }) });
745
+ return /* @__PURE__ */ jsx10(AdminPage, { children: /* @__PURE__ */ jsxs10(Panel, { title: "Booking availability", actions: note ? /* @__PURE__ */ jsx10("span", { className: "muted", children: note }) : void 0, children: [
746
+ /* @__PURE__ */ jsx10(Field3, { label: "Days", error: errors.days, children: /* @__PURE__ */ jsx10("div", { style: { display: "flex", gap: 14, flexWrap: "wrap" }, children: DAYS.map((d, i) => /* @__PURE__ */ jsx10(Checkbox2, { label: d, checked: cfg.days.includes(i), onChange: () => toggleDay(i) }, d)) }) }),
747
+ /* @__PURE__ */ jsxs10("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 14 }, children: [
748
+ numField("startHour", "Start hour (0\u201324)"),
749
+ numField("endHour", "End hour (0\u201324)"),
750
+ numField("slotMinutes", "Slot length (minutes)"),
751
+ numField("minNoticeHours", "Minimum notice (hours)"),
752
+ numField("windowDays", "Booking window (days)"),
753
+ /* @__PURE__ */ jsx10(Field3, { label: "Timezone", error: errors.timezone, children: /* @__PURE__ */ jsx10(Input2, { value: cfg.timezone, onChange: (e) => edit({ timezone: e.target.value }) }) })
754
+ ] }),
755
+ /* @__PURE__ */ jsx10(Field3, { label: "Event summary template", error: errors.summaryTemplate, children: /* @__PURE__ */ jsx10(Input2, { value: cfg.summaryTemplate, onChange: (e) => edit({ summaryTemplate: e.target.value }) }) }),
756
+ /* @__PURE__ */ jsx10("div", { children: /* @__PURE__ */ jsx10(Button4, { disabled: !draft || busy, onClick: () => void save(), children: "Save availability" }) })
757
+ ] }) });
758
+ }
759
+ function availabilitySection(options = {}) {
760
+ const { id = "availability", label = "Availability" } = options;
761
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx10(AvailabilityBody, { ctx }) };
762
+ }
763
+
764
+ export {
765
+ AdminPage,
766
+ Panel,
767
+ ThemeWarning,
768
+ AdminNote,
769
+ ChapterAdmin,
770
+ adminFetch,
771
+ useAdminResource,
772
+ RecordActions,
773
+ collectionSection,
774
+ peopleSection,
775
+ dashboardSection,
776
+ billingSection,
777
+ emailSection,
778
+ meetingsSection,
779
+ availabilitySection
780
+ };
781
+ //# sourceMappingURL=chunk-D3QMK3ED.js.map