@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.
@@ -1,642 +0,0 @@
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 useState3 } from "react";
218
- import { CrmList, RecordPanel, useCrmQuery, useCrmRecord } from "@odla-ai/crm/ui";
219
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
220
- function PeopleBody(props) {
221
- const { crm, client, type } = props;
222
- const hookClient = client;
223
- const query = useCrmQuery(hookClient, type);
224
- const [openId, setOpenId] = useState3(null);
225
- const record = useCrmRecord(hookClient, openId);
226
- const [saving, setSaving] = useState3(false);
227
- const saveFields = async (input) => {
228
- if (!openId) return;
229
- setSaving(true);
230
- try {
231
- await client.updateRecord(openId, { input });
232
- record.refresh();
233
- query.refresh();
234
- } finally {
235
- setSaving(false);
236
- }
237
- };
238
- const moveStage = async (to) => {
239
- if (!openId) return;
240
- await client.setStage(openId, to);
241
- record.refresh();
242
- query.refresh();
243
- };
244
- const addTag = async (tag) => {
245
- if (openId) {
246
- await client.addTag(openId, tag);
247
- record.refresh();
248
- }
249
- };
250
- const removeTag = async (tag) => {
251
- if (openId) {
252
- await client.removeTag(openId, tag);
253
- record.refresh();
254
- }
255
- };
256
- return /* @__PURE__ */ jsxs3("div", { className: "wrap", children: [
257
- /* @__PURE__ */ jsx3(CrmList, { crm, type, query, onOpenRecord: (r) => setOpenId(r.id) }),
258
- record.detail ? /* @__PURE__ */ jsx3(
259
- RecordPanel,
260
- {
261
- crm,
262
- detail: record.detail,
263
- onSaveFields: saveFields,
264
- onMoveStage: moveStage,
265
- onAddTag: addTag,
266
- onRemoveTag: removeTag,
267
- saving
268
- }
269
- ) : null
270
- ] });
271
- }
272
- function collectionSection(options) {
273
- const { crm, type } = options;
274
- let fallbackLabel = type;
275
- try {
276
- fallbackLabel = crm.type(type).labelPlural ?? crm.type(type).label ?? type;
277
- } catch {
278
- }
279
- const { id = type, label = fallbackLabel } = options;
280
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx3(PeopleBody, { crm, client: ctx.client, type }) };
281
- }
282
- function peopleSection(options) {
283
- const { crm, id = "people", label = "People", type = "person" } = options;
284
- return collectionSection({ crm, type, id, label });
285
- }
286
-
287
- // src/ui/admin-api.ts
288
- import { useCallback as useCallback2, useEffect as useEffect3, 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
- useEffect3(() => {
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-dashboard.tsx
318
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
319
- var money = (cents) => `$${Math.round(cents / 100).toLocaleString()}`;
320
- function DashboardBody({ ctx }) {
321
- const { data, loading, error } = useAdminResource(ctx.getToken, "/api/admin/dashboard");
322
- if (loading) return /* @__PURE__ */ jsx4("div", { className: "wrap page", children: "Loading\u2026" });
323
- if (error || !data) return /* @__PURE__ */ jsxs4("div", { className: "wrap page", children: [
324
- "Couldn\u2019t load the dashboard: ",
325
- error
326
- ] });
327
- const when = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
328
- const Kpi = ({ label, value, sub }) => /* @__PURE__ */ jsxs4("div", { className: "card kpi", children: [
329
- /* @__PURE__ */ jsx4("div", { className: "kpi-value", children: value }),
330
- /* @__PURE__ */ jsx4("div", { className: "kpi-label", children: label }),
331
- sub ? /* @__PURE__ */ jsx4("div", { className: "kpi-sub muted", children: sub }) : null
332
- ] });
333
- return /* @__PURE__ */ jsxs4("div", { className: "wrap page", children: [
334
- /* @__PURE__ */ jsxs4("div", { className: "kpis", children: [
335
- /* @__PURE__ */ jsx4(Kpi, { label: "Applications", value: data.applications.total, sub: `+${data.applications.last7} in 7d` }),
336
- /* @__PURE__ */ jsx4(Kpi, { label: "Upcoming calls", value: data.calls.upcoming, sub: data.calls.needsAttention ? `${data.calls.needsAttention} need attention` : void 0 }),
337
- data.revenue.billingReady ? /* @__PURE__ */ jsx4(Kpi, { label: "Active members", value: data.revenue.activeCount ?? 0 }) : null,
338
- data.revenue.billingReady ? /* @__PURE__ */ jsx4(Kpi, { label: "Annual run rate", value: money(data.revenue.annualRunRateCents ?? 0) }) : null
339
- ] }),
340
- /* @__PURE__ */ jsxs4("div", { className: "card", children: [
341
- /* @__PURE__ */ jsx4("h3", { children: "Pipeline" }),
342
- /* @__PURE__ */ jsx4("ul", { className: "pipeline-counts", children: Object.entries(data.pipeline).map(([stage, count]) => /* @__PURE__ */ jsxs4("li", { children: [
343
- /* @__PURE__ */ jsx4("span", { className: "stage", children: stage }),
344
- /* @__PURE__ */ jsx4("span", { className: "count", children: count }),
345
- data.pipelineDelta[stage] ? /* @__PURE__ */ jsxs4("span", { className: "delta", children: [
346
- "+",
347
- data.pipelineDelta[stage]
348
- ] }) : null
349
- ] }, stage)) })
350
- ] }),
351
- /* @__PURE__ */ jsxs4("div", { className: "card", children: [
352
- /* @__PURE__ */ jsxs4("h3", { children: [
353
- "Upcoming calls ",
354
- /* @__PURE__ */ jsxs4("span", { className: "muted", children: [
355
- "(",
356
- data.timezone,
357
- ")"
358
- ] })
359
- ] }),
360
- data.agenda.length === 0 ? /* @__PURE__ */ jsx4("p", { className: "muted", children: "No calls scheduled." }) : /* @__PURE__ */ jsx4("ul", { className: "agenda", children: data.agenda.map((m) => /* @__PURE__ */ jsxs4("li", { children: [
361
- /* @__PURE__ */ jsx4("span", { className: "when", children: when(m.startAt) }),
362
- /* @__PURE__ */ jsxs4("span", { className: "who", children: [
363
- m.name,
364
- m.email ? ` \xB7 ${m.email}` : ""
365
- ] }),
366
- m.drift && m.drift !== "none" ? /* @__PURE__ */ jsxs4("span", { className: "badge danger", children: [
367
- "drift: ",
368
- m.drift
369
- ] }) : null,
370
- m.meetUrl ? /* @__PURE__ */ jsx4("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: "Meet" }) : null
371
- ] }, m.id)) })
372
- ] })
373
- ] });
374
- }
375
- function dashboardSection(options = {}) {
376
- const { id = "dashboard", label = "Dashboard" } = options;
377
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx4(DashboardBody, { ctx }) };
378
- }
379
-
380
- // src/ui/admin-billing.tsx
381
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
382
- var money2 = (cents) => `$${(cents / 100).toLocaleString(void 0, { minimumFractionDigits: 0 })}`;
383
- var date = (t) => t ? new Date(t).toLocaleDateString([], { dateStyle: "medium" }) : "\u2014";
384
- function BillingBody({ ctx }) {
385
- const { data, loading, error } = useAdminResource(ctx.getToken, "/api/admin/billing");
386
- if (loading) return /* @__PURE__ */ jsx5("div", { className: "wrap page", children: "Loading\u2026" });
387
- if (error || !data) return /* @__PURE__ */ jsxs5("div", { className: "wrap page", children: [
388
- "Couldn\u2019t load billing: ",
389
- error
390
- ] });
391
- if (!data.billingReady) return /* @__PURE__ */ jsx5("div", { className: "wrap page", children: /* @__PURE__ */ jsx5("div", { className: "card", children: /* @__PURE__ */ jsx5("p", { className: "muted", children: "Billing isn\u2019t configured \u2014 no Stripe key is vaulted for this group." }) }) });
392
- const s = data.summary;
393
- return /* @__PURE__ */ jsxs5("div", { className: "wrap page", children: [
394
- data.testMode ? /* @__PURE__ */ jsx5("div", { className: "badge", children: "Stripe test mode" }) : null,
395
- s ? /* @__PURE__ */ jsxs5("div", { className: "kpis", children: [
396
- /* @__PURE__ */ jsxs5("div", { className: "card kpi", children: [
397
- /* @__PURE__ */ jsx5("div", { className: "kpi-value", children: s.activeCount }),
398
- /* @__PURE__ */ jsx5("div", { className: "kpi-label", children: "Active" })
399
- ] }),
400
- /* @__PURE__ */ jsxs5("div", { className: "card kpi", children: [
401
- /* @__PURE__ */ jsx5("div", { className: "kpi-value", children: money2(s.annualizedCents) }),
402
- /* @__PURE__ */ jsx5("div", { className: "kpi-label", children: "Annualized" })
403
- ] }),
404
- /* @__PURE__ */ jsxs5("div", { className: "card kpi", children: [
405
- /* @__PURE__ */ jsx5("div", { className: "kpi-value", children: s.renewingSoonCount }),
406
- /* @__PURE__ */ jsx5("div", { className: "kpi-label", children: "Renewing \u226460d" })
407
- ] }),
408
- /* @__PURE__ */ jsxs5("div", { className: "card kpi", children: [
409
- /* @__PURE__ */ jsx5("div", { className: "kpi-value", children: s.pastDueCount }),
410
- /* @__PURE__ */ jsx5("div", { className: "kpi-label", children: "Past due" })
411
- ] })
412
- ] }) : null,
413
- data.truncated ? /* @__PURE__ */ jsx5("p", { className: "badge danger", children: "Showing the first 100 subscriptions \u2014 the list is truncated." }) : null,
414
- /* @__PURE__ */ jsx5("div", { className: "card", children: /* @__PURE__ */ jsxs5("table", { className: "data-table", children: [
415
- /* @__PURE__ */ jsx5("thead", { children: /* @__PURE__ */ jsxs5("tr", { children: [
416
- /* @__PURE__ */ jsx5("th", { children: "Name" }),
417
- /* @__PURE__ */ jsx5("th", { children: "Email" }),
418
- /* @__PURE__ */ jsx5("th", { children: "Application" }),
419
- /* @__PURE__ */ jsx5("th", { children: "Subscription" }),
420
- /* @__PURE__ */ jsx5("th", { children: "Amount" }),
421
- /* @__PURE__ */ jsx5("th", { children: "Renews" })
422
- ] }) }),
423
- /* @__PURE__ */ jsxs5("tbody", { children: [
424
- data.rows.map((r) => /* @__PURE__ */ jsxs5("tr", { children: [
425
- /* @__PURE__ */ jsx5("td", { children: r.name }),
426
- /* @__PURE__ */ jsx5("td", { children: r.email }),
427
- /* @__PURE__ */ jsx5("td", { children: r.applicationStatus }),
428
- /* @__PURE__ */ jsxs5("td", { children: [
429
- r.subscriptionStatus ?? "\u2014",
430
- r.cancelAtPeriodEnd ? " (cancelling)" : ""
431
- ] }),
432
- /* @__PURE__ */ jsxs5("td", { children: [
433
- money2(r.amountCents),
434
- "/",
435
- r.interval === "month" ? "mo" : "yr"
436
- ] }),
437
- /* @__PURE__ */ jsx5("td", { children: date(r.renewalAt) })
438
- ] }, r.id)),
439
- data.rows.length === 0 ? /* @__PURE__ */ jsx5("tr", { children: /* @__PURE__ */ jsx5("td", { colSpan: 6, className: "muted", children: "No subscriptions yet." }) }) : null
440
- ] })
441
- ] }) })
442
- ] });
443
- }
444
- function billingSection(options = {}) {
445
- const { id = "billing", label = "Billing" } = options;
446
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx5(BillingBody, { ctx }) };
447
- }
448
-
449
- // src/ui/admin-email.tsx
450
- import { useState as useState5 } from "react";
451
- import { Button } from "@odla-ai/ui/components";
452
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
453
- function EmailBody({ ctx }) {
454
- const cfg = useAdminResource(ctx.getToken, "/api/admin/group/email");
455
- const log = useAdminResource(ctx.getToken, "/api/admin/email/log");
456
- const [draft, setDraft] = useState5(null);
457
- const [busy, setBusy] = useState5(null);
458
- const [note, setNote] = useState5(null);
459
- const data = draft ?? cfg.data;
460
- if (cfg.loading) return /* @__PURE__ */ jsx6("div", { className: "wrap page", children: "Loading\u2026" });
461
- if (cfg.error || !data) return /* @__PURE__ */ jsxs6("div", { className: "wrap page", children: [
462
- "Couldn\u2019t load email config: ",
463
- cfg.error
464
- ] });
465
- const edit = (patch) => setDraft({ ...data, ...patch });
466
- const editTemplate = (key, patch) => edit({ emailTemplates: { ...data.emailTemplates, [key]: { ...data.emailTemplates[key], ...patch } } });
467
- const save = async () => {
468
- setBusy("save");
469
- setNote(null);
470
- try {
471
- await adminFetch(ctx.getToken, "/api/admin/group/email", { method: "PUT", body: JSON.stringify(data) });
472
- setDraft(null);
473
- cfg.refresh();
474
- setNote("Saved.");
475
- } catch (e) {
476
- setNote(e instanceof Error ? e.message : String(e));
477
- } finally {
478
- setBusy(null);
479
- }
480
- };
481
- const test = async (template) => {
482
- setBusy(`test:${template}`);
483
- setNote(null);
484
- try {
485
- const r = await adminFetch(ctx.getToken, "/api/admin/email/test", { method: "POST", body: JSON.stringify({ template }) });
486
- setNote(r.ok ? `Sent ${template} to ${r.to}${r.redirected ? " (dev-redirected)" : ""}.` : `Test send did not deliver.`);
487
- log.refresh();
488
- } catch (e) {
489
- setNote(e instanceof Error ? e.message : String(e));
490
- } finally {
491
- setBusy(null);
492
- }
493
- };
494
- return /* @__PURE__ */ jsxs6("div", { className: "wrap page", children: [
495
- /* @__PURE__ */ jsxs6("div", { className: "card", children: [
496
- /* @__PURE__ */ jsx6("h3", { children: "Delivery" }),
497
- /* @__PURE__ */ jsxs6("p", { className: "muted", children: [
498
- data.envName,
499
- " \xB7 ",
500
- data.transport,
501
- data.fromEmail ? ` \xB7 from ${data.fromEmail}` : ""
502
- ] }),
503
- /* @__PURE__ */ jsxs6("label", { children: [
504
- "Notification address",
505
- /* @__PURE__ */ jsx6("input", { value: data.notificationEmail, onChange: (e) => edit({ notificationEmail: e.target.value }) })
506
- ] }),
507
- /* @__PURE__ */ jsxs6("label", { children: [
508
- "Reply-to",
509
- /* @__PURE__ */ jsx6("input", { value: data.replyTo, onChange: (e) => edit({ replyTo: e.target.value }) })
510
- ] }),
511
- /* @__PURE__ */ jsxs6("label", { children: [
512
- "Debug inbox (non-prod redirect)",
513
- /* @__PURE__ */ jsx6("input", { value: data.debugEmail, onChange: (e) => edit({ debugEmail: e.target.value }) })
514
- ] })
515
- ] }),
516
- Object.entries(data.emailTemplates).map(([key, t]) => /* @__PURE__ */ jsxs6("div", { className: "card", children: [
517
- /* @__PURE__ */ jsx6("h3", { children: key }),
518
- /* @__PURE__ */ jsxs6("label", { className: "row", children: [
519
- /* @__PURE__ */ jsx6("input", { type: "checkbox", checked: t.enabled, onChange: (e) => editTemplate(key, { enabled: e.target.checked }) }),
520
- " enabled"
521
- ] }),
522
- /* @__PURE__ */ jsxs6("label", { children: [
523
- "Subject",
524
- /* @__PURE__ */ jsx6("input", { value: t.subject, onChange: (e) => editTemplate(key, { subject: e.target.value }) })
525
- ] }),
526
- /* @__PURE__ */ jsxs6("label", { children: [
527
- "Body",
528
- /* @__PURE__ */ jsx6("textarea", { rows: 5, value: t.text, onChange: (e) => editTemplate(key, { text: e.target.value }) })
529
- ] }),
530
- /* @__PURE__ */ jsx6(Button, { variant: "secondary", mini: true, disabled: busy === `test:${key}`, onClick: () => void test(key), children: "Send test" })
531
- ] }, key)),
532
- /* @__PURE__ */ jsxs6("div", { className: "row", children: [
533
- /* @__PURE__ */ jsx6(Button, { disabled: !draft || busy === "save", onClick: () => void save(), children: "Save changes" }),
534
- note ? /* @__PURE__ */ jsx6("span", { className: "muted", children: note }) : null
535
- ] }),
536
- /* @__PURE__ */ jsxs6("div", { className: "card", children: [
537
- /* @__PURE__ */ jsx6("h3", { children: "Send log" }),
538
- (log.data?.sends ?? []).length === 0 ? /* @__PURE__ */ jsx6("p", { className: "muted", children: "No sends yet." }) : /* @__PURE__ */ jsx6("ul", { className: "log", children: (log.data?.sends ?? []).map((r) => /* @__PURE__ */ jsxs6("li", { children: [
539
- /* @__PURE__ */ jsx6("span", { children: new Date(r.sentAt).toLocaleString() }),
540
- " \xB7 ",
541
- /* @__PURE__ */ jsx6("span", { children: r.template }),
542
- " \u2192 ",
543
- /* @__PURE__ */ jsx6("span", { children: r.to }),
544
- r.error ? /* @__PURE__ */ jsx6("span", { className: "badge danger", children: "failed" }) : r.redirected ? /* @__PURE__ */ jsx6("span", { className: "badge", children: "redirected" }) : null
545
- ] }, r.id)) })
546
- ] })
547
- ] });
548
- }
549
- function emailSection(options = {}) {
550
- const { id = "email", label = "Email" } = options;
551
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx6(EmailBody, { ctx }) };
552
- }
553
-
554
- // src/ui/admin-meetings.tsx
555
- import { useState as useState6 } from "react";
556
- import { Button as Button2 } from "@odla-ai/ui/components";
557
- import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
558
- function MeetingsBody({ ctx }) {
559
- const { data, loading, error, refresh } = useAdminResource(ctx.getToken, "/api/admin/meetings?all=1");
560
- const [busy, setBusy] = useState6(null);
561
- const [note, setNote] = useState6(null);
562
- if (loading) return /* @__PURE__ */ jsx7("div", { className: "wrap page", children: "Loading\u2026" });
563
- if (error || !data) return /* @__PURE__ */ jsxs7("div", { className: "wrap page", children: [
564
- "Couldn\u2019t load meetings: ",
565
- error
566
- ] });
567
- const when = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
568
- const cancel = async (id) => {
569
- if (!globalThis.confirm?.("Cancel this call? Google notifies the attendee.")) return;
570
- setBusy(id);
571
- setNote(null);
572
- try {
573
- await adminFetch(ctx.getToken, `/api/admin/meetings/${id}/cancel`, { method: "POST" });
574
- refresh();
575
- } catch (e) {
576
- setNote(e instanceof Error ? e.message : String(e));
577
- } finally {
578
- setBusy(null);
579
- }
580
- };
581
- const reschedule = async (id) => {
582
- const input = globalThis.prompt?.("New start time (ISO or 'YYYY-MM-DD HH:MM'):");
583
- if (!input) return;
584
- const startAt = Date.parse(input);
585
- if (!Number.isFinite(startAt)) {
586
- setNote("Couldn\u2019t parse that time.");
587
- return;
588
- }
589
- setBusy(id);
590
- setNote(null);
591
- try {
592
- await adminFetch(ctx.getToken, `/api/admin/meetings/${id}/reschedule`, { method: "POST", body: JSON.stringify({ startAt }) });
593
- refresh();
594
- } catch (e) {
595
- setNote(e instanceof Error ? e.message : String(e));
596
- } finally {
597
- setBusy(null);
598
- }
599
- };
600
- return /* @__PURE__ */ jsxs7("div", { className: "wrap page", children: [
601
- /* @__PURE__ */ jsxs7("h3", { children: [
602
- "Agenda ",
603
- /* @__PURE__ */ jsxs7("span", { className: "muted", children: [
604
- "(",
605
- data.timezone,
606
- ")"
607
- ] })
608
- ] }),
609
- note ? /* @__PURE__ */ jsx7("p", { className: "muted", children: note }) : null,
610
- data.meetings.length === 0 ? /* @__PURE__ */ jsx7("p", { className: "muted", children: "No meetings." }) : /* @__PURE__ */ jsx7("ul", { className: "agenda", children: data.meetings.map((m) => /* @__PURE__ */ jsxs7("li", { className: m.status === "cancelled" ? "cancelled" : "", children: [
611
- /* @__PURE__ */ jsx7("span", { className: "when", children: when(m.startAt) }),
612
- /* @__PURE__ */ jsx7("span", { className: "who", children: m.applicant ? `${m.applicant.name} \xB7 ${m.applicant.email}` : "(unknown)" }),
613
- /* @__PURE__ */ jsx7("span", { className: "badge", children: m.status }),
614
- m.drift && m.drift !== "none" ? /* @__PURE__ */ jsxs7("span", { className: "badge danger", children: [
615
- "drift: ",
616
- m.drift
617
- ] }) : null,
618
- m.meetUrl ? /* @__PURE__ */ jsx7("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: "Meet" }) : null,
619
- m.status === "scheduled" ? /* @__PURE__ */ jsxs7("span", { className: "actions", children: [
620
- /* @__PURE__ */ jsx7(Button2, { variant: "secondary", mini: true, disabled: busy === m.id, onClick: () => void reschedule(m.id), children: "Reschedule" }),
621
- /* @__PURE__ */ jsx7(Button2, { variant: "danger", mini: true, disabled: busy === m.id, onClick: () => void cancel(m.id), children: "Cancel" })
622
- ] }) : null
623
- ] }, m.id)) })
624
- ] });
625
- }
626
- function meetingsSection(options = {}) {
627
- const { id = "meetings", label = "Meetings" } = options;
628
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx7(MeetingsBody, { ctx }) };
629
- }
630
-
631
- export {
632
- ChapterAdmin,
633
- collectionSection,
634
- peopleSection,
635
- adminFetch,
636
- useAdminResource,
637
- dashboardSection,
638
- billingSection,
639
- emailSection,
640
- meetingsSection
641
- };
642
- //# sourceMappingURL=chunk-HB2HUJCW.js.map