@odla-ai/chapter 0.16.0 → 0.17.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.
- package/dist/chunk-HB2HUJCW.js +642 -0
- package/dist/chunk-HB2HUJCW.js.map +1 -0
- package/dist/ui/admin/index.d.ts +74 -5
- package/dist/ui/admin/index.js +17 -3
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +17 -3
- package/package.json +1 -1
- package/dist/chunk-YO6DY5DL.js +0 -281
- package/dist/chunk-YO6DY5DL.js.map +0 -1
|
@@ -0,0 +1,642 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ui/admin.tsx","../src/ui/chrome.tsx","../src/ui/admin-people.tsx","../src/ui/admin-api.ts","../src/ui/admin-dashboard.tsx","../src/ui/admin-billing.tsx","../src/ui/admin-email.tsx","../src/ui/admin-meetings.tsx"],"sourcesContent":["// ChapterAdmin — the whole admin console for a chapter/hub, owned by the package\n// so no site re-wires (or re-bugs) it: the Clerk publishable-key fetch\n// (/api/config), the themed sign-in with the post-sign-in redirect DERIVED from\n// basePath (so it never bounces to \"/\"), the odla-db admins-allowlist check\n// (/api/me), and a TopBar shell (chrome.tsx) that renders the caller's sections.\nimport { useEffect, useMemo, useState } from \"react\";\nimport { ClerkGate, SignedIn, SignedOut, SignIn, useClerkAuth, clerkAppearanceFromTokens } from \"@odla-ai/auth-clerk\";\nimport { CrmClient } from \"@odla-ai/crm\";\nimport { S, Gate, AdminShell } from \"./chrome.js\";\nimport type { AdminSection, Brand } from \"./chrome.js\";\n\nexport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\n/** Props for {@link ChapterAdmin}. */\nexport interface ChapterAdminProps {\n /** The admin sections to render (nav label + body). */\n sections: AdminSection[];\n /** Admin mount path. The sign-in redirect + section routing derive from it,\n * so the \"bounce to home after sign-in\" bug is impossible. Default \"/admin\". */\n basePath?: string;\n /** Wordmark shown in the gate + top bar. */\n brand?: Brand;\n /** CRM route mount. Default \"/api/crm\". */\n crmBasePath?: string;\n /** Origin for /api/config and /api/me. Default same origin. */\n apiBase?: string;\n}\n\n// After sign-in: build a token-bound CrmClient, confirm the account is an\n// allowlisted admin (/api/me), then render the shell.\nfunction Authed(props: {\n sections: AdminSection[];\n basePath: string;\n brand: Brand;\n crmBasePath: string;\n apiBase: string;\n}) {\n const { sections, basePath, brand, crmBasePath, apiBase } = props;\n const { getToken, signOut } = useClerkAuth();\n const client = useMemo(\n () =>\n new CrmClient({\n basePath: crmBasePath,\n headers: async (): Promise<Record<string, string>> => {\n const t = await getToken();\n return t ? { authorization: `Bearer ${t}` } : {};\n },\n }),\n [getToken, crmBasePath],\n );\n const [state, setState] = useState<{ status: \"checking\" | \"ok\" | \"denied\"; email?: string | null }>({\n status: \"checking\",\n });\n\n useEffect(() => {\n let live = true;\n void (async () => {\n try {\n const t = await getToken();\n const res = await fetch(`${apiBase}/api/me`, { headers: t ? { authorization: `Bearer ${t}` } : {} });\n const body = (await res.json().catch(() => ({}))) as { authorized?: boolean; email?: string | null };\n if (live) setState({ status: body.authorized ? \"ok\" : \"denied\", email: body.email ?? null });\n } catch {\n if (live) setState({ status: \"denied\" });\n }\n })();\n return () => {\n live = false;\n };\n }, [getToken, apiBase]);\n\n if (state.status === \"checking\") {\n return (\n <Gate brand={brand}>\n <p style={S.muted}>Checking access…</p>\n </Gate>\n );\n }\n if (state.status === \"denied\") {\n return (\n <Gate brand={brand}>\n <div className=\"card\" style={S.card}>\n <h2 style={{ marginTop: 0 }}>Not authorized</h2>\n <p style={S.muted}>\n {state.email ? `${state.email} isn't` : \"This account isn't\"} on the admin list. Ask an existing admin to add\n you in odla Studio.\n </p>\n <button className=\"btn secondary\" onClick={() => signOut()} style={{ marginTop: 12 }}>\n Sign out\n </button>\n </div>\n </Gate>\n );\n }\n return (\n <AdminShell\n sections={sections}\n basePath={basePath}\n brand={brand}\n client={client}\n getToken={getToken}\n signOut={signOut}\n email={state.email ?? null}\n />\n );\n}\n\n/**\n * The whole admin console for a chapter/hub: fetches the Clerk publishable key\n * (/api/config), gates on sign-in with the post-sign-in redirect derived from\n * `basePath`, checks the odla-db admins allowlist (/api/me), and renders a\n * TopBar shell over the caller's `sections`. The gate/shell/redirect are owned\n * here so no site re-wires (or re-bugs) them.\n */\nexport function ChapterAdmin(props: ChapterAdminProps) {\n const sections = props.sections;\n const basePath = props.basePath ?? \"/admin\";\n const brand = props.brand ?? { name: \"Admin\" };\n const crmBasePath = props.crmBasePath ?? \"/api/crm\";\n const apiBase = props.apiBase ?? \"\";\n\n const [pk, setPk] = useState<string | null | undefined>(undefined);\n useEffect(() => {\n let live = true;\n void (async () => {\n try {\n const res = await fetch(`${apiBase}/api/config`);\n const body = (await res.json()) as { clerkPublishableKey?: string | null };\n if (live) setPk(body.clerkPublishableKey ?? null);\n } catch {\n if (live) setPk(null);\n }\n })();\n return () => {\n live = false;\n };\n }, [apiBase]);\n\n if (pk === undefined) {\n return (\n <Gate brand={brand}>\n <p style={S.muted}>Loading…</p>\n </Gate>\n );\n }\n if (!pk) {\n return (\n <Gate brand={brand}>\n <div className=\"card\" style={S.card}>\n <h2 style={{ marginTop: 0 }}>Sign-in not configured</h2>\n <p style={S.muted}>No Clerk publishable key is set for this environment yet.</p>\n </div>\n </Gate>\n );\n }\n return (\n <ClerkGate publishableKey={pk} appearance={clerkAppearanceFromTokens()} afterSignOutUrl=\"/\">\n <SignedOut>\n <Gate brand={brand} tagline=\"Admin sign-in — invite only\">\n <SignIn routing=\"hash\" forceRedirectUrl={basePath} signUpForceRedirectUrl={basePath} />\n </Gate>\n </SignedOut>\n <SignedIn>\n <Authed sections={sections} basePath={basePath} brand={brand} crmBasePath={crmBasePath} apiBase={apiBase} />\n </SignedIn>\n </ClerkGate>\n );\n}\n","// Shared chrome for ChapterAdmin: the section contract, the centered gate\n// screen, the signed-in TopBar shell, and the inline style tokens. Split out of\n// admin.tsx to stay under the repo's per-file LOC cap. Uses only @odla-ai/ui\n// classes + --ui-* tokens, so a consumer needs nothing beyond the odla-ui sheet.\nimport { useCallback, useEffect, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { TopBar, TopBarLink } from \"@odla-ai/ui/components\";\nimport type { CrmClient } from \"@odla-ai/crm\";\n\n/** What each admin section receives. `client` is a CrmClient bound to the\n * signed-in admin's bearer token; `navigate` switches sections. */\nexport interface AdminSectionContext {\n client: CrmClient;\n getToken: () => Promise<string | null>;\n /** Switch to another section by id (e.g. Overview \"jump in\" buttons). */\n navigate: (sectionId: string) => void;\n}\n\n/** One admin-console section: a nav label and a body renderer. */\nexport interface AdminSection {\n id: string;\n label: string;\n render: (ctx: AdminSectionContext) => ReactNode;\n}\n\n/** Wordmark shown in the gate and top bar. `name` is the plain-text identity (and\n * the badge fallback); `wordmark` is an optional rich display node for brands\n * whose typography can't be a plain string (e.g. an ampersand wrapped to render\n * upright), and `badge` overrides the glyph. */\nexport type Brand = { name: string; badge?: string; wordmark?: ReactNode };\n\n/** The badge glyph: the explicit `badge`, else the first 3 letters of `name`. */\nexport const badgeText = (brand: Brand): string => brand.badge ?? brand.name.slice(0, 3).toUpperCase();\n\n/** The display node: the rich `wordmark` if given, else the plain `name`. */\nexport const brandDisplay = (brand: Brand): ReactNode => brand.wordmark ?? brand.name;\n\n/** Inline style tokens (keyed to --ui-* custom properties). */\nexport const S: Record<string, CSSProperties> = {\n gate: {\n minHeight: \"100vh\",\n display: \"grid\",\n placeItems: \"center\",\n padding: 24,\n background:\n \"radial-gradient(900px 480px at 50% -8%, var(--ui-accent-soft), transparent 70%),\" +\n \" radial-gradient(700px 500px at 110% 10%, var(--ui-good-soft), transparent 60%)\",\n },\n gateInner: { width: \"100%\", maxWidth: 400, display: \"flex\", flexDirection: \"column\", alignItems: \"center\" },\n brandBox: { textAlign: \"center\", marginBottom: 22 },\n badge: {\n width: 52,\n height: 52,\n margin: \"0 auto 14px\",\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 15,\n fontWeight: 700,\n color: \"var(--ui-on-accent)\",\n background: \"linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))\",\n borderRadius: 14,\n boxShadow: \"0 10px 28px var(--ui-accent-soft)\",\n },\n badgeSm: {\n width: 26,\n height: 26,\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 10,\n fontWeight: 700,\n color: \"var(--ui-on-accent)\",\n background: \"linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))\",\n borderRadius: 7,\n marginRight: 9,\n },\n h1: { margin: 0, fontSize: 28, letterSpacing: \"-0.02em\", fontWeight: 700 },\n muted: { color: \"var(--ui-text-muted)\" },\n role: {\n fontFamily: \"var(--ui-font-mono)\",\n fontSize: 11,\n textTransform: \"uppercase\",\n letterSpacing: \"0.04em\",\n padding: \"2px 8px\",\n borderRadius: 999,\n background: \"var(--ui-accent-soft)\",\n border: \"1px solid var(--ui-accent)\",\n color: \"var(--ui-accent-strong)\",\n },\n whoami: { display: \"flex\", alignItems: \"center\", gap: 8, fontSize: 12 },\n // Sections own their own width/padding (e.g. a .wrap container).\n main: { minHeight: \"calc(100vh - 61px)\" },\n card: { width: \"100%\", textAlign: \"center\" },\n};\n\n/** Centered gate screen used for loading / sign-in / denied / not-configured. */\nexport function Gate(props: { brand: Brand; tagline?: string; children: ReactNode }) {\n const { brand, tagline, children } = props;\n return (\n <div style={S.gate}>\n <div style={S.gateInner}>\n <div style={S.brandBox}>\n <div style={S.badge}>{badgeText(brand)}</div>\n <h1 style={S.h1}>{brandDisplay(brand)}</h1>\n {tagline ? <p style={{ ...S.muted, margin: \"8px 0 0\", fontSize: 14 }}>{tagline}</p> : null}\n </div>\n {children}\n </div>\n </div>\n );\n}\n\n/** The signed-in shell: a TopBar of the caller's sections + the active body. */\nexport function AdminShell(props: {\n sections: AdminSection[];\n basePath: string;\n brand: Brand;\n client: CrmClient;\n getToken: () => Promise<string | null>;\n signOut: () => void;\n email: string | null;\n}) {\n const { sections, basePath, brand, client, getToken, signOut, email } = props;\n\n const sectionFromPath = useCallback(() => {\n const fallback = sections[0]?.id ?? \"\";\n if (typeof window === \"undefined\") return fallback;\n const rest = window.location.pathname.slice(basePath.length).replace(/^\\//, \"\");\n const id = rest.split(\"/\")[0] ?? \"\";\n return sections.some((s) => s.id === id) ? id : fallback;\n }, [sections, basePath]);\n\n const [section, setSection] = useState(sectionFromPath);\n\n const go = useCallback(\n (id: string) => {\n window.history.pushState(null, \"\", `${basePath}/${id}`);\n setSection(id);\n },\n [basePath],\n );\n\n useEffect(() => {\n const onPop = () => setSection(sectionFromPath());\n window.addEventListener(\"popstate\", onPop);\n return () => window.removeEventListener(\"popstate\", onPop);\n }, [sectionFromPath]);\n\n const active = sections.find((s) => s.id === section) ?? sections[0];\n\n return (\n <>\n <TopBar>\n <a className=\"topbar-brand\" href=\"/\" style={{ display: \"inline-flex\", alignItems: \"center\" }}>\n <span style={S.badgeSm}>{badgeText(brand)}</span>\n {brandDisplay(brand)}\n </a>\n <nav className=\"topbar-nav\" aria-label=\"Admin navigation\">\n {sections.map((s) => (\n <TopBarLink key={s.id} active={section === s.id} onClick={() => go(s.id)}>\n {s.label}\n </TopBarLink>\n ))}\n </nav>\n <div className=\"topbar-spacer\" />\n <div className=\"topbar-actions\">\n <span style={S.whoami}>\n <span style={S.role}>admin</span>\n {email ? <span style={S.muted}>{email}</span> : null}\n </span>\n <button className=\"btn secondary mini\" onClick={() => signOut()}>\n Sign out\n </button>\n </div>\n </TopBar>\n <main className=\"shell-main\" style={S.main}>\n {active ? active.render({ client, getToken, navigate: go }) : null}\n </main>\n </>\n );\n}\n","// People/CRM admin section — the core admin workflow, assembled from the shipped\n// @odla-ai/crm/ui kit against the /api/crm/* routes the worker already mounts.\n// It's a FACTORY: peopleSection({ crm }) → an AdminSection you drop into\n// ChapterAdmin's `sections`. Every lifecycle action (edit fields, move pipeline\n// stage, tag) goes through the token-bound CrmClient — never a direct db write,\n// so the package's operational invariants hold (G7).\nimport { useState } from \"react\";\nimport { CrmList, RecordPanel, useCrmQuery, useCrmRecord } from \"@odla-ai/crm/ui\";\nimport type { Crm, CrmClient, CrmRecord } from \"@odla-ai/crm\";\nimport type { AdminSection } from \"./chrome.js\";\n\nfunction PeopleBody(props: { crm: Crm; client: CrmClient; type: string }) {\n const { crm, client, type } = props;\n // The crm/ui hooks type their client against @odla-ai/crm's ui-entry bundled\n // declaration; ctx.client is the core-entry declaration. Same runtime class,\n // but their private field makes the two .d.ts copies nominally distinct — cast\n // to the param type the hooks expect.\n const hookClient = client as unknown as Parameters<typeof useCrmQuery>[0];\n const query = useCrmQuery(hookClient, type);\n const [openId, setOpenId] = useState<string | null>(null);\n const record = useCrmRecord(hookClient, openId);\n const [saving, setSaving] = useState(false);\n\n const saveFields = async (input: Record<string, unknown>) => {\n if (!openId) return;\n setSaving(true);\n try {\n await client.updateRecord(openId, { input });\n record.refresh();\n query.refresh();\n } finally {\n setSaving(false);\n }\n };\n const moveStage = async (to: string) => {\n if (!openId) return;\n await client.setStage(openId, to);\n record.refresh();\n query.refresh();\n };\n const addTag = async (tag: string) => {\n if (openId) {\n await client.addTag(openId, tag);\n record.refresh();\n }\n };\n const removeTag = async (tag: string) => {\n if (openId) {\n await client.removeTag(openId, tag);\n record.refresh();\n }\n };\n\n return (\n <div className=\"wrap\">\n <CrmList crm={crm} type={type} query={query} onOpenRecord={(r: CrmRecord) => setOpenId(r.id)} />\n {record.detail ? (\n <RecordPanel\n crm={crm}\n detail={record.detail}\n onSaveFields={saveFields}\n onMoveStage={moveStage}\n onAddTag={addTag}\n onRemoveTag={removeTag}\n saving={saving}\n />\n ) : null}\n </div>\n );\n}\n\n/** Options for {@link peopleSection}. */\nexport interface PeopleSectionOptions {\n /** The chapter's resolved CRM engine — `chapter.crm` from defineChapter. */\n crm: Crm;\n /** Section id / route segment. Default \"people\". */\n id?: string;\n /** Nav label. Default \"People\". */\n label?: string;\n /** The record type to list. Default \"person\". */\n type?: string;\n}\n\n/** Options for {@link collectionSection}. */\nexport interface CollectionSectionOptions {\n /** The chapter's resolved CRM engine — `chapter.crm` from defineChapter. */\n crm: Crm;\n /** The CRM record type to list (e.g. `\"person\"`, `\"deal\"`, `\"investment\"`). */\n type: string;\n /** Section id / route segment. Defaults to `type`. */\n id?: string;\n /** Nav label. Defaults to the type's plural label from the CRM config. */\n label?: string;\n}\n\n/**\n * Build a CRM collection admin section for ANY declared record type: a\n * server-driven records table plus a facet-composed detail drawer (fields,\n * pipeline stage, tags) over the token-bound CrmClient. This is the generic kit —\n * call it once per collection a site runs (members, deals, investments), and each\n * renders with the same UX. Every action goes through `/api/crm/*`, never a direct\n * db write, so the CRM invariants hold.\n */\nexport function collectionSection(options: CollectionSectionOptions): AdminSection {\n const { crm, type } = options;\n let fallbackLabel = type;\n try {\n fallbackLabel = crm.type(type).labelPlural ?? crm.type(type).label ?? type;\n } catch {\n // Unknown type — the section still renders; the CRM route reports the error.\n }\n const { id = type, label = fallbackLabel } = options;\n return { id, label, render: (ctx) => <PeopleBody crm={crm} client={ctx.client} type={type} /> };\n}\n\n/**\n * Build the People/CRM admin section — {@link collectionSection} preset to the\n * `person` type. Kept as a convenience; new collections use `collectionSection`.\n */\nexport function peopleSection(options: PeopleSectionOptions): AdminSection {\n const { crm, id = \"people\", label = \"People\", type = \"person\" } = options;\n return collectionSection({ crm, type, id, label });\n}\n","// Token-bound access to the /api/admin/* routes for the console sections. Every\n// section fetches with the signed-in admin's bearer token (from ChapterAdmin's\n// getToken) — the worker gates each route with verifyUser + isAdmin, so the UI\n// carries no privilege of its own.\nimport { useCallback, useEffect, useState } from \"react\";\n\n/** Fetch a JSON `/api/admin/*` route with the admin bearer token. Throws on a\n * non-2xx (the body's `error`, else the status). */\nexport async function adminFetch<T = unknown>(\n getToken: () => Promise<string | null>,\n path: string,\n init?: RequestInit,\n): Promise<T> {\n const token = await getToken();\n const headers = new Headers(init?.headers);\n if (token) headers.set(\"authorization\", `Bearer ${token}`);\n if (init?.body) headers.set(\"content-type\", \"application/json\");\n const res = await fetch(path, { ...init, headers });\n const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;\n if (!res.ok) throw new Error(typeof body.error === \"string\" ? body.error : `request failed (${res.status})`);\n return body as T;\n}\n\n/** The state of one admin resource load. */\nexport interface AdminResource<T> {\n data: T | null;\n loading: boolean;\n error: string | null;\n /** Re-fetch (e.g. after a mutation). */\n refresh: () => void;\n}\n\n/** Load a `/api/admin/*` GET route into React state, re-fetching on `refresh()`\n * or when `path` changes. */\nexport function useAdminResource<T = unknown>(getToken: () => Promise<string | null>, path: string): AdminResource<T> {\n const [data, setData] = useState<T | null>(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [nonce, setNonce] = useState(0);\n\n useEffect(() => {\n let live = true;\n setLoading(true);\n setError(null);\n adminFetch<T>(getToken, path)\n .then((d) => live && setData(d))\n .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)))\n .finally(() => live && setLoading(false));\n return () => {\n live = false;\n };\n }, [getToken, path, nonce]);\n\n const refresh = useCallback(() => setNonce((n) => n + 1), []);\n return { data, loading, error, refresh };\n}\n","// Dashboard section: the operational overview from GET /api/admin/dashboard —\n// application flow, pipeline stage counts, the upcoming call agenda, and (when\n// billing is wired) live revenue. Renders the response the route already shapes,\n// so a hub gets the identical overview per chapter.\nimport { useAdminResource } from \"./admin-api.js\";\nimport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\ninterface DashboardData {\n applications: { total: number; last7: number; last30: number };\n pipeline: Record<string, number>;\n pipelineDelta: Record<string, number>;\n calls: { upcoming: number; needsAttention: number };\n agenda: Array<{ id: string; startAt: number; name: string; email: string | null; drift: string; meetUrl: string | null }>;\n timezone: string;\n revenue: { billingReady: boolean; activeCount?: number; annualRunRateCents?: number };\n}\n\nconst money = (cents: number): string => `$${Math.round(cents / 100).toLocaleString()}`;\n\nfunction DashboardBody({ ctx }: { ctx: AdminSectionContext }) {\n const { data, loading, error } = useAdminResource<DashboardData>(ctx.getToken, \"/api/admin/dashboard\");\n if (loading) return <div className=\"wrap page\">Loading…</div>;\n if (error || !data) return <div className=\"wrap page\">Couldn’t load the dashboard: {error}</div>;\n\n const when = (t: number): string => new Date(t).toLocaleString([], { dateStyle: \"medium\", timeStyle: \"short\" });\n const Kpi = ({ label, value, sub }: { label: string; value: string | number; sub?: string }) => (\n <div className=\"card kpi\">\n <div className=\"kpi-value\">{value}</div>\n <div className=\"kpi-label\">{label}</div>\n {sub ? <div className=\"kpi-sub muted\">{sub}</div> : null}\n </div>\n );\n return (\n <div className=\"wrap page\">\n <div className=\"kpis\">\n <Kpi label=\"Applications\" value={data.applications.total} sub={`+${data.applications.last7} in 7d`} />\n <Kpi label=\"Upcoming calls\" value={data.calls.upcoming} sub={data.calls.needsAttention ? `${data.calls.needsAttention} need attention` : undefined} />\n {data.revenue.billingReady ? <Kpi label=\"Active members\" value={data.revenue.activeCount ?? 0} /> : null}\n {data.revenue.billingReady ? <Kpi label=\"Annual run rate\" value={money(data.revenue.annualRunRateCents ?? 0)} /> : null}\n </div>\n\n <div className=\"card\">\n <h3>Pipeline</h3>\n <ul className=\"pipeline-counts\">\n {Object.entries(data.pipeline).map(([stage, count]) => (\n <li key={stage}>\n <span className=\"stage\">{stage}</span>\n <span className=\"count\">{count}</span>\n {data.pipelineDelta[stage] ? <span className=\"delta\">+{data.pipelineDelta[stage]}</span> : null}\n </li>\n ))}\n </ul>\n </div>\n\n <div className=\"card\">\n <h3>Upcoming calls <span className=\"muted\">({data.timezone})</span></h3>\n {data.agenda.length === 0 ? (\n <p className=\"muted\">No calls scheduled.</p>\n ) : (\n <ul className=\"agenda\">\n {data.agenda.map((m) => (\n <li key={m.id}>\n <span className=\"when\">{when(m.startAt)}</span>\n <span className=\"who\">{m.name}{m.email ? ` · ${m.email}` : \"\"}</span>\n {m.drift && m.drift !== \"none\" ? <span className=\"badge danger\">drift: {m.drift}</span> : null}\n {m.meetUrl ? <a href={m.meetUrl} target=\"_blank\" rel=\"noreferrer\">Meet</a> : null}\n </li>\n ))}\n </ul>\n )}\n </div>\n </div>\n );\n}\n\n/** Options for {@link dashboardSection}. */\nexport interface DashboardSectionOptions {\n id?: string;\n label?: string;\n}\n\n/** Build the Dashboard admin section over `GET /api/admin/dashboard`. Drop the\n * result into `ChapterAdmin`'s `sections`. */\nexport function dashboardSection(options: DashboardSectionOptions = {}): AdminSection {\n const { id = \"dashboard\", label = \"Dashboard\" } = options;\n return { id, label, render: (ctx) => <DashboardBody ctx={ctx} /> };\n}\n","// Billing section: applications joined with live Stripe subscription state from\n// GET /api/admin/billing. Degrades to a \"not configured\" note when the route\n// reports billingReady:false (no stripe_secret_key vaulted), and surfaces the\n// route's `truncated` flag rather than pretending the list is complete.\nimport { useAdminResource } from \"./admin-api.js\";\nimport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\ninterface BillingRow {\n id: string;\n name: string;\n email: string;\n applicationStatus: string;\n subscriptionStatus: string | null;\n cancelAtPeriodEnd: boolean;\n amountCents: number;\n interval: string;\n renewalAt: number | null;\n}\ninterface BillingData {\n billingReady: boolean;\n testMode?: boolean;\n truncated?: boolean;\n rows: BillingRow[];\n summary: { activeCount: number; annualizedCents: number; renewingSoonCount: number; pastDueCount: number; canceledCount: number; refundedCount: number } | null;\n}\n\nconst money = (cents: number): string => `$${(cents / 100).toLocaleString(undefined, { minimumFractionDigits: 0 })}`;\nconst date = (t: number | null): string => (t ? new Date(t).toLocaleDateString([], { dateStyle: \"medium\" }) : \"—\");\n\nfunction BillingBody({ ctx }: { ctx: AdminSectionContext }) {\n const { data, loading, error } = useAdminResource<BillingData>(ctx.getToken, \"/api/admin/billing\");\n if (loading) return <div className=\"wrap page\">Loading…</div>;\n if (error || !data) return <div className=\"wrap page\">Couldn’t load billing: {error}</div>;\n if (!data.billingReady) return <div className=\"wrap page\"><div className=\"card\"><p className=\"muted\">Billing isn’t configured — no Stripe key is vaulted for this group.</p></div></div>;\n\n const s = data.summary;\n return (\n <div className=\"wrap page\">\n {data.testMode ? <div className=\"badge\">Stripe test mode</div> : null}\n {s ? (\n <div className=\"kpis\">\n <div className=\"card kpi\"><div className=\"kpi-value\">{s.activeCount}</div><div className=\"kpi-label\">Active</div></div>\n <div className=\"card kpi\"><div className=\"kpi-value\">{money(s.annualizedCents)}</div><div className=\"kpi-label\">Annualized</div></div>\n <div className=\"card kpi\"><div className=\"kpi-value\">{s.renewingSoonCount}</div><div className=\"kpi-label\">Renewing ≤60d</div></div>\n <div className=\"card kpi\"><div className=\"kpi-value\">{s.pastDueCount}</div><div className=\"kpi-label\">Past due</div></div>\n </div>\n ) : null}\n {data.truncated ? <p className=\"badge danger\">Showing the first 100 subscriptions — the list is truncated.</p> : null}\n <div className=\"card\">\n <table className=\"data-table\">\n <thead>\n <tr><th>Name</th><th>Email</th><th>Application</th><th>Subscription</th><th>Amount</th><th>Renews</th></tr>\n </thead>\n <tbody>\n {data.rows.map((r) => (\n <tr key={r.id}>\n <td>{r.name}</td>\n <td>{r.email}</td>\n <td>{r.applicationStatus}</td>\n <td>{r.subscriptionStatus ?? \"—\"}{r.cancelAtPeriodEnd ? \" (cancelling)\" : \"\"}</td>\n <td>{money(r.amountCents)}/{r.interval === \"month\" ? \"mo\" : \"yr\"}</td>\n <td>{date(r.renewalAt)}</td>\n </tr>\n ))}\n {data.rows.length === 0 ? <tr><td colSpan={6} className=\"muted\">No subscriptions yet.</td></tr> : null}\n </tbody>\n </table>\n </div>\n </div>\n );\n}\n\n/** Options for {@link billingSection}. */\nexport interface BillingSectionOptions {\n id?: string;\n label?: string;\n}\n\n/** Build the Billing admin section over `GET /api/admin/billing`. Renders a\n * \"not configured\" note when the group has no Stripe key vaulted. */\nexport function billingSection(options: BillingSectionOptions = {}): AdminSection {\n const { id = \"billing\", label = \"Billing\" } = options;\n return { id, label, render: (ctx) => <BillingBody ctx={ctx} /> };\n}\n","// Email section: the owner-editable email config (templates + addresses) from\n// GET/PUT /api/admin/group/email, the send audit from GET /api/admin/email/log,\n// and a POST /api/admin/email/test button that exercises the real transport.\n// Everything through the token-bound /api/admin routes — no direct db write.\nimport { useState } from \"react\";\nimport { Button } from \"@odla-ai/ui/components\";\nimport { adminFetch, useAdminResource } from \"./admin-api.js\";\nimport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\ninterface Template { subject: string; text: string; enabled: boolean }\ninterface EmailConfig {\n emailTemplates: Record<string, Template>;\n replyTo: string;\n notificationEmail: string;\n debugEmail: string;\n commitmentText: string;\n normsText: string;\n transport: string;\n fromEmail: string | null;\n envName: string;\n}\ninterface LogRow { id: string; template: string; to: string; subject: string; transport: string; redirected: boolean; error: string | null; sentAt: number }\n\nfunction EmailBody({ ctx }: { ctx: AdminSectionContext }) {\n const cfg = useAdminResource<EmailConfig>(ctx.getToken, \"/api/admin/group/email\");\n const log = useAdminResource<{ sends: LogRow[] }>(ctx.getToken, \"/api/admin/email/log\");\n const [draft, setDraft] = useState<EmailConfig | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [note, setNote] = useState<string | null>(null);\n\n const data = draft ?? cfg.data;\n if (cfg.loading) return <div className=\"wrap page\">Loading…</div>;\n if (cfg.error || !data) return <div className=\"wrap page\">Couldn’t load email config: {cfg.error}</div>;\n\n const edit = (patch: Partial<EmailConfig>) => setDraft({ ...data, ...patch });\n const editTemplate = (key: string, patch: Partial<Template>) =>\n edit({ emailTemplates: { ...data.emailTemplates, [key]: { ...data.emailTemplates[key], ...patch } as Template } });\n\n const save = async () => {\n setBusy(\"save\");\n setNote(null);\n try {\n await adminFetch(ctx.getToken, \"/api/admin/group/email\", { method: \"PUT\", body: JSON.stringify(data) });\n setDraft(null);\n cfg.refresh();\n setNote(\"Saved.\");\n } catch (e) {\n setNote(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n const test = async (template: string) => {\n setBusy(`test:${template}`);\n setNote(null);\n try {\n const r = await adminFetch<{ ok: boolean; to: string; redirected: boolean }>(ctx.getToken, \"/api/admin/email/test\", { method: \"POST\", body: JSON.stringify({ template }) });\n setNote(r.ok ? `Sent ${template} to ${r.to}${r.redirected ? \" (dev-redirected)\" : \"\"}.` : `Test send did not deliver.`);\n log.refresh();\n } catch (e) {\n setNote(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n\n return (\n <div className=\"wrap page\">\n <div className=\"card\">\n <h3>Delivery</h3>\n <p className=\"muted\">{data.envName} · {data.transport}{data.fromEmail ? ` · from ${data.fromEmail}` : \"\"}</p>\n <label>Notification address<input value={data.notificationEmail} onChange={(e) => edit({ notificationEmail: (e.target as HTMLInputElement).value })} /></label>\n <label>Reply-to<input value={data.replyTo} onChange={(e) => edit({ replyTo: (e.target as HTMLInputElement).value })} /></label>\n <label>Debug inbox (non-prod redirect)<input value={data.debugEmail} onChange={(e) => edit({ debugEmail: (e.target as HTMLInputElement).value })} /></label>\n </div>\n\n {Object.entries(data.emailTemplates).map(([key, t]) => (\n <div className=\"card\" key={key}>\n <h3>{key}</h3>\n <label className=\"row\"><input type=\"checkbox\" checked={t.enabled} onChange={(e) => editTemplate(key, { enabled: (e.target as HTMLInputElement).checked })} /> enabled</label>\n <label>Subject<input value={t.subject} onChange={(e) => editTemplate(key, { subject: (e.target as HTMLInputElement).value })} /></label>\n <label>Body<textarea rows={5} value={t.text} onChange={(e) => editTemplate(key, { text: (e.target as HTMLTextAreaElement).value })} /></label>\n <Button variant=\"secondary\" mini disabled={busy === `test:${key}`} onClick={() => void test(key)}>Send test</Button>\n </div>\n ))}\n\n <div className=\"row\">\n <Button disabled={!draft || busy === \"save\"} onClick={() => void save()}>Save changes</Button>\n {note ? <span className=\"muted\">{note}</span> : null}\n </div>\n\n <div className=\"card\">\n <h3>Send log</h3>\n {(log.data?.sends ?? []).length === 0 ? <p className=\"muted\">No sends yet.</p> : (\n <ul className=\"log\">\n {(log.data?.sends ?? []).map((r) => (\n <li key={r.id}>\n <span>{new Date(r.sentAt).toLocaleString()}</span> · <span>{r.template}</span> → <span>{r.to}</span>\n {r.error ? <span className=\"badge danger\">failed</span> : r.redirected ? <span className=\"badge\">redirected</span> : null}\n </li>\n ))}\n </ul>\n )}\n </div>\n </div>\n );\n}\n\n/** Options for {@link emailSection}. */\nexport interface EmailSectionOptions {\n id?: string;\n label?: string;\n}\n\n/** Build the Email admin section: template + address editor over\n * `GET/PUT /api/admin/group/email`, the send log, and per-template test sends. */\nexport function emailSection(options: EmailSectionOptions = {}): AdminSection {\n const { id = \"email\", label = \"Email\" } = options;\n return { id, label, render: (ctx) => <EmailBody ctx={ctx} /> };\n}\n","// Meetings section: the reconciled agenda from GET /api/admin/meetings, with the\n// reschedule and cancel actions wired to POST /api/admin/meetings/:id/{...}.\n// Drift against live Google state is flagged, never auto-adopted.\nimport { useState } from \"react\";\nimport { Button } from \"@odla-ai/ui/components\";\nimport { adminFetch, useAdminResource } from \"./admin-api.js\";\nimport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\ninterface MeetingRow {\n id: string;\n startAt: number;\n endAt: number;\n status: string;\n drift: string;\n meetUrl: string | null;\n htmlLink: string | null;\n applicant: { name: string; email: string; status: string } | null;\n}\n\nfunction MeetingsBody({ ctx }: { ctx: AdminSectionContext }) {\n const { data, loading, error, refresh } = useAdminResource<{ meetings: MeetingRow[]; timezone: string }>(ctx.getToken, \"/api/admin/meetings?all=1\");\n const [busy, setBusy] = useState<string | null>(null);\n const [note, setNote] = useState<string | null>(null);\n if (loading) return <div className=\"wrap page\">Loading…</div>;\n if (error || !data) return <div className=\"wrap page\">Couldn’t load meetings: {error}</div>;\n\n const when = (t: number): string => new Date(t).toLocaleString([], { dateStyle: \"medium\", timeStyle: \"short\" });\n const cancel = async (id: string) => {\n if (!globalThis.confirm?.(\"Cancel this call? Google notifies the attendee.\")) return;\n setBusy(id);\n setNote(null);\n try {\n await adminFetch(ctx.getToken, `/api/admin/meetings/${id}/cancel`, { method: \"POST\" });\n refresh();\n } catch (e) {\n setNote(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n const reschedule = async (id: string) => {\n const input = globalThis.prompt?.(\"New start time (ISO or 'YYYY-MM-DD HH:MM'):\");\n if (!input) return;\n const startAt = Date.parse(input);\n if (!Number.isFinite(startAt)) {\n setNote(\"Couldn’t parse that time.\");\n return;\n }\n setBusy(id);\n setNote(null);\n try {\n await adminFetch(ctx.getToken, `/api/admin/meetings/${id}/reschedule`, { method: \"POST\", body: JSON.stringify({ startAt }) });\n refresh();\n } catch (e) {\n setNote(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(null);\n }\n };\n\n return (\n <div className=\"wrap page\">\n <h3>Agenda <span className=\"muted\">({data.timezone})</span></h3>\n {note ? <p className=\"muted\">{note}</p> : null}\n {data.meetings.length === 0 ? <p className=\"muted\">No meetings.</p> : (\n <ul className=\"agenda\">\n {data.meetings.map((m) => (\n <li key={m.id} className={m.status === \"cancelled\" ? \"cancelled\" : \"\"}>\n <span className=\"when\">{when(m.startAt)}</span>\n <span className=\"who\">{m.applicant ? `${m.applicant.name} · ${m.applicant.email}` : \"(unknown)\"}</span>\n <span className=\"badge\">{m.status}</span>\n {m.drift && m.drift !== \"none\" ? <span className=\"badge danger\">drift: {m.drift}</span> : null}\n {m.meetUrl ? <a href={m.meetUrl} target=\"_blank\" rel=\"noreferrer\">Meet</a> : null}\n {m.status === \"scheduled\" ? (\n <span className=\"actions\">\n <Button variant=\"secondary\" mini disabled={busy === m.id} onClick={() => void reschedule(m.id)}>Reschedule</Button>\n <Button variant=\"danger\" mini disabled={busy === m.id} onClick={() => void cancel(m.id)}>Cancel</Button>\n </span>\n ) : null}\n </li>\n ))}\n </ul>\n )}\n </div>\n );\n}\n\n/** Options for {@link meetingsSection}. */\nexport interface MeetingsSectionOptions {\n id?: string;\n label?: string;\n}\n\n/** Build the Meetings admin section: the reconciled agenda over\n * `GET /api/admin/meetings`, with reschedule/cancel actions. */\nexport function meetingsSection(options: MeetingsSectionOptions = {}): AdminSection {\n const { id = \"meetings\", label = \"Meetings\" } = options;\n return { id, label, render: (ctx) => <MeetingsBody ctx={ctx} /> };\n}\n"],"mappings":"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","names":["useEffect","useState","jsx","jsxs","useState","useEffect","useState","jsx","jsxs","useCallback","useEffect","useState","jsx","jsxs","jsx","jsxs","money","useState","jsx","jsxs","useState","useState","Button","jsx","jsxs","useState","Button"]}
|
package/dist/ui/admin/index.d.ts
CHANGED
|
@@ -60,12 +60,81 @@ interface PeopleSectionOptions {
|
|
|
60
60
|
/** The record type to list. Default "person". */
|
|
61
61
|
type?: string;
|
|
62
62
|
}
|
|
63
|
+
/** Options for {@link collectionSection}. */
|
|
64
|
+
interface CollectionSectionOptions {
|
|
65
|
+
/** The chapter's resolved CRM engine — `chapter.crm` from defineChapter. */
|
|
66
|
+
crm: Crm;
|
|
67
|
+
/** The CRM record type to list (e.g. `"person"`, `"deal"`, `"investment"`). */
|
|
68
|
+
type: string;
|
|
69
|
+
/** Section id / route segment. Defaults to `type`. */
|
|
70
|
+
id?: string;
|
|
71
|
+
/** Nav label. Defaults to the type's plural label from the CRM config. */
|
|
72
|
+
label?: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build a CRM collection admin section for ANY declared record type: a
|
|
76
|
+
* server-driven records table plus a facet-composed detail drawer (fields,
|
|
77
|
+
* pipeline stage, tags) over the token-bound CrmClient. This is the generic kit —
|
|
78
|
+
* call it once per collection a site runs (members, deals, investments), and each
|
|
79
|
+
* renders with the same UX. Every action goes through `/api/crm/*`, never a direct
|
|
80
|
+
* db write, so the CRM invariants hold.
|
|
81
|
+
*/
|
|
82
|
+
declare function collectionSection(options: CollectionSectionOptions): AdminSection;
|
|
63
83
|
/**
|
|
64
|
-
* Build the People/CRM admin section
|
|
65
|
-
*
|
|
66
|
-
* token-bound CrmClient. Drop the result into `ChapterAdmin`'s `sections`.
|
|
67
|
-
* (Notes/activity feed and saved-view navigation are noted follow-ups.)
|
|
84
|
+
* Build the People/CRM admin section — {@link collectionSection} preset to the
|
|
85
|
+
* `person` type. Kept as a convenience; new collections use `collectionSection`.
|
|
68
86
|
*/
|
|
69
87
|
declare function peopleSection(options: PeopleSectionOptions): AdminSection;
|
|
70
88
|
|
|
71
|
-
|
|
89
|
+
/** Options for {@link dashboardSection}. */
|
|
90
|
+
interface DashboardSectionOptions {
|
|
91
|
+
id?: string;
|
|
92
|
+
label?: string;
|
|
93
|
+
}
|
|
94
|
+
/** Build the Dashboard admin section over `GET /api/admin/dashboard`. Drop the
|
|
95
|
+
* result into `ChapterAdmin`'s `sections`. */
|
|
96
|
+
declare function dashboardSection(options?: DashboardSectionOptions): AdminSection;
|
|
97
|
+
|
|
98
|
+
/** Options for {@link billingSection}. */
|
|
99
|
+
interface BillingSectionOptions {
|
|
100
|
+
id?: string;
|
|
101
|
+
label?: string;
|
|
102
|
+
}
|
|
103
|
+
/** Build the Billing admin section over `GET /api/admin/billing`. Renders a
|
|
104
|
+
* "not configured" note when the group has no Stripe key vaulted. */
|
|
105
|
+
declare function billingSection(options?: BillingSectionOptions): AdminSection;
|
|
106
|
+
|
|
107
|
+
/** Options for {@link emailSection}. */
|
|
108
|
+
interface EmailSectionOptions {
|
|
109
|
+
id?: string;
|
|
110
|
+
label?: string;
|
|
111
|
+
}
|
|
112
|
+
/** Build the Email admin section: template + address editor over
|
|
113
|
+
* `GET/PUT /api/admin/group/email`, the send log, and per-template test sends. */
|
|
114
|
+
declare function emailSection(options?: EmailSectionOptions): AdminSection;
|
|
115
|
+
|
|
116
|
+
/** Options for {@link meetingsSection}. */
|
|
117
|
+
interface MeetingsSectionOptions {
|
|
118
|
+
id?: string;
|
|
119
|
+
label?: string;
|
|
120
|
+
}
|
|
121
|
+
/** Build the Meetings admin section: the reconciled agenda over
|
|
122
|
+
* `GET /api/admin/meetings`, with reschedule/cancel actions. */
|
|
123
|
+
declare function meetingsSection(options?: MeetingsSectionOptions): AdminSection;
|
|
124
|
+
|
|
125
|
+
/** Fetch a JSON `/api/admin/*` route with the admin bearer token. Throws on a
|
|
126
|
+
* non-2xx (the body's `error`, else the status). */
|
|
127
|
+
declare function adminFetch<T = unknown>(getToken: () => Promise<string | null>, path: string, init?: RequestInit): Promise<T>;
|
|
128
|
+
/** The state of one admin resource load. */
|
|
129
|
+
interface AdminResource<T> {
|
|
130
|
+
data: T | null;
|
|
131
|
+
loading: boolean;
|
|
132
|
+
error: string | null;
|
|
133
|
+
/** Re-fetch (e.g. after a mutation). */
|
|
134
|
+
refresh: () => void;
|
|
135
|
+
}
|
|
136
|
+
/** Load a `/api/admin/*` GET route into React state, re-fetching on `refresh()`
|
|
137
|
+
* or when `path` changes. */
|
|
138
|
+
declare function useAdminResource<T = unknown>(getToken: () => Promise<string | null>, path: string): AdminResource<T>;
|
|
139
|
+
|
|
140
|
+
export { type AdminResource, type AdminSection, type AdminSectionContext, type BillingSectionOptions, ChapterAdmin, type ChapterAdminProps, type CollectionSectionOptions, type DashboardSectionOptions, type EmailSectionOptions, type MeetingsSectionOptions, type PeopleSectionOptions, adminFetch, billingSection, collectionSection, dashboardSection, emailSection, meetingsSection, peopleSection, useAdminResource };
|
package/dist/ui/admin/index.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ChapterAdmin,
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
adminFetch,
|
|
4
|
+
billingSection,
|
|
5
|
+
collectionSection,
|
|
6
|
+
dashboardSection,
|
|
7
|
+
emailSection,
|
|
8
|
+
meetingsSection,
|
|
9
|
+
peopleSection,
|
|
10
|
+
useAdminResource
|
|
11
|
+
} from "../../chunk-HB2HUJCW.js";
|
|
5
12
|
export {
|
|
6
13
|
ChapterAdmin,
|
|
7
|
-
|
|
14
|
+
adminFetch,
|
|
15
|
+
billingSection,
|
|
16
|
+
collectionSection,
|
|
17
|
+
dashboardSection,
|
|
18
|
+
emailSection,
|
|
19
|
+
meetingsSection,
|
|
20
|
+
peopleSection,
|
|
21
|
+
useAdminResource
|
|
8
22
|
};
|
|
9
23
|
//# sourceMappingURL=index.js.map
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AdminSection, AdminSectionContext, ChapterAdmin, ChapterAdminProps, PeopleSectionOptions, peopleSection } from './admin/index.js';
|
|
1
|
+
export { AdminResource, AdminSection, AdminSectionContext, BillingSectionOptions, ChapterAdmin, ChapterAdminProps, CollectionSectionOptions, DashboardSectionOptions, EmailSectionOptions, MeetingsSectionOptions, PeopleSectionOptions, adminFetch, billingSection, collectionSection, dashboardSection, emailSection, meetingsSection, peopleSection, useAdminResource } from './admin/index.js';
|
|
2
2
|
export { ApiFn, BrandStyle, BrandStyleProps, JoinConfig, JoinIsland, JoinIslandProps, MembersArea, MembersAreaProps, PaymentStep, PaymentStepProps, RescheduleProps, Rescheduler, Slot, SlotPicker, SlotPickerClasses, SlotPickerProps, dayKey, dayLabel, fmtDate, fmtMoney, fullLabel, groupSlotsByDay, timeLabel, tzShort } from './member/index.js';
|
|
3
3
|
import 'react';
|
|
4
4
|
import '@odla-ai/crm';
|
package/dist/ui/index.js
CHANGED
|
@@ -16,8 +16,15 @@ import {
|
|
|
16
16
|
} from "../chunk-ZCZK6QLC.js";
|
|
17
17
|
import {
|
|
18
18
|
ChapterAdmin,
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
adminFetch,
|
|
20
|
+
billingSection,
|
|
21
|
+
collectionSection,
|
|
22
|
+
dashboardSection,
|
|
23
|
+
emailSection,
|
|
24
|
+
meetingsSection,
|
|
25
|
+
peopleSection,
|
|
26
|
+
useAdminResource
|
|
27
|
+
} from "../chunk-HB2HUJCW.js";
|
|
21
28
|
export {
|
|
22
29
|
BrandStyle,
|
|
23
30
|
ChapterAdmin,
|
|
@@ -26,14 +33,21 @@ export {
|
|
|
26
33
|
PaymentStep,
|
|
27
34
|
Rescheduler,
|
|
28
35
|
SlotPicker,
|
|
36
|
+
adminFetch,
|
|
37
|
+
billingSection,
|
|
38
|
+
collectionSection,
|
|
39
|
+
dashboardSection,
|
|
29
40
|
dayKey,
|
|
30
41
|
dayLabel,
|
|
42
|
+
emailSection,
|
|
31
43
|
fmtDate,
|
|
32
44
|
fmtMoney,
|
|
33
45
|
fullLabel,
|
|
34
46
|
groupSlotsByDay,
|
|
47
|
+
meetingsSection,
|
|
35
48
|
peopleSection,
|
|
36
49
|
timeLabel,
|
|
37
|
-
tzShort
|
|
50
|
+
tzShort,
|
|
51
|
+
useAdminResource
|
|
38
52
|
};
|
|
39
53
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odla-ai/chapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "A foundation for membership sites: one config (defineChapter) stands up a full member site (join, Stripe membership, Google booking, member area, admin, CRM) or an admin-only hub, on odla-db + Clerk + @odla-ai/crm + calendar + email.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://odla.ai/docs/packages/chapter",
|
package/dist/chunk-YO6DY5DL.js
DELETED
|
@@ -1,281 +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 peopleSection(options) {
|
|
273
|
-
const { crm, id = "people", label = "People", type = "person" } = options;
|
|
274
|
-
return { id, label, render: (ctx) => /* @__PURE__ */ jsx3(PeopleBody, { crm, client: ctx.client, type }) };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export {
|
|
278
|
-
ChapterAdmin,
|
|
279
|
-
peopleSection
|
|
280
|
-
};
|
|
281
|
-
//# sourceMappingURL=chunk-YO6DY5DL.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/ui/admin.tsx","../src/ui/chrome.tsx","../src/ui/admin-people.tsx"],"sourcesContent":["// ChapterAdmin — the whole admin console for a chapter/hub, owned by the package\n// so no site re-wires (or re-bugs) it: the Clerk publishable-key fetch\n// (/api/config), the themed sign-in with the post-sign-in redirect DERIVED from\n// basePath (so it never bounces to \"/\"), the odla-db admins-allowlist check\n// (/api/me), and a TopBar shell (chrome.tsx) that renders the caller's sections.\nimport { useEffect, useMemo, useState } from \"react\";\nimport { ClerkGate, SignedIn, SignedOut, SignIn, useClerkAuth, clerkAppearanceFromTokens } from \"@odla-ai/auth-clerk\";\nimport { CrmClient } from \"@odla-ai/crm\";\nimport { S, Gate, AdminShell } from \"./chrome.js\";\nimport type { AdminSection, Brand } from \"./chrome.js\";\n\nexport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\n/** Props for {@link ChapterAdmin}. */\nexport interface ChapterAdminProps {\n /** The admin sections to render (nav label + body). */\n sections: AdminSection[];\n /** Admin mount path. The sign-in redirect + section routing derive from it,\n * so the \"bounce to home after sign-in\" bug is impossible. Default \"/admin\". */\n basePath?: string;\n /** Wordmark shown in the gate + top bar. */\n brand?: Brand;\n /** CRM route mount. Default \"/api/crm\". */\n crmBasePath?: string;\n /** Origin for /api/config and /api/me. Default same origin. */\n apiBase?: string;\n}\n\n// After sign-in: build a token-bound CrmClient, confirm the account is an\n// allowlisted admin (/api/me), then render the shell.\nfunction Authed(props: {\n sections: AdminSection[];\n basePath: string;\n brand: Brand;\n crmBasePath: string;\n apiBase: string;\n}) {\n const { sections, basePath, brand, crmBasePath, apiBase } = props;\n const { getToken, signOut } = useClerkAuth();\n const client = useMemo(\n () =>\n new CrmClient({\n basePath: crmBasePath,\n headers: async (): Promise<Record<string, string>> => {\n const t = await getToken();\n return t ? { authorization: `Bearer ${t}` } : {};\n },\n }),\n [getToken, crmBasePath],\n );\n const [state, setState] = useState<{ status: \"checking\" | \"ok\" | \"denied\"; email?: string | null }>({\n status: \"checking\",\n });\n\n useEffect(() => {\n let live = true;\n void (async () => {\n try {\n const t = await getToken();\n const res = await fetch(`${apiBase}/api/me`, { headers: t ? { authorization: `Bearer ${t}` } : {} });\n const body = (await res.json().catch(() => ({}))) as { authorized?: boolean; email?: string | null };\n if (live) setState({ status: body.authorized ? \"ok\" : \"denied\", email: body.email ?? null });\n } catch {\n if (live) setState({ status: \"denied\" });\n }\n })();\n return () => {\n live = false;\n };\n }, [getToken, apiBase]);\n\n if (state.status === \"checking\") {\n return (\n <Gate brand={brand}>\n <p style={S.muted}>Checking access…</p>\n </Gate>\n );\n }\n if (state.status === \"denied\") {\n return (\n <Gate brand={brand}>\n <div className=\"card\" style={S.card}>\n <h2 style={{ marginTop: 0 }}>Not authorized</h2>\n <p style={S.muted}>\n {state.email ? `${state.email} isn't` : \"This account isn't\"} on the admin list. Ask an existing admin to add\n you in odla Studio.\n </p>\n <button className=\"btn secondary\" onClick={() => signOut()} style={{ marginTop: 12 }}>\n Sign out\n </button>\n </div>\n </Gate>\n );\n }\n return (\n <AdminShell\n sections={sections}\n basePath={basePath}\n brand={brand}\n client={client}\n getToken={getToken}\n signOut={signOut}\n email={state.email ?? null}\n />\n );\n}\n\n/**\n * The whole admin console for a chapter/hub: fetches the Clerk publishable key\n * (/api/config), gates on sign-in with the post-sign-in redirect derived from\n * `basePath`, checks the odla-db admins allowlist (/api/me), and renders a\n * TopBar shell over the caller's `sections`. The gate/shell/redirect are owned\n * here so no site re-wires (or re-bugs) them.\n */\nexport function ChapterAdmin(props: ChapterAdminProps) {\n const sections = props.sections;\n const basePath = props.basePath ?? \"/admin\";\n const brand = props.brand ?? { name: \"Admin\" };\n const crmBasePath = props.crmBasePath ?? \"/api/crm\";\n const apiBase = props.apiBase ?? \"\";\n\n const [pk, setPk] = useState<string | null | undefined>(undefined);\n useEffect(() => {\n let live = true;\n void (async () => {\n try {\n const res = await fetch(`${apiBase}/api/config`);\n const body = (await res.json()) as { clerkPublishableKey?: string | null };\n if (live) setPk(body.clerkPublishableKey ?? null);\n } catch {\n if (live) setPk(null);\n }\n })();\n return () => {\n live = false;\n };\n }, [apiBase]);\n\n if (pk === undefined) {\n return (\n <Gate brand={brand}>\n <p style={S.muted}>Loading…</p>\n </Gate>\n );\n }\n if (!pk) {\n return (\n <Gate brand={brand}>\n <div className=\"card\" style={S.card}>\n <h2 style={{ marginTop: 0 }}>Sign-in not configured</h2>\n <p style={S.muted}>No Clerk publishable key is set for this environment yet.</p>\n </div>\n </Gate>\n );\n }\n return (\n <ClerkGate publishableKey={pk} appearance={clerkAppearanceFromTokens()} afterSignOutUrl=\"/\">\n <SignedOut>\n <Gate brand={brand} tagline=\"Admin sign-in — invite only\">\n <SignIn routing=\"hash\" forceRedirectUrl={basePath} signUpForceRedirectUrl={basePath} />\n </Gate>\n </SignedOut>\n <SignedIn>\n <Authed sections={sections} basePath={basePath} brand={brand} crmBasePath={crmBasePath} apiBase={apiBase} />\n </SignedIn>\n </ClerkGate>\n );\n}\n","// Shared chrome for ChapterAdmin: the section contract, the centered gate\n// screen, the signed-in TopBar shell, and the inline style tokens. Split out of\n// admin.tsx to stay under the repo's per-file LOC cap. Uses only @odla-ai/ui\n// classes + --ui-* tokens, so a consumer needs nothing beyond the odla-ui sheet.\nimport { useCallback, useEffect, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { TopBar, TopBarLink } from \"@odla-ai/ui/components\";\nimport type { CrmClient } from \"@odla-ai/crm\";\n\n/** What each admin section receives. `client` is a CrmClient bound to the\n * signed-in admin's bearer token; `navigate` switches sections. */\nexport interface AdminSectionContext {\n client: CrmClient;\n getToken: () => Promise<string | null>;\n /** Switch to another section by id (e.g. Overview \"jump in\" buttons). */\n navigate: (sectionId: string) => void;\n}\n\n/** One admin-console section: a nav label and a body renderer. */\nexport interface AdminSection {\n id: string;\n label: string;\n render: (ctx: AdminSectionContext) => ReactNode;\n}\n\n/** Wordmark shown in the gate and top bar. `name` is the plain-text identity (and\n * the badge fallback); `wordmark` is an optional rich display node for brands\n * whose typography can't be a plain string (e.g. an ampersand wrapped to render\n * upright), and `badge` overrides the glyph. */\nexport type Brand = { name: string; badge?: string; wordmark?: ReactNode };\n\n/** The badge glyph: the explicit `badge`, else the first 3 letters of `name`. */\nexport const badgeText = (brand: Brand): string => brand.badge ?? brand.name.slice(0, 3).toUpperCase();\n\n/** The display node: the rich `wordmark` if given, else the plain `name`. */\nexport const brandDisplay = (brand: Brand): ReactNode => brand.wordmark ?? brand.name;\n\n/** Inline style tokens (keyed to --ui-* custom properties). */\nexport const S: Record<string, CSSProperties> = {\n gate: {\n minHeight: \"100vh\",\n display: \"grid\",\n placeItems: \"center\",\n padding: 24,\n background:\n \"radial-gradient(900px 480px at 50% -8%, var(--ui-accent-soft), transparent 70%),\" +\n \" radial-gradient(700px 500px at 110% 10%, var(--ui-good-soft), transparent 60%)\",\n },\n gateInner: { width: \"100%\", maxWidth: 400, display: \"flex\", flexDirection: \"column\", alignItems: \"center\" },\n brandBox: { textAlign: \"center\", marginBottom: 22 },\n badge: {\n width: 52,\n height: 52,\n margin: \"0 auto 14px\",\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 15,\n fontWeight: 700,\n color: \"var(--ui-on-accent)\",\n background: \"linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))\",\n borderRadius: 14,\n boxShadow: \"0 10px 28px var(--ui-accent-soft)\",\n },\n badgeSm: {\n width: 26,\n height: 26,\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 10,\n fontWeight: 700,\n color: \"var(--ui-on-accent)\",\n background: \"linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))\",\n borderRadius: 7,\n marginRight: 9,\n },\n h1: { margin: 0, fontSize: 28, letterSpacing: \"-0.02em\", fontWeight: 700 },\n muted: { color: \"var(--ui-text-muted)\" },\n role: {\n fontFamily: \"var(--ui-font-mono)\",\n fontSize: 11,\n textTransform: \"uppercase\",\n letterSpacing: \"0.04em\",\n padding: \"2px 8px\",\n borderRadius: 999,\n background: \"var(--ui-accent-soft)\",\n border: \"1px solid var(--ui-accent)\",\n color: \"var(--ui-accent-strong)\",\n },\n whoami: { display: \"flex\", alignItems: \"center\", gap: 8, fontSize: 12 },\n // Sections own their own width/padding (e.g. a .wrap container).\n main: { minHeight: \"calc(100vh - 61px)\" },\n card: { width: \"100%\", textAlign: \"center\" },\n};\n\n/** Centered gate screen used for loading / sign-in / denied / not-configured. */\nexport function Gate(props: { brand: Brand; tagline?: string; children: ReactNode }) {\n const { brand, tagline, children } = props;\n return (\n <div style={S.gate}>\n <div style={S.gateInner}>\n <div style={S.brandBox}>\n <div style={S.badge}>{badgeText(brand)}</div>\n <h1 style={S.h1}>{brandDisplay(brand)}</h1>\n {tagline ? <p style={{ ...S.muted, margin: \"8px 0 0\", fontSize: 14 }}>{tagline}</p> : null}\n </div>\n {children}\n </div>\n </div>\n );\n}\n\n/** The signed-in shell: a TopBar of the caller's sections + the active body. */\nexport function AdminShell(props: {\n sections: AdminSection[];\n basePath: string;\n brand: Brand;\n client: CrmClient;\n getToken: () => Promise<string | null>;\n signOut: () => void;\n email: string | null;\n}) {\n const { sections, basePath, brand, client, getToken, signOut, email } = props;\n\n const sectionFromPath = useCallback(() => {\n const fallback = sections[0]?.id ?? \"\";\n if (typeof window === \"undefined\") return fallback;\n const rest = window.location.pathname.slice(basePath.length).replace(/^\\//, \"\");\n const id = rest.split(\"/\")[0] ?? \"\";\n return sections.some((s) => s.id === id) ? id : fallback;\n }, [sections, basePath]);\n\n const [section, setSection] = useState(sectionFromPath);\n\n const go = useCallback(\n (id: string) => {\n window.history.pushState(null, \"\", `${basePath}/${id}`);\n setSection(id);\n },\n [basePath],\n );\n\n useEffect(() => {\n const onPop = () => setSection(sectionFromPath());\n window.addEventListener(\"popstate\", onPop);\n return () => window.removeEventListener(\"popstate\", onPop);\n }, [sectionFromPath]);\n\n const active = sections.find((s) => s.id === section) ?? sections[0];\n\n return (\n <>\n <TopBar>\n <a className=\"topbar-brand\" href=\"/\" style={{ display: \"inline-flex\", alignItems: \"center\" }}>\n <span style={S.badgeSm}>{badgeText(brand)}</span>\n {brandDisplay(brand)}\n </a>\n <nav className=\"topbar-nav\" aria-label=\"Admin navigation\">\n {sections.map((s) => (\n <TopBarLink key={s.id} active={section === s.id} onClick={() => go(s.id)}>\n {s.label}\n </TopBarLink>\n ))}\n </nav>\n <div className=\"topbar-spacer\" />\n <div className=\"topbar-actions\">\n <span style={S.whoami}>\n <span style={S.role}>admin</span>\n {email ? <span style={S.muted}>{email}</span> : null}\n </span>\n <button className=\"btn secondary mini\" onClick={() => signOut()}>\n Sign out\n </button>\n </div>\n </TopBar>\n <main className=\"shell-main\" style={S.main}>\n {active ? active.render({ client, getToken, navigate: go }) : null}\n </main>\n </>\n );\n}\n","// People/CRM admin section — the core admin workflow, assembled from the shipped\n// @odla-ai/crm/ui kit against the /api/crm/* routes the worker already mounts.\n// It's a FACTORY: peopleSection({ crm }) → an AdminSection you drop into\n// ChapterAdmin's `sections`. Every lifecycle action (edit fields, move pipeline\n// stage, tag) goes through the token-bound CrmClient — never a direct db write,\n// so the package's operational invariants hold (G7).\nimport { useState } from \"react\";\nimport { CrmList, RecordPanel, useCrmQuery, useCrmRecord } from \"@odla-ai/crm/ui\";\nimport type { Crm, CrmClient, CrmRecord } from \"@odla-ai/crm\";\nimport type { AdminSection } from \"./chrome.js\";\n\nfunction PeopleBody(props: { crm: Crm; client: CrmClient; type: string }) {\n const { crm, client, type } = props;\n // The crm/ui hooks type their client against @odla-ai/crm's ui-entry bundled\n // declaration; ctx.client is the core-entry declaration. Same runtime class,\n // but their private field makes the two .d.ts copies nominally distinct — cast\n // to the param type the hooks expect.\n const hookClient = client as unknown as Parameters<typeof useCrmQuery>[0];\n const query = useCrmQuery(hookClient, type);\n const [openId, setOpenId] = useState<string | null>(null);\n const record = useCrmRecord(hookClient, openId);\n const [saving, setSaving] = useState(false);\n\n const saveFields = async (input: Record<string, unknown>) => {\n if (!openId) return;\n setSaving(true);\n try {\n await client.updateRecord(openId, { input });\n record.refresh();\n query.refresh();\n } finally {\n setSaving(false);\n }\n };\n const moveStage = async (to: string) => {\n if (!openId) return;\n await client.setStage(openId, to);\n record.refresh();\n query.refresh();\n };\n const addTag = async (tag: string) => {\n if (openId) {\n await client.addTag(openId, tag);\n record.refresh();\n }\n };\n const removeTag = async (tag: string) => {\n if (openId) {\n await client.removeTag(openId, tag);\n record.refresh();\n }\n };\n\n return (\n <div className=\"wrap\">\n <CrmList crm={crm} type={type} query={query} onOpenRecord={(r: CrmRecord) => setOpenId(r.id)} />\n {record.detail ? (\n <RecordPanel\n crm={crm}\n detail={record.detail}\n onSaveFields={saveFields}\n onMoveStage={moveStage}\n onAddTag={addTag}\n onRemoveTag={removeTag}\n saving={saving}\n />\n ) : null}\n </div>\n );\n}\n\n/** Options for {@link peopleSection}. */\nexport interface PeopleSectionOptions {\n /** The chapter's resolved CRM engine — `chapter.crm` from defineChapter. */\n crm: Crm;\n /** Section id / route segment. Default \"people\". */\n id?: string;\n /** Nav label. Default \"People\". */\n label?: string;\n /** The record type to list. Default \"person\". */\n type?: string;\n}\n\n/**\n * Build the People/CRM admin section: a server-driven records table plus a\n * facet-composed detail drawer (fields, pipeline stage, tags) over the\n * token-bound CrmClient. Drop the result into `ChapterAdmin`'s `sections`.\n * (Notes/activity feed and saved-view navigation are noted follow-ups.)\n */\nexport function peopleSection(options: PeopleSectionOptions): AdminSection {\n const { crm, id = \"people\", label = \"People\", type = \"person\" } = options;\n return { id, label, render: (ctx) => <PeopleBody crm={crm} client={ctx.client} type={type} /> };\n}\n"],"mappings":"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","names":["useEffect","useState","jsx","jsxs","useState","useEffect","useState","jsx","jsxs"]}
|