@burdenoff/microfe-admin 2026.805.1 → 2026.829.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ import { useAdminPermissions as e } from "../hooks/useAdminPermissions.js";
2
+ import { Badge as t, EmptyState as n, Input as r } from "../components/ui.js";
3
+ import { PageLayout as ee } from "../components/PageLayout.js";
4
+ import { formatNumber as i, formatRelativeTime as a } from "../utils/formatters.js";
5
+ import { DataTable as o, Pagination as te } from "../components/DataTable.js";
6
+ import { PermissionGuard as s } from "../components/PermissionGuard.js";
7
+ import "../components/index.js";
8
+ import { useCallback as c, useMemo as l, useState as u } from "react";
9
+ import { MessageSquare as d, MousePointerClick as f, TriangleAlert as p } from "lucide-react";
10
+ import { jsx as m, jsxs as h } from "react/jsx-runtime";
11
+ import { EmphasisPanel as ne, PagePurpose as g } from "@burdenoff/fe-libs/ui";
12
+ import { gql as _ } from "@apollo/client";
13
+ import { useQuery as v } from "@apollo/client/react";
14
+ //#region src/admin/pages/ExploreConversationsPage.tsx
15
+ var y = 25, b = _`
16
+ query ExploreAdminStats($filter: ExploreAdminFilter) {
17
+ exploreAdminStats(filter: $filter) {
18
+ conversations
19
+ messages
20
+ unanswered
21
+ clicks
22
+ byProduct {
23
+ product
24
+ answerCount
25
+ clickCount
26
+ }
27
+ }
28
+ }
29
+ `, x = _`
30
+ query ExploreAdminConversations(
31
+ $filter: ExploreAdminFilter
32
+ $sort: ExploreAdminSort
33
+ $limit: Int
34
+ $offset: Int
35
+ ) {
36
+ exploreAdminConversations(filter: $filter, sort: $sort, limit: $limit, offset: $offset) {
37
+ totalCount
38
+ hasMore
39
+ items {
40
+ id
41
+ productSlug
42
+ ipHash
43
+ locale
44
+ turnCount
45
+ clickCount
46
+ hasUnanswered
47
+ createdAt
48
+ lastMessageAt
49
+ messages {
50
+ id
51
+ role
52
+ status
53
+ content
54
+ answered
55
+ createdAt
56
+ references {
57
+ url
58
+ title
59
+ product
60
+ }
61
+ ctas {
62
+ kind
63
+ label
64
+ url
65
+ }
66
+ clicks {
67
+ id
68
+ kind
69
+ url
70
+ product
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ `;
77
+ function S(e, t = !1) {
78
+ if (e) return (/* @__PURE__ */ new Date(`${e}T${t ? "23:59:59" : "00:00:00"}`)).toISOString();
79
+ }
80
+ function C() {
81
+ let { canViewExploreConversations: _, isLoading: C } = e(), [w, T] = u(1), [E, D] = u(""), [O, k] = u(""), [A, j] = u(""), [M, N] = u(""), [P, F] = u(""), [I, L] = u(!1), [R, z] = u(!1), [B, V] = u("NEWEST"), [H, U] = u(null), W = l(() => ({
82
+ productSlug: E || void 0,
83
+ ipHash: O || void 0,
84
+ from: S(A),
85
+ to: S(M, !0),
86
+ search: P || void 0,
87
+ unansweredOnly: I || void 0,
88
+ convertedOnly: R || void 0
89
+ }), [
90
+ E,
91
+ O,
92
+ A,
93
+ M,
94
+ P,
95
+ I,
96
+ R
97
+ ]), G = C || !_, { data: K } = v(b, {
98
+ variables: { filter: W },
99
+ fetchPolicy: "cache-and-network",
100
+ skip: G
101
+ }), { data: q, loading: J, error: Y } = v(x, {
102
+ variables: {
103
+ filter: W,
104
+ sort: B,
105
+ limit: y,
106
+ offset: (w - 1) * y
107
+ },
108
+ fetchPolicy: "cache-and-network",
109
+ skip: G
110
+ }), X = K?.exploreAdminStats, Z = q?.exploreAdminConversations?.items ?? [], Q = q?.exploreAdminConversations?.totalCount ?? 0, re = Math.ceil(Q / y), $ = c((e) => (t) => {
111
+ e(t), T(1);
112
+ }, []), ie = l(() => [
113
+ {
114
+ key: "product",
115
+ header: "Product",
116
+ width: "140px",
117
+ render: (e) => /* @__PURE__ */ m("span", {
118
+ className: "text-sm font-medium",
119
+ children: e.productSlug ?? "—"
120
+ })
121
+ },
122
+ {
123
+ key: "question",
124
+ header: "First question",
125
+ render: (e) => /* @__PURE__ */ m("span", {
126
+ className: "line-clamp-2 text-sm text-text-secondary",
127
+ children: e.messages.find((e) => e.role === "USER")?.content ?? "—"
128
+ })
129
+ },
130
+ {
131
+ key: "turns",
132
+ header: "Turns",
133
+ width: "80px",
134
+ align: "right",
135
+ render: (e) => i(e.turnCount)
136
+ },
137
+ {
138
+ key: "clicks",
139
+ header: "Clicks",
140
+ width: "90px",
141
+ align: "right",
142
+ render: (e) => e.clickCount > 0 ? /* @__PURE__ */ m(t, {
143
+ variant: "success",
144
+ children: e.clickCount
145
+ }) : /* @__PURE__ */ m("span", {
146
+ className: "text-text-tertiary",
147
+ children: "0"
148
+ })
149
+ },
150
+ {
151
+ key: "answered",
152
+ header: "Gaps",
153
+ width: "90px",
154
+ render: (e) => e.hasUnanswered ? /* @__PURE__ */ m(t, {
155
+ variant: "warning",
156
+ children: "unanswered"
157
+ }) : null
158
+ },
159
+ {
160
+ key: "ip",
161
+ header: "Visitor",
162
+ width: "110px",
163
+ render: (e) => /* @__PURE__ */ m("button", {
164
+ type: "button",
165
+ title: "Filter to this visitor's sessions",
166
+ className: "font-mono text-xs text-text-tertiary underline-offset-2 hover:underline",
167
+ onClick: (t) => {
168
+ t.stopPropagation(), k(e.ipHash), T(1);
169
+ },
170
+ children: e.ipHash.slice(0, 8)
171
+ })
172
+ },
173
+ {
174
+ key: "when",
175
+ header: "Started",
176
+ width: "130px",
177
+ render: (e) => /* @__PURE__ */ m("span", {
178
+ className: "text-sm text-text-tertiary",
179
+ children: a(e.createdAt)
180
+ })
181
+ }
182
+ ], []);
183
+ return /* @__PURE__ */ m(s, {
184
+ allowed: _,
185
+ isLoading: C,
186
+ pageTitle: "Explore Conversations",
187
+ denyMessage: "Explore conversations are restricted to super admins.",
188
+ children: /* @__PURE__ */ h(ee, {
189
+ title: "Explore Conversations",
190
+ gradientWord: "Conversations",
191
+ description: "Every question asked of the public /explore assistant, what it answered, and where it sent people.",
192
+ children: [
193
+ /* @__PURE__ */ m(g, { children: "Visitors are anonymous. The only handle here is a salted hash of their IP, which groups one visitor's sessions without exposing an address." }),
194
+ X ? /* @__PURE__ */ m(ne, { children: /* @__PURE__ */ h("div", {
195
+ className: "grid grid-cols-2 gap-4 sm:grid-cols-4",
196
+ children: [
197
+ /* @__PURE__ */ h("div", { children: [/* @__PURE__ */ m("p", {
198
+ className: "text-2xl font-semibold",
199
+ children: i(X.conversations)
200
+ }), /* @__PURE__ */ m("p", {
201
+ className: "text-xs text-text-tertiary",
202
+ children: "Conversations"
203
+ })] }),
204
+ /* @__PURE__ */ h("div", { children: [/* @__PURE__ */ m("p", {
205
+ className: "text-2xl font-semibold",
206
+ children: i(X.messages)
207
+ }), /* @__PURE__ */ m("p", {
208
+ className: "text-xs text-text-tertiary",
209
+ children: "Messages"
210
+ })] }),
211
+ /* @__PURE__ */ h("div", { children: [/* @__PURE__ */ m("p", {
212
+ className: "text-2xl font-semibold",
213
+ children: i(X.clicks)
214
+ }), /* @__PURE__ */ m("p", {
215
+ className: "text-xs text-text-tertiary",
216
+ children: "Links followed"
217
+ })] }),
218
+ /* @__PURE__ */ h("div", { children: [/* @__PURE__ */ m("p", {
219
+ className: "text-2xl font-semibold",
220
+ children: i(X.unanswered)
221
+ }), /* @__PURE__ */ m("p", {
222
+ className: "text-xs text-text-tertiary",
223
+ children: "Couldn't answer"
224
+ })] })
225
+ ]
226
+ }) }) : null,
227
+ X && X.byProduct.length > 0 ? /* @__PURE__ */ h("div", {
228
+ className: "rounded-xl border border-border-seam p-4",
229
+ children: [/* @__PURE__ */ m("p", {
230
+ className: "mb-3 text-sm font-semibold",
231
+ children: "Where the assistant sends people"
232
+ }), /* @__PURE__ */ m("div", {
233
+ className: "space-y-1.5",
234
+ children: X.byProduct.slice(0, 12).map((e) => /* @__PURE__ */ h("div", {
235
+ className: "flex items-center gap-3 text-sm",
236
+ children: [
237
+ /* @__PURE__ */ m("span", {
238
+ className: "w-40 shrink-0 truncate",
239
+ children: e.product
240
+ }),
241
+ /* @__PURE__ */ h("span", {
242
+ className: "text-text-tertiary",
243
+ children: [i(e.answerCount), " cited"]
244
+ }),
245
+ /* @__PURE__ */ m(f, { className: "h-3 w-3 text-text-tertiary" }),
246
+ /* @__PURE__ */ h("span", {
247
+ className: "text-text-tertiary",
248
+ children: [i(e.clickCount), " followed"]
249
+ }),
250
+ e.answerCount > 0 && e.clickCount === 0 ? /* @__PURE__ */ m(t, {
251
+ variant: "warning",
252
+ children: "no conversions"
253
+ }) : null
254
+ ]
255
+ }, e.product))
256
+ })]
257
+ }) : null,
258
+ /* @__PURE__ */ h("div", {
259
+ className: "grid gap-3 sm:grid-cols-2 lg:grid-cols-4",
260
+ children: [
261
+ /* @__PURE__ */ m(r, {
262
+ placeholder: "Search message text…",
263
+ value: P,
264
+ onChange: (e) => $(F)(e.target.value)
265
+ }),
266
+ /* @__PURE__ */ m(r, {
267
+ placeholder: "Product slug (e.g. vibecontrols)",
268
+ value: E,
269
+ onChange: (e) => $(D)(e.target.value)
270
+ }),
271
+ /* @__PURE__ */ m(r, {
272
+ type: "date",
273
+ "aria-label": "From date",
274
+ value: A,
275
+ onChange: (e) => $(j)(e.target.value)
276
+ }),
277
+ /* @__PURE__ */ m(r, {
278
+ type: "date",
279
+ "aria-label": "To date",
280
+ value: M,
281
+ onChange: (e) => $(N)(e.target.value)
282
+ })
283
+ ]
284
+ }),
285
+ /* @__PURE__ */ h("div", {
286
+ className: "flex flex-wrap items-center gap-3 text-sm",
287
+ children: [
288
+ /* @__PURE__ */ h("label", {
289
+ className: "flex items-center gap-1.5",
290
+ children: [
291
+ /* @__PURE__ */ m("input", {
292
+ type: "checkbox",
293
+ checked: I,
294
+ onChange: (e) => $(L)(e.target.checked)
295
+ }),
296
+ /* @__PURE__ */ m(p, { className: "h-3.5 w-3.5" }),
297
+ "Only unanswered"
298
+ ]
299
+ }),
300
+ /* @__PURE__ */ h("label", {
301
+ className: "flex items-center gap-1.5",
302
+ children: [
303
+ /* @__PURE__ */ m("input", {
304
+ type: "checkbox",
305
+ checked: R,
306
+ onChange: (e) => $(z)(e.target.checked)
307
+ }),
308
+ /* @__PURE__ */ m(f, { className: "h-3.5 w-3.5" }),
309
+ "Only converted"
310
+ ]
311
+ }),
312
+ /* @__PURE__ */ h("label", {
313
+ className: "flex items-center gap-1.5",
314
+ children: ["Sort", /* @__PURE__ */ h("select", {
315
+ className: "rounded-md border border-border-seam bg-bg-surface px-2 py-1",
316
+ value: B,
317
+ onChange: (e) => $(V)(e.target.value),
318
+ children: [
319
+ /* @__PURE__ */ m("option", {
320
+ value: "NEWEST",
321
+ children: "Newest"
322
+ }),
323
+ /* @__PURE__ */ m("option", {
324
+ value: "OLDEST",
325
+ children: "Oldest"
326
+ }),
327
+ /* @__PURE__ */ m("option", {
328
+ value: "MOST_TURNS",
329
+ children: "Most turns"
330
+ }),
331
+ /* @__PURE__ */ m("option", {
332
+ value: "MOST_CLICKS",
333
+ children: "Most clicks"
334
+ })
335
+ ]
336
+ })]
337
+ }),
338
+ O ? /* @__PURE__ */ h("button", {
339
+ type: "button",
340
+ className: "underline underline-offset-2",
341
+ onClick: () => $(k)(""),
342
+ children: [
343
+ "Clear visitor filter (",
344
+ O.slice(0, 8),
345
+ ")"
346
+ ]
347
+ }) : null
348
+ ]
349
+ }),
350
+ Y ? /* @__PURE__ */ m("div", {
351
+ className: "rounded-lg border border-status-error-border bg-status-error-bg p-3 text-sm text-status-error-text",
352
+ children: Y.message
353
+ }) : null,
354
+ /* @__PURE__ */ m(o, {
355
+ columns: ie,
356
+ data: Z,
357
+ keyExtractor: (e) => e.id,
358
+ loading: J,
359
+ onRowClick: (e) => U((t) => t === e.id ? null : e.id),
360
+ emptyState: /* @__PURE__ */ m(n, {
361
+ icon: /* @__PURE__ */ m(d, { className: "h-8 w-8" }),
362
+ title: "No conversations",
363
+ description: "Nothing matches these filters yet."
364
+ })
365
+ }),
366
+ H ? /* @__PURE__ */ h("div", {
367
+ className: "rounded-xl border border-border-seam p-4",
368
+ children: [/* @__PURE__ */ m("p", {
369
+ className: "mb-3 text-sm font-semibold",
370
+ children: "Transcript"
371
+ }), /* @__PURE__ */ m("div", {
372
+ className: "space-y-3",
373
+ children: (Z.find((e) => e.id === H)?.messages ?? []).map((e) => /* @__PURE__ */ h("div", {
374
+ className: "text-sm",
375
+ children: [
376
+ /* @__PURE__ */ h("div", {
377
+ className: "flex items-center gap-2",
378
+ children: [
379
+ /* @__PURE__ */ m(t, {
380
+ variant: e.role === "USER" ? "default" : "secondary",
381
+ children: e.role
382
+ }),
383
+ e.role === "ASSISTANT" && !e.answered ? /* @__PURE__ */ m(t, {
384
+ variant: "warning",
385
+ children: "couldn't answer"
386
+ }) : null,
387
+ /* @__PURE__ */ m("span", {
388
+ className: "text-xs text-text-tertiary",
389
+ children: a(e.createdAt)
390
+ })
391
+ ]
392
+ }),
393
+ /* @__PURE__ */ m("p", {
394
+ className: "mt-1 whitespace-pre-wrap break-words text-text-secondary",
395
+ children: e.content
396
+ }),
397
+ e.clicks.length > 0 ? /* @__PURE__ */ h("p", {
398
+ className: "mt-1 text-xs text-status-success-text",
399
+ children: ["Followed: ", e.clicks.map((e) => e.url).join(", ")]
400
+ }) : null
401
+ ]
402
+ }, e.id))
403
+ })]
404
+ }) : null,
405
+ Q > y ? /* @__PURE__ */ m(te, {
406
+ page: w,
407
+ totalPages: re,
408
+ totalItems: Q,
409
+ pageSize: y,
410
+ onPageChange: T
411
+ }) : null
412
+ ]
413
+ })
414
+ });
415
+ }
416
+ //#endregion
417
+ export { C as ExploreConversationsPage };
418
+
419
+ //# sourceMappingURL=ExploreConversationsPage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExploreConversationsPage.js","names":[],"sources":["../../../src/admin/pages/ExploreConversationsPage.tsx"],"sourcesContent":["import { useCallback, useMemo, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { MessageSquare, MousePointerClick, TriangleAlert } from 'lucide-react';\nimport { PagePurpose, EmphasisPanel } from '@burdenoff/fe-libs/ui';\n\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n EmptyState,\n Badge,\n PermissionGuard,\n} from '../components';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport { formatRelativeTime, formatNumber } from '../utils/formatters';\n\nconst PAGE_SIZE = 25;\n\n/**\n * Operations are declared inline rather than in `src/operations/global/*.graphql`.\n *\n * Codegen runs in CI against the COMMITTED `graphql/global.graphql` snapshot, and that\n * snapshot can only be refreshed by `schema:pull` against a local gateway with the new\n * subgraph composed. Putting these in an operations file would therefore fail CI codegen\n * until the schema has propagated to the dev Hive target. Inline documents are not scanned\n * by codegen, so the page builds today and can be migrated to generated hooks — with no\n * change to the queries themselves — once the snapshot catches up.\n */\nconst EXPLORE_ADMIN_STATS = gql`\n query ExploreAdminStats($filter: ExploreAdminFilter) {\n exploreAdminStats(filter: $filter) {\n conversations\n messages\n unanswered\n clicks\n byProduct {\n product\n answerCount\n clickCount\n }\n }\n }\n`;\n\nconst EXPLORE_ADMIN_CONVERSATIONS = gql`\n query ExploreAdminConversations(\n $filter: ExploreAdminFilter\n $sort: ExploreAdminSort\n $limit: Int\n $offset: Int\n ) {\n exploreAdminConversations(filter: $filter, sort: $sort, limit: $limit, offset: $offset) {\n totalCount\n hasMore\n items {\n id\n productSlug\n ipHash\n locale\n turnCount\n clickCount\n hasUnanswered\n createdAt\n lastMessageAt\n messages {\n id\n role\n status\n content\n answered\n createdAt\n references {\n url\n title\n product\n }\n ctas {\n kind\n label\n url\n }\n clicks {\n id\n kind\n url\n product\n }\n }\n }\n }\n }\n`;\n\ntype SortOption = 'NEWEST' | 'OLDEST' | 'MOST_TURNS' | 'MOST_CLICKS';\n\ninterface AdminClick {\n id: string;\n kind: string;\n url: string;\n product?: string | null;\n}\n\ninterface AdminMessage {\n id: string;\n role: string;\n status: string;\n content: string;\n answered: boolean;\n createdAt: string;\n references: Array<{ url: string; title?: string | null; product?: string | null }>;\n ctas: Array<{ kind: string; label: string; url: string }>;\n clicks: AdminClick[];\n}\n\ninterface AdminProductStat {\n product: string;\n answerCount: number;\n clickCount: number;\n}\n\ninterface AdminStats {\n conversations: number;\n messages: number;\n unanswered: number;\n clicks: number;\n byProduct: AdminProductStat[];\n}\n\ninterface AdminConversation {\n id: string;\n productSlug?: string | null;\n ipHash: string;\n locale?: string | null;\n turnCount: number;\n clickCount: number;\n hasUnanswered: boolean;\n createdAt: string;\n lastMessageAt?: string | null;\n messages: AdminMessage[];\n}\n\n/**\n * Result shapes for the inline documents above.\n *\n * Hand-written because these operations are deliberately not run through codegen (see the\n * note on the documents). They mirror the schema exactly; when the snapshot catches up and\n * the queries move to generated hooks, these go away.\n */\ninterface StatsResult {\n exploreAdminStats: AdminStats;\n}\n\ninterface ConversationsResult {\n exploreAdminConversations: {\n totalCount: number;\n hasMore: boolean;\n items: AdminConversation[];\n };\n}\n\n/** `datetime-local`-free: the fleet has no date picker, so two native date inputs it is. */\nfunction toDate(value: string, endOfDay = false): string | undefined {\n if (!value) return undefined;\n return new Date(`${value}T${endOfDay ? '23:59:59' : '00:00:00'}`).toISOString();\n}\n\n/**\n * Explore conversations — the superadmin view of the public `/explore` AI chat.\n *\n * Answers three questions the product could not answer before: what are visitors asking,\n * which products is the assistant routing them to, and which questions are we failing to\n * answer at all.\n *\n * Visitors are anonymous and stay that way. The only handle shown is the salted `ipHash`\n * already stored server-side, which groups one visitor's sessions without this console ever\n * handling an IP address.\n */\nexport function ExploreConversationsPage() {\n const { canViewExploreConversations, isLoading: permissionsLoading } = useAdminPermissions();\n\n const [page, setPage] = useState(1);\n const [productSlug, setProductSlug] = useState('');\n const [ipHash, setIpHash] = useState('');\n const [from, setFrom] = useState('');\n const [to, setTo] = useState('');\n const [search, setSearch] = useState('');\n const [unansweredOnly, setUnansweredOnly] = useState(false);\n const [convertedOnly, setConvertedOnly] = useState(false);\n const [sort, setSort] = useState<SortOption>('NEWEST');\n const [expanded, setExpanded] = useState<string | null>(null);\n\n const filter = useMemo(\n () => ({\n productSlug: productSlug || undefined,\n ipHash: ipHash || undefined,\n from: toDate(from),\n to: toDate(to, true),\n search: search || undefined,\n unansweredOnly: unansweredOnly || undefined,\n convertedOnly: convertedOnly || undefined,\n }),\n [productSlug, ipHash, from, to, search, unansweredOnly, convertedOnly]\n );\n\n // The whole app sits behind AdminGuard, which is an @burdenoff.com / ADMIN gate rather\n // than a super_admin one — and it is client-side. The real boundary is the\n // `@rbac(actorTypes: [\"super_admin\"])` on these fields; this check only avoids showing a\n // page that would come back FORBIDDEN.\n const skip = permissionsLoading || !canViewExploreConversations;\n\n const { data: statsData } = useQuery<StatsResult>(EXPLORE_ADMIN_STATS, {\n variables: { filter },\n fetchPolicy: 'cache-and-network',\n skip,\n });\n\n const { data, loading, error } = useQuery<ConversationsResult>(EXPLORE_ADMIN_CONVERSATIONS, {\n variables: { filter, sort, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },\n fetchPolicy: 'cache-and-network',\n skip,\n });\n\n const stats = statsData?.exploreAdminStats;\n const conversations: AdminConversation[] = data?.exploreAdminConversations?.items ?? [];\n const total: number = data?.exploreAdminConversations?.totalCount ?? 0;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const resetToFirstPage = useCallback(<T,>(setter: (value: T) => void) => {\n return (value: T) => {\n setter(value);\n setPage(1);\n };\n }, []);\n\n const columns = useMemo(\n () => [\n {\n key: 'product',\n header: 'Product',\n width: '140px',\n render: (row: AdminConversation) => (\n <span className=\"text-sm font-medium\">{row.productSlug ?? '—'}</span>\n ),\n },\n {\n key: 'question',\n header: 'First question',\n render: (row: AdminConversation) => {\n const first = row.messages.find((message) => message.role === 'USER');\n return (\n <span className=\"line-clamp-2 text-sm text-text-secondary\">\n {first?.content ?? '—'}\n </span>\n );\n },\n },\n {\n key: 'turns',\n header: 'Turns',\n width: '80px',\n align: 'right' as const,\n render: (row: AdminConversation) => formatNumber(row.turnCount),\n },\n {\n key: 'clicks',\n header: 'Clicks',\n width: '90px',\n align: 'right' as const,\n render: (row: AdminConversation) =>\n row.clickCount > 0 ? (\n <Badge variant=\"success\">{row.clickCount}</Badge>\n ) : (\n <span className=\"text-text-tertiary\">0</span>\n ),\n },\n {\n key: 'answered',\n header: 'Gaps',\n width: '90px',\n render: (row: AdminConversation) =>\n row.hasUnanswered ? <Badge variant=\"warning\">unanswered</Badge> : null,\n },\n {\n key: 'ip',\n header: 'Visitor',\n width: '110px',\n render: (row: AdminConversation) => (\n <button\n type=\"button\"\n title=\"Filter to this visitor's sessions\"\n className=\"font-mono text-xs text-text-tertiary underline-offset-2 hover:underline\"\n onClick={(event) => {\n event.stopPropagation();\n setIpHash(row.ipHash);\n setPage(1);\n }}\n >\n {row.ipHash.slice(0, 8)}\n </button>\n ),\n },\n {\n key: 'when',\n header: 'Started',\n width: '130px',\n render: (row: AdminConversation) => (\n <span className=\"text-sm text-text-tertiary\">{formatRelativeTime(row.createdAt)}</span>\n ),\n },\n ],\n []\n );\n\n return (\n <PermissionGuard\n allowed={canViewExploreConversations}\n isLoading={permissionsLoading}\n pageTitle=\"Explore Conversations\"\n denyMessage=\"Explore conversations are restricted to super admins.\"\n >\n <PageLayout\n title=\"Explore Conversations\"\n gradientWord=\"Conversations\"\n description=\"Every question asked of the public /explore assistant, what it answered, and where it sent people.\"\n >\n <PagePurpose>\n Visitors are anonymous. The only handle here is a salted hash of their IP, which groups\n one visitor&apos;s sessions without exposing an address.\n </PagePurpose>\n\n {stats ? (\n <EmphasisPanel>\n <div className=\"grid grid-cols-2 gap-4 sm:grid-cols-4\">\n <div>\n <p className=\"text-2xl font-semibold\">{formatNumber(stats.conversations)}</p>\n <p className=\"text-xs text-text-tertiary\">Conversations</p>\n </div>\n <div>\n <p className=\"text-2xl font-semibold\">{formatNumber(stats.messages)}</p>\n <p className=\"text-xs text-text-tertiary\">Messages</p>\n </div>\n <div>\n <p className=\"text-2xl font-semibold\">{formatNumber(stats.clicks)}</p>\n <p className=\"text-xs text-text-tertiary\">Links followed</p>\n </div>\n <div>\n <p className=\"text-2xl font-semibold\">{formatNumber(stats.unanswered)}</p>\n <p className=\"text-xs text-text-tertiary\">Couldn&apos;t answer</p>\n </div>\n </div>\n </EmphasisPanel>\n ) : null}\n\n {stats && stats.byProduct.length > 0 ? (\n <div className=\"rounded-xl border border-border-seam p-4\">\n <p className=\"mb-3 text-sm font-semibold\">Where the assistant sends people</p>\n <div className=\"space-y-1.5\">\n {stats.byProduct.slice(0, 12).map((row: AdminProductStat) => (\n <div key={row.product} className=\"flex items-center gap-3 text-sm\">\n <span className=\"w-40 shrink-0 truncate\">{row.product}</span>\n <span className=\"text-text-tertiary\">{formatNumber(row.answerCount)} cited</span>\n <MousePointerClick className=\"h-3 w-3 text-text-tertiary\" />\n <span className=\"text-text-tertiary\">\n {formatNumber(row.clickCount)} followed\n </span>\n {row.answerCount > 0 && row.clickCount === 0 ? (\n <Badge variant=\"warning\">no conversions</Badge>\n ) : null}\n </div>\n ))}\n </div>\n </div>\n ) : null}\n\n {/* Filters */}\n <div className=\"grid gap-3 sm:grid-cols-2 lg:grid-cols-4\">\n <Input\n placeholder=\"Search message text…\"\n value={search}\n onChange={(event) => resetToFirstPage(setSearch)(event.target.value)}\n />\n <Input\n placeholder=\"Product slug (e.g. vibecontrols)\"\n value={productSlug}\n onChange={(event) => resetToFirstPage(setProductSlug)(event.target.value)}\n />\n <Input\n type=\"date\"\n aria-label=\"From date\"\n value={from}\n onChange={(event) => resetToFirstPage(setFrom)(event.target.value)}\n />\n <Input\n type=\"date\"\n aria-label=\"To date\"\n value={to}\n onChange={(event) => resetToFirstPage(setTo)(event.target.value)}\n />\n </div>\n\n <div className=\"flex flex-wrap items-center gap-3 text-sm\">\n <label className=\"flex items-center gap-1.5\">\n <input\n type=\"checkbox\"\n checked={unansweredOnly}\n onChange={(event) => resetToFirstPage(setUnansweredOnly)(event.target.checked)}\n />\n <TriangleAlert className=\"h-3.5 w-3.5\" />\n Only unanswered\n </label>\n <label className=\"flex items-center gap-1.5\">\n <input\n type=\"checkbox\"\n checked={convertedOnly}\n onChange={(event) => resetToFirstPage(setConvertedOnly)(event.target.checked)}\n />\n <MousePointerClick className=\"h-3.5 w-3.5\" />\n Only converted\n </label>\n <label className=\"flex items-center gap-1.5\">\n Sort\n <select\n className=\"rounded-md border border-border-seam bg-bg-surface px-2 py-1\"\n value={sort}\n onChange={(event) => resetToFirstPage(setSort)(event.target.value as SortOption)}\n >\n <option value=\"NEWEST\">Newest</option>\n <option value=\"OLDEST\">Oldest</option>\n <option value=\"MOST_TURNS\">Most turns</option>\n <option value=\"MOST_CLICKS\">Most clicks</option>\n </select>\n </label>\n {ipHash ? (\n <button\n type=\"button\"\n className=\"underline underline-offset-2\"\n onClick={() => resetToFirstPage(setIpHash)('')}\n >\n Clear visitor filter ({ipHash.slice(0, 8)})\n </button>\n ) : null}\n </div>\n\n {error ? (\n <div className=\"rounded-lg border border-status-error-border bg-status-error-bg p-3 text-sm text-status-error-text\">\n {error.message}\n </div>\n ) : null}\n\n <DataTable\n columns={columns}\n data={conversations}\n keyExtractor={(row: AdminConversation) => row.id}\n loading={loading}\n onRowClick={(row: AdminConversation) =>\n setExpanded((current) => (current === row.id ? null : row.id))\n }\n emptyState={\n <EmptyState\n icon={<MessageSquare className=\"h-8 w-8\" />}\n title=\"No conversations\"\n description=\"Nothing matches these filters yet.\"\n />\n }\n />\n\n {expanded ? (\n <div className=\"rounded-xl border border-border-seam p-4\">\n <p className=\"mb-3 text-sm font-semibold\">Transcript</p>\n <div className=\"space-y-3\">\n {(conversations.find((row) => row.id === expanded)?.messages ?? []).map((message) => (\n <div key={message.id} className=\"text-sm\">\n <div className=\"flex items-center gap-2\">\n <Badge variant={message.role === 'USER' ? 'default' : 'secondary'}>\n {message.role}\n </Badge>\n {message.role === 'ASSISTANT' && !message.answered ? (\n <Badge variant=\"warning\">couldn&apos;t answer</Badge>\n ) : null}\n <span className=\"text-xs text-text-tertiary\">\n {formatRelativeTime(message.createdAt)}\n </span>\n </div>\n <p className=\"mt-1 whitespace-pre-wrap break-words text-text-secondary\">\n {message.content}\n </p>\n {message.clicks.length > 0 ? (\n <p className=\"mt-1 text-xs text-status-success-text\">\n Followed: {message.clicks.map((click) => click.url).join(', ')}\n </p>\n ) : null}\n </div>\n ))}\n </div>\n </div>\n ) : null}\n\n {total > PAGE_SIZE ? (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n ) : null}\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}