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