@burdenoff/microfe-hookmovies 2026.726.3 → 2026.726.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConciergePage.d.ts","sourceRoot":"","sources":["../../../../src/hookmovies/pages/ai/ConciergePage.tsx"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ConciergePage.d.ts","sourceRoot":"","sources":["../../../../src/hookmovies/pages/ai/ConciergePage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EACL,KAAK,EAAE,EAQR,MAAM,OAAO,CAAC;AAoXf,eAAO,MAAM,aAAa,EAAE,EA6P3B,CAAC"}
|
|
@@ -4,317 +4,327 @@ import { PageLayout as n } from "../../components/PageLayout.js";
|
|
|
4
4
|
import { useTitles as r } from "../../hooks/useHookMoviesApi.js";
|
|
5
5
|
import { useFileUrl as i } from "../../hooks/useFiles.js";
|
|
6
6
|
import { formatEnumLabel as a } from "../../utils/formatters.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { useSandboxAssistantTransport as o } from "../../../services/createSandboxAssistantTransport.js";
|
|
8
|
+
import { useCallback as s, useEffect as c, useMemo as l, useRef as u, useState as d } from "react";
|
|
9
|
+
import { AlertCircle as f, Film as p, Info as m, MessageSquareText as h, RotateCcw as g, Send as _, Sparkles as v, Wand2 as y } from "lucide-react";
|
|
10
|
+
import { jsx as b, jsxs as x } from "react/jsx-runtime";
|
|
11
|
+
import { GlassCard as S } from "@burdenoff/fe-libs/ui";
|
|
12
|
+
import { isQuotaExhaustedError as C } from "@burdenoff/fe-libs/shared/assistant";
|
|
11
13
|
//#region src/hookmovies/pages/ai/ConciergePage.tsx
|
|
12
|
-
var
|
|
14
|
+
var w = {
|
|
13
15
|
limit: 8,
|
|
14
16
|
offset: 0
|
|
15
|
-
},
|
|
16
|
-
function
|
|
17
|
-
let
|
|
18
|
-
if (r) return {
|
|
19
|
-
text: "I hit a snag reaching the catalog just now, so I couldn't pull recommendations for that. Give it another try in a moment.",
|
|
20
|
-
note: w
|
|
21
|
-
};
|
|
22
|
-
let i = t.trim().toLowerCase(), a = /^(hi|hey|hello|yo|namaste|hola|good (morning|evening|afternoon))\b/.test(i);
|
|
23
|
-
if (n.length === 0) return a ? {
|
|
24
|
-
text: "Hi! I'm your watch-concierge. Tell me a mood, a genre, or a title you loved and I'll surface something from the catalog to match.",
|
|
25
|
-
note: w
|
|
26
|
-
} : {
|
|
27
|
-
text: `I couldn't find anything published that matches “${t.trim()}” yet. Try a broader mood — like “something funny”, “a gripping thriller”, or “short films” — and I'll look again.`,
|
|
28
|
-
note: w
|
|
29
|
-
};
|
|
30
|
-
let o = n.length, s = o === 1 ? "title" : "titles", c = [
|
|
31
|
-
`Great pick of a mood. Based on what's published in your catalog, here ${o === 1 ? "is" : "are"} ${o} ${s} I think you'll enjoy:`,
|
|
32
|
-
`Say no more — I pulled ${o} real ${s} from the published catalog that fit that vibe:`,
|
|
33
|
-
`Here's what's streaming that lines up with that. ${o} ${s} worth a look:`,
|
|
34
|
-
`On it. These ${o} ${s} from your catalog match what you're after:`
|
|
35
|
-
];
|
|
36
|
-
return {
|
|
37
|
-
text: `${c[i.length % c.length]} tap any poster to dive in.`,
|
|
38
|
-
note: w
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
var E = 0, D = () => (E += 1, `turn-${E}-${Date.now().toString(36)}`), O = new Set(/* @__PURE__ */ "a.an.the.i.me.my.want.wanna.like.love.watch.watching.something.anything.show.me.find.recommend.suggest.give.please.tonight.today.now.for.to.of.and.or.with.about.some.any.good.great.best.can.you.is.are.what.whats.that.this.in.on.up.get.got.im.looking.mood".split("."));
|
|
42
|
-
function k(e) {
|
|
43
|
-
let t = e.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean).filter((e) => !O.has(e) && e.length > 1).slice(0, 6).join(" ").trim();
|
|
17
|
+
}, T = 0, E = () => (T += 1, `turn-${T}-${Date.now().toString(36)}`), D = new Set(/* @__PURE__ */ "a.an.the.i.me.my.want.wanna.like.love.watch.watching.something.anything.show.me.find.recommend.suggest.give.please.tonight.today.now.for.to.of.and.or.with.about.some.any.good.great.best.can.you.is.are.what.whats.that.this.in.on.up.get.got.im.looking.mood".split("."));
|
|
18
|
+
function O(e) {
|
|
19
|
+
let t = e.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean).filter((e) => !D.has(e) && e.length > 1).slice(0, 6).join(" ").trim();
|
|
44
20
|
return t.length > 0 ? t : void 0;
|
|
45
21
|
}
|
|
46
|
-
var
|
|
22
|
+
var k = [
|
|
47
23
|
"Recommend a feel-good movie for tonight",
|
|
48
24
|
"What documentaries are worth watching?",
|
|
49
25
|
"Show me something with high drama",
|
|
50
26
|
"I want a short film under 30 minutes",
|
|
51
27
|
"Surprise me with anime"
|
|
52
|
-
],
|
|
28
|
+
], A = ({ size: e = "sm", active: n = !1, className: r }) => {
|
|
53
29
|
let i = e === "lg" ? "h-16 w-16" : "h-8 w-8", a = e === "lg" ? "h-7 w-7" : "h-4 w-4";
|
|
54
|
-
return /* @__PURE__ */
|
|
30
|
+
return /* @__PURE__ */ x("span", {
|
|
55
31
|
className: t("relative inline-flex shrink-0 items-center justify-center", i, r),
|
|
56
32
|
"aria-hidden": "true",
|
|
57
|
-
children: [/* @__PURE__ */
|
|
33
|
+
children: [/* @__PURE__ */ b("span", { className: t("absolute inset-0 rounded-full bg-action-primary-bg opacity-30 blur-md", n && "motion-safe:animate-ping") }), /* @__PURE__ */ b("span", {
|
|
58
34
|
className: t("relative inline-flex items-center justify-center rounded-full", "bg-action-primary-bg text-action-primary-text shadow-elevation-2", i),
|
|
59
|
-
children: /* @__PURE__ */
|
|
35
|
+
children: /* @__PURE__ */ b(v, { className: a })
|
|
60
36
|
})]
|
|
61
37
|
});
|
|
62
|
-
},
|
|
63
|
-
let { data: r, isLoading: o } = i(e.posterFileId), [s, c] =
|
|
64
|
-
return /* @__PURE__ */
|
|
38
|
+
}, j = ({ title: e, onOpen: n }) => {
|
|
39
|
+
let { data: r, isLoading: o } = i(e.posterFileId), [s, c] = d(!1), l = !!e.posterFileId && !!r && !s, u = [a(e.type), e.releaseYear ? String(e.releaseYear) : null].filter(Boolean).join(" · ");
|
|
40
|
+
return /* @__PURE__ */ x("button", {
|
|
65
41
|
type: "button",
|
|
66
42
|
onClick: () => n(e.id),
|
|
67
43
|
"aria-label": `Open ${e.title}`,
|
|
68
44
|
className: "group w-28 shrink-0 snap-start text-left focus:outline-none sm:w-32",
|
|
69
45
|
children: [
|
|
70
|
-
/* @__PURE__ */
|
|
46
|
+
/* @__PURE__ */ x("div", {
|
|
71
47
|
className: t("relative aspect-[2/3] overflow-hidden rounded-lg border border-border-seam bg-bg-sunken", "shadow-elevation-1 transition-[border-color,box-shadow] duration-200", "group-hover:border-border-strong group-hover:shadow-elevation-2", "group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)]"),
|
|
72
|
-
children: [l ? /* @__PURE__ */
|
|
48
|
+
children: [l ? /* @__PURE__ */ b("img", {
|
|
73
49
|
src: r ?? void 0,
|
|
74
50
|
alt: `${e.title} poster`,
|
|
75
51
|
loading: "lazy",
|
|
76
52
|
onError: () => c(!0),
|
|
77
53
|
className: "h-full w-full object-cover"
|
|
78
|
-
}) : /* @__PURE__ */
|
|
54
|
+
}) : /* @__PURE__ */ b("div", {
|
|
79
55
|
className: t("flex h-full w-full items-center justify-center text-text-muted", o && !!e.posterFileId && "motion-safe:animate-pulse"),
|
|
80
56
|
"aria-hidden": "true",
|
|
81
|
-
children: /* @__PURE__ */
|
|
82
|
-
}), e.aiDisclosure && /* @__PURE__ */
|
|
57
|
+
children: /* @__PURE__ */ b(p, { className: "h-6 w-6" })
|
|
58
|
+
}), e.aiDisclosure && /* @__PURE__ */ x("span", {
|
|
83
59
|
className: "absolute right-1.5 top-1.5 inline-flex items-center gap-1 rounded-full bg-bg-canvas/80 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary backdrop-blur-sm",
|
|
84
60
|
title: "Contains AI-generated or AI-altered content",
|
|
85
|
-
children: [/* @__PURE__ */
|
|
61
|
+
children: [/* @__PURE__ */ b(v, { className: "h-2.5 w-2.5" }), "AI"]
|
|
86
62
|
})]
|
|
87
63
|
}),
|
|
88
|
-
/* @__PURE__ */
|
|
64
|
+
/* @__PURE__ */ b("p", {
|
|
89
65
|
className: "mt-1.5 line-clamp-1 text-xs font-medium text-text-primary",
|
|
90
66
|
children: e.title
|
|
91
67
|
}),
|
|
92
|
-
|
|
68
|
+
u && /* @__PURE__ */ b("p", {
|
|
93
69
|
className: "line-clamp-1 text-[11px] text-text-secondary",
|
|
94
|
-
children:
|
|
70
|
+
children: u
|
|
95
71
|
})
|
|
96
72
|
]
|
|
97
73
|
});
|
|
98
|
-
},
|
|
74
|
+
}, M = ({ message: e, onOpenTitle: n }) => e.role === "user" ? /* @__PURE__ */ b("div", {
|
|
99
75
|
className: "flex justify-end",
|
|
100
|
-
children: /* @__PURE__ */
|
|
76
|
+
children: /* @__PURE__ */ b("div", {
|
|
101
77
|
className: "max-w-[85%] rounded-2xl rounded-br-sm bg-action-primary-bg px-4 py-2.5 text-sm text-action-primary-text shadow-elevation-1 sm:max-w-[75%]",
|
|
102
|
-
children: /* @__PURE__ */
|
|
78
|
+
children: /* @__PURE__ */ b("p", {
|
|
103
79
|
className: "whitespace-pre-wrap break-words",
|
|
104
80
|
children: e.text
|
|
105
81
|
})
|
|
106
82
|
})
|
|
107
|
-
}) : /* @__PURE__ */
|
|
83
|
+
}) : /* @__PURE__ */ x("div", {
|
|
108
84
|
className: "flex items-start gap-3",
|
|
109
|
-
children: [/* @__PURE__ */
|
|
85
|
+
children: [/* @__PURE__ */ b(A, { size: "sm" }), /* @__PURE__ */ x("div", {
|
|
110
86
|
className: "min-w-0 max-w-[85%] sm:max-w-[80%]",
|
|
111
|
-
children: [/* @__PURE__ */
|
|
87
|
+
children: [/* @__PURE__ */ x("div", {
|
|
112
88
|
className: "rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-2.5 shadow-elevation-1",
|
|
113
|
-
children: [/* @__PURE__ */
|
|
89
|
+
children: [/* @__PURE__ */ b("p", {
|
|
114
90
|
className: "whitespace-pre-wrap break-words text-sm text-text-primary",
|
|
115
91
|
children: e.text
|
|
116
|
-
}), e.
|
|
92
|
+
}), e.failed ? /* @__PURE__ */ x("p", {
|
|
93
|
+
className: "mt-1.5 flex items-center gap-1 text-[11px] text-status-warning-text",
|
|
94
|
+
children: [/* @__PURE__ */ b(f, { className: "h-3 w-3" }), "Couldn't complete this turn"]
|
|
95
|
+
}) : e.note && /* @__PURE__ */ x("p", {
|
|
117
96
|
className: "mt-1.5 flex items-center gap-1 text-[11px] text-text-muted",
|
|
118
|
-
children: [/* @__PURE__ */
|
|
97
|
+
children: [/* @__PURE__ */ b(m, { className: "h-3 w-3" }), e.note]
|
|
119
98
|
})]
|
|
120
|
-
}), e.titles && e.titles.length > 0 && /* @__PURE__ */
|
|
99
|
+
}), e.titles && e.titles.length > 0 && /* @__PURE__ */ b("div", {
|
|
121
100
|
className: t("mt-3 flex gap-3 overflow-x-auto pb-2", "snap-x [scrollbar-width:thin]"),
|
|
122
|
-
children: e.titles.map((e) => /* @__PURE__ */
|
|
101
|
+
children: e.titles.map((e) => /* @__PURE__ */ b(j, {
|
|
123
102
|
title: e,
|
|
124
103
|
onOpen: n
|
|
125
104
|
}, e.id))
|
|
126
105
|
})]
|
|
127
106
|
})]
|
|
128
|
-
}),
|
|
107
|
+
}), N = () => /* @__PURE__ */ x("div", {
|
|
129
108
|
className: "flex items-start gap-3",
|
|
130
109
|
"aria-live": "polite",
|
|
131
110
|
"aria-label": "Concierge is thinking",
|
|
132
|
-
children: [/* @__PURE__ */
|
|
111
|
+
children: [/* @__PURE__ */ b(A, {
|
|
133
112
|
size: "sm",
|
|
134
113
|
active: !0
|
|
135
|
-
}), /* @__PURE__ */
|
|
114
|
+
}), /* @__PURE__ */ b("div", {
|
|
136
115
|
className: "rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-3 shadow-elevation-1",
|
|
137
|
-
children: /* @__PURE__ */
|
|
116
|
+
children: /* @__PURE__ */ b("span", {
|
|
138
117
|
className: "flex items-center gap-1",
|
|
139
118
|
children: [
|
|
140
119
|
0,
|
|
141
120
|
1,
|
|
142
121
|
2
|
|
143
|
-
].map((e) => /* @__PURE__ */
|
|
122
|
+
].map((e) => /* @__PURE__ */ b("span", {
|
|
144
123
|
className: "h-2 w-2 rounded-full bg-text-muted motion-safe:animate-bounce",
|
|
145
124
|
style: { animationDelay: `${e * 150}ms` }
|
|
146
125
|
}, e))
|
|
147
126
|
})
|
|
148
127
|
})]
|
|
149
|
-
}),
|
|
128
|
+
}), P = ({ onPick: e, disabled: n, className: r }) => /* @__PURE__ */ b("div", {
|
|
150
129
|
className: t("flex flex-wrap gap-2", r),
|
|
151
|
-
children:
|
|
130
|
+
children: k.map((r) => /* @__PURE__ */ x("button", {
|
|
152
131
|
type: "button",
|
|
153
132
|
disabled: n,
|
|
154
133
|
onClick: () => e(r),
|
|
155
134
|
className: t("inline-flex items-center gap-1.5 rounded-full border border-border-subtle bg-bg-surface", "px-3 py-1.5 text-xs font-medium text-text-secondary transition-colors", "hover:border-border-strong hover:text-text-primary", "focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]", "disabled:cursor-not-allowed disabled:opacity-50"),
|
|
156
|
-
children: [/* @__PURE__ */ y
|
|
135
|
+
children: [/* @__PURE__ */ b(y, { className: "h-3 w-3 text-action-primary-bg" }), r]
|
|
157
136
|
}, r))
|
|
158
|
-
}),
|
|
159
|
-
let { navigateTo: i, currentUser: a } = e(),
|
|
160
|
-
|
|
161
|
-
if (!
|
|
162
|
-
|
|
163
|
-
let e =
|
|
164
|
-
return
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
137
|
+
}), F = () => {
|
|
138
|
+
let { navigateTo: i, currentUser: a } = e(), f = o(), [p, v] = d([]), [y, T] = d(""), [D, k] = d(null), [j, F] = d(""), [I, L] = d(null), R = u(null), z = u(null), B = r({ status: "PUBLISHED" }, w, D?.term), V = s((e) => i(`title/${e}`), [i]);
|
|
139
|
+
c(() => {
|
|
140
|
+
if (!D) return;
|
|
141
|
+
F(""), L(null);
|
|
142
|
+
let e = new AbortController();
|
|
143
|
+
return z.current = e, f.sendPrompt({
|
|
144
|
+
prompt: D.prompt,
|
|
145
|
+
onProgress: (e) => F(e),
|
|
146
|
+
signal: e.signal
|
|
147
|
+
}).then(({ text: e }) => L({
|
|
148
|
+
turnId: D.turnId,
|
|
149
|
+
text: e,
|
|
150
|
+
failed: !1
|
|
151
|
+
})).catch((t) => {
|
|
152
|
+
if (e.signal.aborted) return;
|
|
153
|
+
let n = C(t) ? "You've reached this workspace's AI credit limit for now. Try again once it resets, or ask an admin to raise it." : "I hit a snag reaching the assistant just now. Give it another try in a moment.";
|
|
154
|
+
L({
|
|
155
|
+
turnId: D.turnId,
|
|
156
|
+
text: n,
|
|
157
|
+
failed: !0
|
|
158
|
+
});
|
|
159
|
+
}), () => e.abort();
|
|
160
|
+
}, [D]), c(() => {
|
|
161
|
+
if (!D || !I || I.turnId !== D.turnId || B.isFetching) return;
|
|
162
|
+
let e = !I.failed && B.isSuccess ? B.data.items : [], t = !I.failed && e.length > 0 ? `${e.length} title${e.length === 1 ? "" : "s"} pulled live from your catalog` : void 0;
|
|
163
|
+
v((n) => [...n, {
|
|
164
|
+
id: D.turnId,
|
|
174
165
|
role: "assistant",
|
|
175
|
-
text:
|
|
176
|
-
|
|
166
|
+
text: I.text,
|
|
167
|
+
failed: I.failed,
|
|
168
|
+
note: t,
|
|
177
169
|
titles: e
|
|
178
|
-
}]),
|
|
170
|
+
}]), k(null), L(null), F("");
|
|
179
171
|
}, [
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
top: M.current.scrollHeight,
|
|
172
|
+
D,
|
|
173
|
+
I,
|
|
174
|
+
B.isFetching,
|
|
175
|
+
B.isSuccess,
|
|
176
|
+
B.data
|
|
177
|
+
]), c(() => {
|
|
178
|
+
R.current?.scrollTo({
|
|
179
|
+
top: R.current.scrollHeight,
|
|
189
180
|
behavior: "smooth"
|
|
190
181
|
});
|
|
191
|
-
}, [
|
|
192
|
-
|
|
182
|
+
}, [
|
|
183
|
+
p,
|
|
184
|
+
D,
|
|
185
|
+
j
|
|
186
|
+
]), c(() => () => z.current?.abort(), []);
|
|
187
|
+
let H = s((e) => {
|
|
193
188
|
let t = e.trim();
|
|
194
|
-
!t ||
|
|
195
|
-
id:
|
|
189
|
+
!t || D || (v((e) => [...e, {
|
|
190
|
+
id: E(),
|
|
196
191
|
role: "user",
|
|
197
192
|
text: t
|
|
198
|
-
}]),
|
|
199
|
-
turnId:
|
|
193
|
+
}]), T(""), k({
|
|
194
|
+
turnId: E(),
|
|
200
195
|
prompt: t,
|
|
201
|
-
term:
|
|
196
|
+
term: O(t)
|
|
202
197
|
}));
|
|
203
|
-
}, [
|
|
204
|
-
e.preventDefault(),
|
|
205
|
-
},
|
|
206
|
-
e.key === "Enter" && !e.shiftKey && (e.preventDefault(),
|
|
207
|
-
},
|
|
208
|
-
|
|
209
|
-
},
|
|
198
|
+
}, [D]), U = (e) => {
|
|
199
|
+
e.preventDefault(), H(y);
|
|
200
|
+
}, W = (e) => {
|
|
201
|
+
e.key === "Enter" && !e.shiftKey && (e.preventDefault(), H(y));
|
|
202
|
+
}, G = () => {
|
|
203
|
+
z.current?.abort(), v([]), k(null), L(null), F(""), T("");
|
|
204
|
+
}, K = l(() => {
|
|
210
205
|
let e = a?.name?.trim();
|
|
211
206
|
if (e) return e.split(/\s+/)[0];
|
|
212
207
|
let t = a?.email?.trim();
|
|
213
208
|
return t ? t.split("@")[0] : null;
|
|
214
|
-
}, [a]),
|
|
215
|
-
return /* @__PURE__ */
|
|
209
|
+
}, [a]), q = D !== null, J = p.length > 0 || q;
|
|
210
|
+
return /* @__PURE__ */ b(n, {
|
|
216
211
|
hero: !0,
|
|
217
|
-
title: /* @__PURE__ */
|
|
212
|
+
title: /* @__PURE__ */ x("span", {
|
|
218
213
|
className: "inline-flex items-center gap-2",
|
|
219
|
-
children: [/* @__PURE__ */
|
|
214
|
+
children: [/* @__PURE__ */ b(h, { className: "h-6 w-6 text-action-primary-bg" }), "AI Watch-Concierge"]
|
|
220
215
|
}),
|
|
221
|
-
description: "Describe a mood or genre and get
|
|
222
|
-
purpose: "Ask for something to watch in plain language
|
|
223
|
-
actions:
|
|
216
|
+
description: "Describe a mood or genre and get AI picks verified against your live catalog.",
|
|
217
|
+
purpose: "Ask for something to watch in plain language. This is a live AI assistant with real access to your catalog and entitlement data — it checks availability before answering rather than guessing, and surfaces titles from your published catalog as tappable posters.",
|
|
218
|
+
actions: J ? /* @__PURE__ */ x("button", {
|
|
224
219
|
type: "button",
|
|
225
|
-
onClick:
|
|
226
|
-
disabled:
|
|
220
|
+
onClick: G,
|
|
221
|
+
disabled: q,
|
|
227
222
|
className: t("inline-flex items-center gap-2 rounded-lg border border-border-strong bg-bg-surface px-3 py-2 text-sm font-medium text-text-primary transition-colors", "hover:bg-bg-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]", "disabled:cursor-not-allowed disabled:opacity-50"),
|
|
228
|
-
children: [/* @__PURE__ */
|
|
223
|
+
children: [/* @__PURE__ */ b(g, { className: "h-4 w-4" }), "New chat"]
|
|
229
224
|
}) : void 0,
|
|
230
|
-
children: /* @__PURE__ */
|
|
225
|
+
children: /* @__PURE__ */ x("div", {
|
|
231
226
|
className: "mx-auto flex h-full max-w-3xl flex-col",
|
|
232
227
|
children: [
|
|
233
|
-
/* @__PURE__ */
|
|
228
|
+
/* @__PURE__ */ x("div", {
|
|
234
229
|
role: "note",
|
|
235
|
-
className: "mb-4 flex shrink-0 items-center gap-2 rounded-lg border border-status-
|
|
236
|
-
children: [/* @__PURE__ */
|
|
230
|
+
className: "mb-4 flex shrink-0 items-center gap-2 rounded-lg border border-status-info-border bg-status-info-bg-subtle px-3 py-2 text-xs text-status-info-text",
|
|
231
|
+
children: [/* @__PURE__ */ b(m, { className: "h-4 w-4 shrink-0" }), /* @__PURE__ */ x("span", { children: [/* @__PURE__ */ b("strong", {
|
|
237
232
|
className: "font-semibold",
|
|
238
|
-
children: "
|
|
239
|
-
}), " —
|
|
233
|
+
children: "Live AI"
|
|
234
|
+
}), " — answers can still be imperfect; availability and pricing are checked against your real catalog, but double-check before you buy."] })]
|
|
240
235
|
}),
|
|
241
|
-
/* @__PURE__ */
|
|
242
|
-
ref:
|
|
236
|
+
/* @__PURE__ */ b("div", {
|
|
237
|
+
ref: R,
|
|
243
238
|
className: "flex-1 overflow-y-auto pr-1",
|
|
244
|
-
children:
|
|
239
|
+
children: J ? /* @__PURE__ */ x("div", {
|
|
245
240
|
className: "space-y-5 py-2",
|
|
246
241
|
role: "log",
|
|
247
242
|
"aria-live": "polite",
|
|
248
243
|
"aria-relevant": "additions",
|
|
249
|
-
children: [
|
|
244
|
+
children: [p.map((e) => /* @__PURE__ */ b(M, {
|
|
250
245
|
message: e,
|
|
251
|
-
onOpenTitle:
|
|
252
|
-
}, e.id)),
|
|
253
|
-
|
|
246
|
+
onOpenTitle: V
|
|
247
|
+
}, e.id)), q && (j ? /* @__PURE__ */ x("div", {
|
|
248
|
+
className: "flex items-start gap-3",
|
|
249
|
+
children: [/* @__PURE__ */ b(A, {
|
|
250
|
+
size: "sm",
|
|
251
|
+
active: !0
|
|
252
|
+
}), /* @__PURE__ */ b("div", {
|
|
253
|
+
className: "min-w-0 max-w-[85%] sm:max-w-[80%]",
|
|
254
|
+
children: /* @__PURE__ */ b("div", {
|
|
255
|
+
className: "rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-2.5 shadow-elevation-1",
|
|
256
|
+
children: /* @__PURE__ */ b("p", {
|
|
257
|
+
className: "whitespace-pre-wrap break-words text-sm text-text-primary",
|
|
258
|
+
children: j
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
})]
|
|
262
|
+
}) : /* @__PURE__ */ b(N, {}))]
|
|
263
|
+
}) : /* @__PURE__ */ x("div", {
|
|
254
264
|
className: "flex min-h-full flex-col items-center justify-center py-10 text-center",
|
|
255
265
|
children: [
|
|
256
|
-
/* @__PURE__ */
|
|
266
|
+
/* @__PURE__ */ b(A, {
|
|
257
267
|
size: "lg",
|
|
258
268
|
active: !0,
|
|
259
269
|
className: "mb-5"
|
|
260
270
|
}),
|
|
261
|
-
/* @__PURE__ */
|
|
271
|
+
/* @__PURE__ */ b("h2", {
|
|
262
272
|
className: "text-xl font-semibold text-text-primary sm:text-2xl",
|
|
263
|
-
children:
|
|
273
|
+
children: K ? `Hi ${K} — what are you in the mood for?` : "What are you in the mood for?"
|
|
264
274
|
}),
|
|
265
|
-
/* @__PURE__ */
|
|
275
|
+
/* @__PURE__ */ b("p", {
|
|
266
276
|
className: "mt-2 max-w-md text-sm text-text-secondary",
|
|
267
277
|
children: "Tell me a vibe, a genre, or a title you loved. I'll surface real picks from what's published in your catalog."
|
|
268
278
|
}),
|
|
269
|
-
/* @__PURE__ */
|
|
270
|
-
onPick:
|
|
271
|
-
disabled:
|
|
279
|
+
/* @__PURE__ */ b(P, {
|
|
280
|
+
onPick: H,
|
|
281
|
+
disabled: q,
|
|
272
282
|
className: "mt-6 justify-center"
|
|
273
283
|
})
|
|
274
284
|
]
|
|
275
285
|
})
|
|
276
286
|
}),
|
|
277
|
-
/* @__PURE__ */
|
|
287
|
+
/* @__PURE__ */ x("div", {
|
|
278
288
|
className: "shrink-0 pt-4",
|
|
279
289
|
children: [
|
|
280
|
-
|
|
281
|
-
onPick:
|
|
282
|
-
disabled:
|
|
290
|
+
J && /* @__PURE__ */ b(P, {
|
|
291
|
+
onPick: H,
|
|
292
|
+
disabled: q,
|
|
283
293
|
className: "mb-3"
|
|
284
294
|
}),
|
|
285
|
-
/* @__PURE__ */
|
|
295
|
+
/* @__PURE__ */ b(S, {
|
|
286
296
|
treatment: "glass",
|
|
287
297
|
className: "border-border-seam p-2",
|
|
288
|
-
children: /* @__PURE__ */
|
|
289
|
-
onSubmit:
|
|
298
|
+
children: /* @__PURE__ */ x("form", {
|
|
299
|
+
onSubmit: U,
|
|
290
300
|
className: "flex items-end gap-2",
|
|
291
301
|
children: [
|
|
292
|
-
/* @__PURE__ */
|
|
302
|
+
/* @__PURE__ */ b("label", {
|
|
293
303
|
htmlFor: "concierge-input",
|
|
294
304
|
className: "sr-only",
|
|
295
305
|
children: "Message the watch-concierge"
|
|
296
306
|
}),
|
|
297
|
-
/* @__PURE__ */
|
|
307
|
+
/* @__PURE__ */ b("textarea", {
|
|
298
308
|
id: "concierge-input",
|
|
299
309
|
rows: 1,
|
|
300
|
-
value:
|
|
301
|
-
onChange: (e) =>
|
|
302
|
-
onKeyDown:
|
|
310
|
+
value: y,
|
|
311
|
+
onChange: (e) => T(e.target.value),
|
|
312
|
+
onKeyDown: W,
|
|
303
313
|
placeholder: "Ask for something to watch…",
|
|
304
|
-
disabled:
|
|
314
|
+
disabled: q,
|
|
305
315
|
className: t("max-h-32 min-h-[2.5rem] flex-1 resize-none bg-transparent px-3 py-2 text-sm text-text-primary", "placeholder:text-text-secondary focus:outline-none disabled:opacity-60")
|
|
306
316
|
}),
|
|
307
|
-
/* @__PURE__ */
|
|
317
|
+
/* @__PURE__ */ b("button", {
|
|
308
318
|
type: "submit",
|
|
309
|
-
disabled:
|
|
319
|
+
disabled: q || y.trim().length === 0,
|
|
310
320
|
"aria-label": "Send message",
|
|
311
321
|
className: t("inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-action-primary-bg text-action-primary-text shadow-elevation-1 transition-opacity", "hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]", "disabled:cursor-not-allowed disabled:opacity-40"),
|
|
312
|
-
children: /* @__PURE__ */
|
|
322
|
+
children: /* @__PURE__ */ b(_, { className: "h-4 w-4" })
|
|
313
323
|
})
|
|
314
324
|
]
|
|
315
325
|
})
|
|
316
326
|
}),
|
|
317
|
-
/* @__PURE__ */
|
|
327
|
+
/* @__PURE__ */ b("p", {
|
|
318
328
|
className: "mt-2 text-center text-[11px] text-text-muted",
|
|
319
329
|
children: "Press Enter to send · Shift + Enter for a new line"
|
|
320
330
|
})
|
|
@@ -325,6 +335,6 @@ var A = [
|
|
|
325
335
|
});
|
|
326
336
|
};
|
|
327
337
|
//#endregion
|
|
328
|
-
export {
|
|
338
|
+
export { F as ConciergePage };
|
|
329
339
|
|
|
330
340
|
//# sourceMappingURL=ConciergePage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConciergePage.js","names":[],"sources":["../../../../src/hookmovies/pages/ai/ConciergePage.tsx"],"sourcesContent":["/**\n * AI Watch-Concierge (Consumer)\n * @module hookmovies/pages/ai\n *\n * A polished, conversational surface where a viewer can ask for something to\n * watch and get poster recommendations back inline.\n *\n * IMPORTANT — this is a PREVIEW: the assistant's *prose* is MOCKED (canned,\n * deterministic) and is clearly labelled as such throughout the UI (\"Preview —\n * AI responses are simulated\"). It is NOT a live LLM.\n *\n * To keep the experience grounded and honest, every recommendation card is\n * REAL: each user turn drives a live `hokListTitles(search:)` query against the\n * published catalog (via {@link useTitles}) and the returned titles are rendered\n * as tappable poster cards that deep-link into the consumer title view. So the\n * cards are truthful catalog data even though the surrounding prose is canned.\n *\n * The seam for a real model is a single function — {@link getConciergeReply}.\n * Swap its body for a call to a real concierge endpoint that returns the same\n * `ConciergeReply` shape and the rest of this component needs no change.\n *\n * Fully tokenized (fe-libs semantic tokens only — no raw colors), mobile-first,\n * reduce-motion friendly, with loading / empty / error handling for the live\n * recommendation queries.\n */\n\nimport {\n type FC,\n type FormEvent,\n type KeyboardEvent,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport {\n Sparkles,\n Send,\n Film,\n Wand2,\n RotateCcw,\n Info,\n AlertCircle,\n MessageSquareText,\n} from 'lucide-react';\nimport { GlassCard } from '@burdenoff/fe-libs/ui';\nimport { PageLayout } from '../../components/PageLayout';\nimport { useHookMovies } from '../../context/HookMoviesContext';\nimport { useTitles, type HokTitle } from '../../hooks/useHookMoviesApi';\nimport { useFileUrl } from '../../hooks/useFiles';\nimport { cn } from '../../utils/cn';\nimport { formatEnumLabel } from '../../utils/formatters';\n\n// Recommendations returned per turn — enough to fill one scrollable rail.\nconst RECO_PAGE = { limit: 8, offset: 0 } as const;\n\n// A short, deliberate \"thinking\" beat so the simulated reply feels considered\n// (and, incidentally, guarantees the live recommendation query has settled for\n// the current turn before we render the answer — see the resolver effect).\nconst THINKING_MS = 700;\n\nconst SIMULATED_NOTE = 'Simulated response — not a live model';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype ChatRole = 'user' | 'assistant';\n\ninterface ChatMessage {\n id: string;\n role: ChatRole;\n text: string;\n /** Optional \"this was simulated\" caption shown under assistant prose. */\n note?: string;\n /** Real catalog titles surfaced as recommendation cards for this turn. */\n titles?: HokTitle[];\n}\n\ninterface PendingTurn {\n turnId: string;\n prompt: string;\n /** Derived search term driving the live `hokListTitles` query. */\n term?: string;\n}\n\ninterface ConciergeReply {\n text: string;\n note?: string;\n}\n\n// ============================================================================\n// Mock reply engine — THE SINGLE SEAM for a real LLM\n// ============================================================================\n\n/**\n * Produce the concierge's prose reply for a turn.\n *\n * TODAY: returns a deterministic, clearly-labelled MOCK grounded in the real\n * titles that were just fetched from the catalog. It never claims to be a live\n * model and always carries the {@link SIMULATED_NOTE}.\n *\n * LATER: replace the body with a call to a real concierge endpoint (streaming\n * or request/response) that returns the same {@link ConciergeReply} shape. No\n * other part of this component needs to change — the UI already renders `text`,\n * the optional `note`, and the real `titles` cards independently.\n */\nfunction getConciergeReply(args: {\n prompt: string;\n titles: HokTitle[];\n failed: boolean;\n}): ConciergeReply {\n const { prompt, titles, failed } = args;\n\n if (failed) {\n return {\n text: \"I hit a snag reaching the catalog just now, so I couldn't pull recommendations for that. Give it another try in a moment.\",\n note: SIMULATED_NOTE,\n };\n }\n\n const normalized = prompt.trim().toLowerCase();\n const isGreeting = /^(hi|hey|hello|yo|namaste|hola|good (morning|evening|afternoon))\\b/.test(\n normalized\n );\n\n if (titles.length === 0) {\n if (isGreeting) {\n return {\n text: \"Hi! I'm your watch-concierge. Tell me a mood, a genre, or a title you loved and I'll surface something from the catalog to match.\",\n note: SIMULATED_NOTE,\n };\n }\n return {\n text: `I couldn't find anything published that matches “${prompt.trim()}” yet. Try a broader mood — like “something funny”, “a gripping thriller”, or “short films” — and I'll look again.`,\n note: SIMULATED_NOTE,\n };\n }\n\n const count = titles.length;\n const plural = count === 1 ? 'title' : 'titles';\n const leadIns = [\n `Great pick of a mood. Based on what's published in your catalog, here ${count === 1 ? 'is' : 'are'} ${count} ${plural} I think you'll enjoy:`,\n `Say no more — I pulled ${count} real ${plural} from the published catalog that fit that vibe:`,\n `Here's what's streaming that lines up with that. ${count} ${plural} worth a look:`,\n `On it. These ${count} ${plural} from your catalog match what you're after:`,\n ];\n // Deterministic variety without randomness (stable across re-renders).\n const lead = leadIns[normalized.length % leadIns.length];\n\n return {\n text: `${lead} tap any poster to dive in.`,\n note: SIMULATED_NOTE,\n };\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nlet turnCounter = 0;\nconst nextId = (): string => {\n turnCounter += 1;\n return `turn-${turnCounter}-${Date.now().toString(36)}`;\n};\n\n// Words that add no signal to a catalog search — stripped before querying.\nconst STOPWORDS = new Set([\n 'a',\n 'an',\n 'the',\n 'i',\n 'me',\n 'my',\n 'want',\n 'wanna',\n 'like',\n 'love',\n 'watch',\n 'watching',\n 'something',\n 'anything',\n 'show',\n 'me',\n 'find',\n 'recommend',\n 'suggest',\n 'give',\n 'please',\n 'tonight',\n 'today',\n 'now',\n 'for',\n 'to',\n 'of',\n 'and',\n 'or',\n 'with',\n 'about',\n 'some',\n 'any',\n 'good',\n 'great',\n 'best',\n 'can',\n 'you',\n 'is',\n 'are',\n 'what',\n 'whats',\n 'that',\n 'this',\n 'in',\n 'on',\n 'up',\n 'get',\n 'got',\n 'im',\n 'looking',\n 'mood',\n]);\n\n/**\n * Reduce a free-text prompt to a concise search term for `hokListTitles`.\n * Keeps the most meaningful keywords (mood / genre / title words); returns\n * `undefined` when nothing meaningful remains (the catalog then lists its\n * freshest published titles as generic \"surprise me\" picks).\n */\nfunction deriveSearchTerm(prompt: string): string | undefined {\n const words = prompt\n .toLowerCase()\n .replace(/[^a-z0-9\\s]/g, ' ')\n .split(/\\s+/)\n .filter(Boolean)\n .filter((w) => !STOPWORDS.has(w) && w.length > 1);\n const term = words.slice(0, 6).join(' ').trim();\n return term.length > 0 ? term : undefined;\n}\n\nconst SUGGESTED_PROMPTS = [\n 'Recommend a feel-good movie for tonight',\n 'What documentaries are worth watching?',\n 'Show me something with high drama',\n 'I want a short film under 30 minutes',\n 'Surprise me with anime',\n] as const;\n\n// ============================================================================\n// AI orb — the crimson concierge avatar (brand action-primary tokens only)\n// ============================================================================\n\ninterface AiOrbProps {\n size?: 'sm' | 'lg';\n /** Pulse the halo (used on the hero / while thinking). */\n active?: boolean;\n className?: string;\n}\n\nconst AiOrb: FC<AiOrbProps> = ({ size = 'sm', active = false, className }) => {\n const dim = size === 'lg' ? 'h-16 w-16' : 'h-8 w-8';\n const glyph = size === 'lg' ? 'h-7 w-7' : 'h-4 w-4';\n return (\n <span\n className={cn('relative inline-flex shrink-0 items-center justify-center', dim, className)}\n aria-hidden=\"true\"\n >\n {/* Soft crimson halo — brand accent, softened so it reads as a glow. */}\n <span\n className={cn(\n 'absolute inset-0 rounded-full bg-action-primary-bg opacity-30 blur-md',\n active && 'motion-safe:animate-ping'\n )}\n />\n <span\n className={cn(\n 'relative inline-flex items-center justify-center rounded-full',\n 'bg-action-primary-bg text-action-primary-text shadow-elevation-2',\n dim\n )}\n >\n <Sparkles className={glyph} />\n </span>\n </span>\n );\n};\n\n// ============================================================================\n// RecoCard — a compact, real catalog poster surfaced inline in a reply\n// ============================================================================\n\ninterface RecoCardProps {\n title: HokTitle;\n onOpen: (id: string) => void;\n}\n\nconst RecoCard: FC<RecoCardProps> = ({ title, onOpen }) => {\n const { data: url, isLoading } = useFileUrl(title.posterFileId);\n const [errored, setErrored] = useState(false);\n\n const showImage = Boolean(title.posterFileId) && Boolean(url) && !errored;\n const meta = [formatEnumLabel(title.type), title.releaseYear ? String(title.releaseYear) : null]\n .filter(Boolean)\n .join(' · ');\n\n return (\n <button\n type=\"button\"\n onClick={() => onOpen(title.id)}\n aria-label={`Open ${title.title}`}\n className=\"group w-28 shrink-0 snap-start text-left focus:outline-none sm:w-32\"\n >\n <div\n className={cn(\n 'relative aspect-[2/3] overflow-hidden rounded-lg border border-border-seam bg-bg-sunken',\n 'shadow-elevation-1 transition-[border-color,box-shadow] duration-200',\n 'group-hover:border-border-strong group-hover:shadow-elevation-2',\n 'group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)]'\n )}\n >\n {showImage ? (\n <img\n src={url ?? undefined}\n alt={`${title.title} poster`}\n loading=\"lazy\"\n onError={() => setErrored(true)}\n className=\"h-full w-full object-cover\"\n />\n ) : (\n <div\n className={cn(\n 'flex h-full w-full items-center justify-center text-text-muted',\n isLoading && Boolean(title.posterFileId) && 'motion-safe:animate-pulse'\n )}\n aria-hidden=\"true\"\n >\n <Film className=\"h-6 w-6\" />\n </div>\n )}\n\n {title.aiDisclosure && (\n <span\n className=\"absolute right-1.5 top-1.5 inline-flex items-center gap-1 rounded-full bg-bg-canvas/80 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary backdrop-blur-sm\"\n title=\"Contains AI-generated or AI-altered content\"\n >\n <Sparkles className=\"h-2.5 w-2.5\" />\n AI\n </span>\n )}\n </div>\n <p className=\"mt-1.5 line-clamp-1 text-xs font-medium text-text-primary\">{title.title}</p>\n {meta && <p className=\"line-clamp-1 text-[11px] text-text-secondary\">{meta}</p>}\n </button>\n );\n};\n\n// ============================================================================\n// MessageRow — one user or assistant turn\n// ============================================================================\n\ninterface MessageRowProps {\n message: ChatMessage;\n onOpenTitle: (id: string) => void;\n}\n\nconst MessageRow: FC<MessageRowProps> = ({ message, onOpenTitle }) => {\n const isUser = message.role === 'user';\n\n if (isUser) {\n return (\n <div className=\"flex justify-end\">\n <div className=\"max-w-[85%] rounded-2xl rounded-br-sm bg-action-primary-bg px-4 py-2.5 text-sm text-action-primary-text shadow-elevation-1 sm:max-w-[75%]\">\n <p className=\"whitespace-pre-wrap break-words\">{message.text}</p>\n </div>\n </div>\n );\n }\n\n return (\n <div className=\"flex items-start gap-3\">\n <AiOrb size=\"sm\" />\n <div className=\"min-w-0 max-w-[85%] sm:max-w-[80%]\">\n <div className=\"rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-2.5 shadow-elevation-1\">\n <p className=\"whitespace-pre-wrap break-words text-sm text-text-primary\">\n {message.text}\n </p>\n {message.note && (\n <p className=\"mt-1.5 flex items-center gap-1 text-[11px] text-text-muted\">\n <Info className=\"h-3 w-3\" />\n {message.note}\n </p>\n )}\n </div>\n\n {message.titles && message.titles.length > 0 && (\n <div\n className={cn('mt-3 flex gap-3 overflow-x-auto pb-2', 'snap-x [scrollbar-width:thin]')}\n >\n {message.titles.map((title) => (\n <RecoCard key={title.id} title={title} onOpen={onOpenTitle} />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n};\n\n// ============================================================================\n// TypingIndicator — the \"thinking\" bubble while a turn resolves\n// ============================================================================\n\nconst TypingIndicator: FC = () => (\n <div className=\"flex items-start gap-3\" aria-live=\"polite\" aria-label=\"Concierge is thinking\">\n <AiOrb size=\"sm\" active />\n <div className=\"rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-3 shadow-elevation-1\">\n <span className=\"flex items-center gap-1\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"h-2 w-2 rounded-full bg-text-muted motion-safe:animate-bounce\"\n style={{ animationDelay: `${i * 150}ms` }}\n />\n ))}\n </span>\n </div>\n </div>\n);\n\n// ============================================================================\n// ChipRow — suggested-prompt chips\n// ============================================================================\n\ninterface ChipRowProps {\n onPick: (prompt: string) => void;\n disabled: boolean;\n className?: string;\n}\n\nconst ChipRow: FC<ChipRowProps> = ({ onPick, disabled, className }) => (\n <div className={cn('flex flex-wrap gap-2', className)}>\n {SUGGESTED_PROMPTS.map((prompt) => (\n <button\n key={prompt}\n type=\"button\"\n disabled={disabled}\n onClick={() => onPick(prompt)}\n className={cn(\n 'inline-flex items-center gap-1.5 rounded-full border border-border-subtle bg-bg-surface',\n 'px-3 py-1.5 text-xs font-medium text-text-secondary transition-colors',\n 'hover:border-border-strong hover:text-text-primary',\n 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]',\n 'disabled:cursor-not-allowed disabled:opacity-50'\n )}\n >\n <Wand2 className=\"h-3 w-3 text-action-primary-bg\" />\n {prompt}\n </button>\n ))}\n </div>\n);\n\n// ============================================================================\n// ConciergePage\n// ============================================================================\n\nexport const ConciergePage: FC = () => {\n const { navigateTo, currentUser } = useHookMovies();\n\n const [messages, setMessages] = useState<ChatMessage[]>([]);\n const [input, setInput] = useState('');\n const [pending, setPending] = useState<PendingTurn | null>(null);\n const [thinkingElapsed, setThinkingElapsed] = useState(false);\n\n const scrollRef = useRef<HTMLDivElement>(null);\n\n // Live recommendation query for the in-flight turn. When there's no pending\n // turn `term` is undefined and this simply lists freshest published titles —\n // harmless, and keeps the hook mounted so the query cache stays warm.\n const recos = useTitles({ status: 'PUBLISHED' }, RECO_PAGE, pending?.term);\n\n const openTitle = useCallback((id: string) => navigateTo(`title/${id}`), [navigateTo]);\n\n // Kick off (and reset) the deliberate \"thinking\" beat for each new turn.\n useEffect(() => {\n if (!pending) return;\n setThinkingElapsed(false);\n const handle = window.setTimeout(() => setThinkingElapsed(true), THINKING_MS);\n return () => window.clearTimeout(handle);\n }, [pending]);\n\n // Resolve the pending turn once the thinking beat has passed AND the live\n // recommendation query has settled for the current term. The timer guarantees\n // the query for the new term has begun, so `!isFetching` here means it has\n // finished for THIS turn (not a stale prior result).\n useEffect(() => {\n if (!pending || !thinkingElapsed || recos.isFetching) return;\n\n const titles = recos.isSuccess ? recos.data.items : [];\n const reply = getConciergeReply({\n prompt: pending.prompt,\n titles,\n failed: recos.isError,\n });\n\n setMessages((prev) => [\n ...prev,\n {\n id: pending.turnId,\n role: 'assistant',\n text: reply.text,\n note: reply.note,\n titles,\n },\n ]);\n setPending(null);\n }, [pending, thinkingElapsed, recos.isFetching, recos.isSuccess, recos.isError, recos.data]);\n\n // Auto-scroll to the newest turn.\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });\n }, [messages, pending]);\n\n const send = useCallback(\n (raw: string) => {\n const trimmed = raw.trim();\n if (!trimmed || pending) return;\n setMessages((prev) => [...prev, { id: nextId(), role: 'user', text: trimmed }]);\n setInput('');\n setPending({ turnId: nextId(), prompt: trimmed, term: deriveSearchTerm(trimmed) });\n },\n [pending]\n );\n\n const handleSubmit = (event: FormEvent) => {\n event.preventDefault();\n send(input);\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault();\n send(input);\n }\n };\n\n const resetConversation = () => {\n setMessages([]);\n setPending(null);\n setInput('');\n };\n\n const firstName = useMemo(() => {\n const name = currentUser?.name?.trim();\n if (name) return name.split(/\\s+/)[0];\n const email = currentUser?.email?.trim();\n return email ? email.split('@')[0] : null;\n }, [currentUser]);\n\n const isBusy = pending !== null;\n const hasConversation = messages.length > 0 || isBusy;\n\n return (\n <PageLayout\n hero\n title={\n <span className=\"inline-flex items-center gap-2\">\n <MessageSquareText className=\"h-6 w-6 text-action-primary-bg\" />\n AI Watch-Concierge\n </span>\n }\n description=\"Describe a mood or genre and get real catalog picks back, instantly.\"\n purpose=\"Ask for something to watch in plain language and the concierge surfaces titles from your published catalog as tappable posters. Responses are a simulated preview; the recommendations themselves are real.\"\n actions={\n hasConversation ? (\n <button\n type=\"button\"\n onClick={resetConversation}\n disabled={isBusy}\n className={cn(\n 'inline-flex items-center gap-2 rounded-lg border border-border-strong bg-bg-surface px-3 py-2 text-sm font-medium text-text-primary transition-colors',\n 'hover:bg-bg-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]',\n 'disabled:cursor-not-allowed disabled:opacity-50'\n )}\n >\n <RotateCcw className=\"h-4 w-4\" />\n New chat\n </button>\n ) : undefined\n }\n >\n <div className=\"mx-auto flex h-full max-w-3xl flex-col\">\n {/* Simulated-preview banner — always visible, unambiguous. */}\n <div\n role=\"note\"\n className=\"mb-4 flex shrink-0 items-center gap-2 rounded-lg border border-status-warning-border bg-status-warning-bg-subtle px-3 py-2 text-xs text-status-warning-text\"\n >\n <AlertCircle className=\"h-4 w-4 shrink-0\" />\n <span>\n <strong className=\"font-semibold\">Preview</strong> — AI responses are simulated. Title\n recommendations are pulled live from your real catalog.\n </span>\n </div>\n\n {/* Conversation / intro */}\n <div ref={scrollRef} className=\"flex-1 overflow-y-auto pr-1\">\n {!hasConversation ? (\n <div className=\"flex min-h-full flex-col items-center justify-center py-10 text-center\">\n <AiOrb size=\"lg\" active className=\"mb-5\" />\n <h2 className=\"text-xl font-semibold text-text-primary sm:text-2xl\">\n {firstName\n ? `Hi ${firstName} — what are you in the mood for?`\n : 'What are you in the mood for?'}\n </h2>\n <p className=\"mt-2 max-w-md text-sm text-text-secondary\">\n Tell me a vibe, a genre, or a title you loved. I'll surface real picks from what's\n published in your catalog.\n </p>\n <ChipRow onPick={send} disabled={isBusy} className=\"mt-6 justify-center\" />\n </div>\n ) : (\n <div className=\"space-y-5 py-2\" role=\"log\" aria-live=\"polite\" aria-relevant=\"additions\">\n {messages.map((message) => (\n <MessageRow key={message.id} message={message} onOpenTitle={openTitle} />\n ))}\n {isBusy && <TypingIndicator />}\n </div>\n )}\n </div>\n\n {/* Composer */}\n <div className=\"shrink-0 pt-4\">\n {hasConversation && <ChipRow onPick={send} disabled={isBusy} className=\"mb-3\" />}\n <GlassCard treatment=\"glass\" className=\"border-border-seam p-2\">\n <form onSubmit={handleSubmit} className=\"flex items-end gap-2\">\n <label htmlFor=\"concierge-input\" className=\"sr-only\">\n Message the watch-concierge\n </label>\n <textarea\n id=\"concierge-input\"\n rows={1}\n value={input}\n onChange={(event) => setInput(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder=\"Ask for something to watch…\"\n disabled={isBusy}\n className={cn(\n 'max-h-32 min-h-[2.5rem] flex-1 resize-none bg-transparent px-3 py-2 text-sm text-text-primary',\n 'placeholder:text-text-secondary focus:outline-none disabled:opacity-60'\n )}\n />\n <button\n type=\"submit\"\n disabled={isBusy || input.trim().length === 0}\n aria-label=\"Send message\"\n className={cn(\n 'inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-action-primary-bg text-action-primary-text shadow-elevation-1 transition-opacity',\n 'hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]',\n 'disabled:cursor-not-allowed disabled:opacity-40'\n )}\n >\n <Send className=\"h-4 w-4\" />\n </button>\n </form>\n </GlassCard>\n <p className=\"mt-2 text-center text-[11px] text-text-muted\">\n Press Enter to send · Shift + Enter for a new line\n </p>\n </div>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConciergePage.js","names":[],"sources":["../../../../src/hookmovies/pages/ai/ConciergePage.tsx"],"sourcesContent":["/**\n * AI Watch-Concierge (Consumer)\n * @module hookmovies/pages/ai\n *\n * A polished, conversational surface where a viewer can ask for something to\n * watch and get poster recommendations back inline.\n *\n * LIVE (2026-07-26, BOFF-4179/4023): every turn is answered by a real model\n * call through the shared in-app sandbox assistant — the SAME platform\n * transport already mounted as the floating `AssistantWidget` in\n * `hookmovies-app/src/components/chrome/AppShell.tsx` (`AI_ASSISTANT_PRODUCT=hookmovies`).\n * There is no bespoke chat backend here (per BOFF-3618's \"no bespoke chat UI,\n * LLM gateway or transport\" directive) — the agent runs in the platform's\n * default `assistant` mode, which can execute real GraphQL operations\n * (`hokListTitles`, `hokCheckEntitlement`, `hokListRightsWindows`, the\n * self-scoped `hokMy*` fields, …) against this workspace's live catalog. Its\n * grounding rules and the full real `hok*` operation surface are documented\n * for the agent in `algoshred/ai-assistant`'s\n * `context/api-calls/products/hookmovies.md`.\n *\n * The poster recommendation rail stays independently REAL too: each turn also\n * drives a live `hokListTitles(search:)` query against the published catalog\n * (via {@link useTitles}), rendered as tappable poster cards. This is a\n * best-effort keyword match on the derived search term, not the same\n * retrieval the model itself may perform — both are grounded in the live\n * catalog, but the two are independent, honest views of it, not one derived\n * from the other.\n *\n * Full semantic/graph-aware discovery (embeddings, mood/runtime/language\n * filters) is NOT implemented yet — `hokListTitles(search:)` remains a plain\n * substring match on the title name (tracked separately: BOFF-3640/4181). The\n * eval set at `hookmovies-specs/ai/eval.json` exercises exactly this\n * boundary and the grounding/refusal rules above.\n *\n * Fully tokenized (fe-libs semantic tokens only — no raw colors), mobile-first,\n * reduce-motion friendly, with loading / empty / error handling for both the\n * live recommendation queries and the live assistant call.\n */\n\nimport {\n type FC,\n type FormEvent,\n type KeyboardEvent,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport {\n Sparkles,\n Send,\n Film,\n Wand2,\n RotateCcw,\n Info,\n AlertCircle,\n MessageSquareText,\n} from 'lucide-react';\nimport { GlassCard } from '@burdenoff/fe-libs/ui';\nimport { isQuotaExhaustedError } from '@burdenoff/fe-libs/shared/assistant';\nimport { PageLayout } from '../../components/PageLayout';\nimport { useHookMovies } from '../../context/HookMoviesContext';\nimport { useTitles, type HokTitle } from '../../hooks/useHookMoviesApi';\nimport { useFileUrl } from '../../hooks/useFiles';\nimport { useSandboxAssistantTransport } from '@/services/createSandboxAssistantTransport';\nimport { cn } from '../../utils/cn';\nimport { formatEnumLabel } from '../../utils/formatters';\n\n// Recommendations returned per turn — enough to fill one scrollable rail.\nconst RECO_PAGE = { limit: 8, offset: 0 } as const;\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype ChatRole = 'user' | 'assistant';\n\ninterface ChatMessage {\n id: string;\n role: ChatRole;\n text: string;\n /** Set when this turn's model call failed or hit a quota limit. */\n failed?: boolean;\n /** Honest grounding caption (e.g. \"N titles pulled live from your catalog\"). */\n note?: string;\n /** Real catalog titles surfaced as recommendation cards for this turn. */\n titles?: HokTitle[];\n}\n\ninterface PendingTurn {\n turnId: string;\n prompt: string;\n /** Derived search term driving the live `hokListTitles` query. */\n term?: string;\n}\n\n/** The live model's reply for one turn, once `sendPrompt` has settled. */\ninterface LlmReply {\n turnId: string;\n text: string;\n failed: boolean;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nlet turnCounter = 0;\nconst nextId = (): string => {\n turnCounter += 1;\n return `turn-${turnCounter}-${Date.now().toString(36)}`;\n};\n\n// Words that add no signal to a catalog search — stripped before querying.\nconst STOPWORDS = new Set([\n 'a',\n 'an',\n 'the',\n 'i',\n 'me',\n 'my',\n 'want',\n 'wanna',\n 'like',\n 'love',\n 'watch',\n 'watching',\n 'something',\n 'anything',\n 'show',\n 'me',\n 'find',\n 'recommend',\n 'suggest',\n 'give',\n 'please',\n 'tonight',\n 'today',\n 'now',\n 'for',\n 'to',\n 'of',\n 'and',\n 'or',\n 'with',\n 'about',\n 'some',\n 'any',\n 'good',\n 'great',\n 'best',\n 'can',\n 'you',\n 'is',\n 'are',\n 'what',\n 'whats',\n 'that',\n 'this',\n 'in',\n 'on',\n 'up',\n 'get',\n 'got',\n 'im',\n 'looking',\n 'mood',\n]);\n\n/**\n * Reduce a free-text prompt to a concise search term for `hokListTitles`.\n * Keeps the most meaningful keywords (mood / genre / title words); returns\n * `undefined` when nothing meaningful remains (the catalog then lists its\n * freshest published titles as generic \"surprise me\" picks).\n */\nfunction deriveSearchTerm(prompt: string): string | undefined {\n const words = prompt\n .toLowerCase()\n .replace(/[^a-z0-9\\s]/g, ' ')\n .split(/\\s+/)\n .filter(Boolean)\n .filter((w) => !STOPWORDS.has(w) && w.length > 1);\n const term = words.slice(0, 6).join(' ').trim();\n return term.length > 0 ? term : undefined;\n}\n\nconst SUGGESTED_PROMPTS = [\n 'Recommend a feel-good movie for tonight',\n 'What documentaries are worth watching?',\n 'Show me something with high drama',\n 'I want a short film under 30 minutes',\n 'Surprise me with anime',\n] as const;\n\n// ============================================================================\n// AI orb — the crimson concierge avatar (brand action-primary tokens only)\n// ============================================================================\n\ninterface AiOrbProps {\n size?: 'sm' | 'lg';\n /** Pulse the halo (used on the hero / while thinking). */\n active?: boolean;\n className?: string;\n}\n\nconst AiOrb: FC<AiOrbProps> = ({ size = 'sm', active = false, className }) => {\n const dim = size === 'lg' ? 'h-16 w-16' : 'h-8 w-8';\n const glyph = size === 'lg' ? 'h-7 w-7' : 'h-4 w-4';\n return (\n <span\n className={cn('relative inline-flex shrink-0 items-center justify-center', dim, className)}\n aria-hidden=\"true\"\n >\n {/* Soft crimson halo — brand accent, softened so it reads as a glow. */}\n <span\n className={cn(\n 'absolute inset-0 rounded-full bg-action-primary-bg opacity-30 blur-md',\n active && 'motion-safe:animate-ping'\n )}\n />\n <span\n className={cn(\n 'relative inline-flex items-center justify-center rounded-full',\n 'bg-action-primary-bg text-action-primary-text shadow-elevation-2',\n dim\n )}\n >\n <Sparkles className={glyph} />\n </span>\n </span>\n );\n};\n\n// ============================================================================\n// RecoCard — a compact, real catalog poster surfaced inline in a reply\n// ============================================================================\n\ninterface RecoCardProps {\n title: HokTitle;\n onOpen: (id: string) => void;\n}\n\nconst RecoCard: FC<RecoCardProps> = ({ title, onOpen }) => {\n const { data: url, isLoading } = useFileUrl(title.posterFileId);\n const [errored, setErrored] = useState(false);\n\n const showImage = Boolean(title.posterFileId) && Boolean(url) && !errored;\n const meta = [formatEnumLabel(title.type), title.releaseYear ? String(title.releaseYear) : null]\n .filter(Boolean)\n .join(' · ');\n\n return (\n <button\n type=\"button\"\n onClick={() => onOpen(title.id)}\n aria-label={`Open ${title.title}`}\n className=\"group w-28 shrink-0 snap-start text-left focus:outline-none sm:w-32\"\n >\n <div\n className={cn(\n 'relative aspect-[2/3] overflow-hidden rounded-lg border border-border-seam bg-bg-sunken',\n 'shadow-elevation-1 transition-[border-color,box-shadow] duration-200',\n 'group-hover:border-border-strong group-hover:shadow-elevation-2',\n 'group-focus-visible:ring-2 group-focus-visible:ring-[var(--color-focus-ring)]'\n )}\n >\n {showImage ? (\n <img\n src={url ?? undefined}\n alt={`${title.title} poster`}\n loading=\"lazy\"\n onError={() => setErrored(true)}\n className=\"h-full w-full object-cover\"\n />\n ) : (\n <div\n className={cn(\n 'flex h-full w-full items-center justify-center text-text-muted',\n isLoading && Boolean(title.posterFileId) && 'motion-safe:animate-pulse'\n )}\n aria-hidden=\"true\"\n >\n <Film className=\"h-6 w-6\" />\n </div>\n )}\n\n {title.aiDisclosure && (\n <span\n className=\"absolute right-1.5 top-1.5 inline-flex items-center gap-1 rounded-full bg-bg-canvas/80 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary backdrop-blur-sm\"\n title=\"Contains AI-generated or AI-altered content\"\n >\n <Sparkles className=\"h-2.5 w-2.5\" />\n AI\n </span>\n )}\n </div>\n <p className=\"mt-1.5 line-clamp-1 text-xs font-medium text-text-primary\">{title.title}</p>\n {meta && <p className=\"line-clamp-1 text-[11px] text-text-secondary\">{meta}</p>}\n </button>\n );\n};\n\n// ============================================================================\n// MessageRow — one user or assistant turn\n// ============================================================================\n\ninterface MessageRowProps {\n message: ChatMessage;\n onOpenTitle: (id: string) => void;\n}\n\nconst MessageRow: FC<MessageRowProps> = ({ message, onOpenTitle }) => {\n const isUser = message.role === 'user';\n\n if (isUser) {\n return (\n <div className=\"flex justify-end\">\n <div className=\"max-w-[85%] rounded-2xl rounded-br-sm bg-action-primary-bg px-4 py-2.5 text-sm text-action-primary-text shadow-elevation-1 sm:max-w-[75%]\">\n <p className=\"whitespace-pre-wrap break-words\">{message.text}</p>\n </div>\n </div>\n );\n }\n\n return (\n <div className=\"flex items-start gap-3\">\n <AiOrb size=\"sm\" />\n <div className=\"min-w-0 max-w-[85%] sm:max-w-[80%]\">\n <div className=\"rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-2.5 shadow-elevation-1\">\n <p className=\"whitespace-pre-wrap break-words text-sm text-text-primary\">\n {message.text}\n </p>\n {message.failed ? (\n <p className=\"mt-1.5 flex items-center gap-1 text-[11px] text-status-warning-text\">\n <AlertCircle className=\"h-3 w-3\" />\n Couldn't complete this turn\n </p>\n ) : (\n message.note && (\n <p className=\"mt-1.5 flex items-center gap-1 text-[11px] text-text-muted\">\n <Info className=\"h-3 w-3\" />\n {message.note}\n </p>\n )\n )}\n </div>\n\n {message.titles && message.titles.length > 0 && (\n <div\n className={cn('mt-3 flex gap-3 overflow-x-auto pb-2', 'snap-x [scrollbar-width:thin]')}\n >\n {message.titles.map((title) => (\n <RecoCard key={title.id} title={title} onOpen={onOpenTitle} />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n};\n\n// ============================================================================\n// TypingIndicator — the \"thinking\" bubble while a turn resolves\n// ============================================================================\n\nconst TypingIndicator: FC = () => (\n <div className=\"flex items-start gap-3\" aria-live=\"polite\" aria-label=\"Concierge is thinking\">\n <AiOrb size=\"sm\" active />\n <div className=\"rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-3 shadow-elevation-1\">\n <span className=\"flex items-center gap-1\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"h-2 w-2 rounded-full bg-text-muted motion-safe:animate-bounce\"\n style={{ animationDelay: `${i * 150}ms` }}\n />\n ))}\n </span>\n </div>\n </div>\n);\n\n// ============================================================================\n// ChipRow — suggested-prompt chips\n// ============================================================================\n\ninterface ChipRowProps {\n onPick: (prompt: string) => void;\n disabled: boolean;\n className?: string;\n}\n\nconst ChipRow: FC<ChipRowProps> = ({ onPick, disabled, className }) => (\n <div className={cn('flex flex-wrap gap-2', className)}>\n {SUGGESTED_PROMPTS.map((prompt) => (\n <button\n key={prompt}\n type=\"button\"\n disabled={disabled}\n onClick={() => onPick(prompt)}\n className={cn(\n 'inline-flex items-center gap-1.5 rounded-full border border-border-subtle bg-bg-surface',\n 'px-3 py-1.5 text-xs font-medium text-text-secondary transition-colors',\n 'hover:border-border-strong hover:text-text-primary',\n 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]',\n 'disabled:cursor-not-allowed disabled:opacity-50'\n )}\n >\n <Wand2 className=\"h-3 w-3 text-action-primary-bg\" />\n {prompt}\n </button>\n ))}\n </div>\n);\n\n// ============================================================================\n// ConciergePage\n// ============================================================================\n\nexport const ConciergePage: FC = () => {\n const { navigateTo, currentUser } = useHookMovies();\n const transport = useSandboxAssistantTransport();\n\n const [messages, setMessages] = useState<ChatMessage[]>([]);\n const [input, setInput] = useState('');\n const [pending, setPending] = useState<PendingTurn | null>(null);\n const [streamingText, setStreamingText] = useState('');\n const [llmReply, setLlmReply] = useState<LlmReply | null>(null);\n\n const scrollRef = useRef<HTMLDivElement>(null);\n const abortRef = useRef<AbortController | null>(null);\n\n // Live recommendation rail for the in-flight turn. When there's no pending\n // turn `term` is undefined and this simply lists freshest published titles —\n // harmless, and keeps the hook mounted so the query cache stays warm. This\n // is independent, best-effort keyword grounding — separate from whatever\n // the model itself may look up via its own tool calls (see module docblock).\n const recos = useTitles({ status: 'PUBLISHED' }, RECO_PAGE, pending?.term);\n\n const openTitle = useCallback((id: string) => navigateTo(`title/${id}`), [navigateTo]);\n\n // Fire the REAL model call for each new pending turn (LIVE — BOFF-4179).\n useEffect(() => {\n if (!pending) return;\n setStreamingText('');\n setLlmReply(null);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n transport\n .sendPrompt({\n prompt: pending.prompt,\n onProgress: (partial) => setStreamingText(partial),\n signal: controller.signal,\n })\n .then(({ text }) => setLlmReply({ turnId: pending.turnId, text, failed: false }))\n .catch((err: unknown) => {\n if (controller.signal.aborted) return;\n const text = isQuotaExhaustedError(err)\n ? \"You've reached this workspace's AI credit limit for now. Try again once it resets, or ask an admin to raise it.\"\n : 'I hit a snag reaching the assistant just now. Give it another try in a moment.';\n setLlmReply({ turnId: pending.turnId, text, failed: true });\n });\n\n return () => controller.abort();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pending]);\n\n // Finalize the turn once BOTH the real model reply AND the live catalog\n // query for this turn have settled, so cards never render from a stale\n // prior term and prose never renders ahead of a real result.\n useEffect(() => {\n if (!pending || !llmReply || llmReply.turnId !== pending.turnId || recos.isFetching) return;\n\n const titles = !llmReply.failed && recos.isSuccess ? recos.data.items : [];\n const note =\n !llmReply.failed && titles.length > 0\n ? `${titles.length} title${titles.length === 1 ? '' : 's'} pulled live from your catalog`\n : undefined;\n\n setMessages((prev) => [\n ...prev,\n {\n id: pending.turnId,\n role: 'assistant',\n text: llmReply.text,\n failed: llmReply.failed,\n note,\n titles,\n },\n ]);\n setPending(null);\n setLlmReply(null);\n setStreamingText('');\n }, [pending, llmReply, recos.isFetching, recos.isSuccess, recos.data]);\n\n // Auto-scroll to the newest turn.\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });\n }, [messages, pending, streamingText]);\n\n // Abort any in-flight turn on unmount.\n useEffect(() => () => abortRef.current?.abort(), []);\n\n const send = useCallback(\n (raw: string) => {\n const trimmed = raw.trim();\n if (!trimmed || pending) return;\n setMessages((prev) => [...prev, { id: nextId(), role: 'user', text: trimmed }]);\n setInput('');\n setPending({ turnId: nextId(), prompt: trimmed, term: deriveSearchTerm(trimmed) });\n },\n [pending]\n );\n\n const handleSubmit = (event: FormEvent) => {\n event.preventDefault();\n send(input);\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault();\n send(input);\n }\n };\n\n const resetConversation = () => {\n abortRef.current?.abort();\n setMessages([]);\n setPending(null);\n setLlmReply(null);\n setStreamingText('');\n setInput('');\n };\n\n const firstName = useMemo(() => {\n const name = currentUser?.name?.trim();\n if (name) return name.split(/\\s+/)[0];\n const email = currentUser?.email?.trim();\n return email ? email.split('@')[0] : null;\n }, [currentUser]);\n\n const isBusy = pending !== null;\n const hasConversation = messages.length > 0 || isBusy;\n\n return (\n <PageLayout\n hero\n title={\n <span className=\"inline-flex items-center gap-2\">\n <MessageSquareText className=\"h-6 w-6 text-action-primary-bg\" />\n AI Watch-Concierge\n </span>\n }\n description=\"Describe a mood or genre and get AI picks verified against your live catalog.\"\n purpose=\"Ask for something to watch in plain language. This is a live AI assistant with real access to your catalog and entitlement data — it checks availability before answering rather than guessing, and surfaces titles from your published catalog as tappable posters.\"\n actions={\n hasConversation ? (\n <button\n type=\"button\"\n onClick={resetConversation}\n disabled={isBusy}\n className={cn(\n 'inline-flex items-center gap-2 rounded-lg border border-border-strong bg-bg-surface px-3 py-2 text-sm font-medium text-text-primary transition-colors',\n 'hover:bg-bg-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]',\n 'disabled:cursor-not-allowed disabled:opacity-50'\n )}\n >\n <RotateCcw className=\"h-4 w-4\" />\n New chat\n </button>\n ) : undefined\n }\n >\n <div className=\"mx-auto flex h-full max-w-3xl flex-col\">\n {/* Live-AI disclosure — always visible, unambiguous. */}\n <div\n role=\"note\"\n className=\"mb-4 flex shrink-0 items-center gap-2 rounded-lg border border-status-info-border bg-status-info-bg-subtle px-3 py-2 text-xs text-status-info-text\"\n >\n <Info className=\"h-4 w-4 shrink-0\" />\n <span>\n <strong className=\"font-semibold\">Live AI</strong> — answers can still be imperfect;\n availability and pricing are checked against your real catalog, but double-check before\n you buy.\n </span>\n </div>\n\n {/* Conversation / intro */}\n <div ref={scrollRef} className=\"flex-1 overflow-y-auto pr-1\">\n {!hasConversation ? (\n <div className=\"flex min-h-full flex-col items-center justify-center py-10 text-center\">\n <AiOrb size=\"lg\" active className=\"mb-5\" />\n <h2 className=\"text-xl font-semibold text-text-primary sm:text-2xl\">\n {firstName\n ? `Hi ${firstName} — what are you in the mood for?`\n : 'What are you in the mood for?'}\n </h2>\n <p className=\"mt-2 max-w-md text-sm text-text-secondary\">\n Tell me a vibe, a genre, or a title you loved. I'll surface real picks from what's\n published in your catalog.\n </p>\n <ChipRow onPick={send} disabled={isBusy} className=\"mt-6 justify-center\" />\n </div>\n ) : (\n <div className=\"space-y-5 py-2\" role=\"log\" aria-live=\"polite\" aria-relevant=\"additions\">\n {messages.map((message) => (\n <MessageRow key={message.id} message={message} onOpenTitle={openTitle} />\n ))}\n {isBusy &&\n (streamingText ? (\n <div className=\"flex items-start gap-3\">\n <AiOrb size=\"sm\" active />\n <div className=\"min-w-0 max-w-[85%] sm:max-w-[80%]\">\n <div className=\"rounded-2xl rounded-tl-sm border border-border-seam bg-bg-surface px-4 py-2.5 shadow-elevation-1\">\n <p className=\"whitespace-pre-wrap break-words text-sm text-text-primary\">\n {streamingText}\n </p>\n </div>\n </div>\n </div>\n ) : (\n <TypingIndicator />\n ))}\n </div>\n )}\n </div>\n\n {/* Composer */}\n <div className=\"shrink-0 pt-4\">\n {hasConversation && <ChipRow onPick={send} disabled={isBusy} className=\"mb-3\" />}\n <GlassCard treatment=\"glass\" className=\"border-border-seam p-2\">\n <form onSubmit={handleSubmit} className=\"flex items-end gap-2\">\n <label htmlFor=\"concierge-input\" className=\"sr-only\">\n Message the watch-concierge\n </label>\n <textarea\n id=\"concierge-input\"\n rows={1}\n value={input}\n onChange={(event) => setInput(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder=\"Ask for something to watch…\"\n disabled={isBusy}\n className={cn(\n 'max-h-32 min-h-[2.5rem] flex-1 resize-none bg-transparent px-3 py-2 text-sm text-text-primary',\n 'placeholder:text-text-secondary focus:outline-none disabled:opacity-60'\n )}\n />\n <button\n type=\"submit\"\n disabled={isBusy || input.trim().length === 0}\n aria-label=\"Send message\"\n className={cn(\n 'inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-action-primary-bg text-action-primary-text shadow-elevation-1 transition-opacity',\n 'hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]',\n 'disabled:cursor-not-allowed disabled:opacity-40'\n )}\n >\n <Send className=\"h-4 w-4\" />\n </button>\n </form>\n </GlassCard>\n <p className=\"mt-2 text-center text-[11px] text-text-muted\">\n Press Enter to send · Shift + Enter for a new line\n </p>\n </div>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/microfe-hookmovies",
|
|
3
|
-
"version": "2026.726.
|
|
3
|
+
"version": "2026.726.4",
|
|
4
4
|
"description": "Burdenoff HookMovies Microfrontend — discovery, catalog, engagement, auditions, productions, distribution, and library surfaces for the global creative-media network.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|