@burdenoff/microfe-hookmovies 2026.726.3 → 2026.731.1

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;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EACL,KAAK,EAAE,EAQR,MAAM,OAAO,CAAC;AA+af,eAAO,MAAM,aAAa,EAAE,EA8M3B,CAAC"}
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 { useCallback as o, useEffect as s, useMemo as c, useRef as l, useState as u } from "react";
8
- import { AlertCircle as d, Film as f, Info as p, MessageSquareText as m, RotateCcw as h, Send as g, Sparkles as _, Wand2 as v } from "lucide-react";
9
- import { jsx as y, jsxs as b } from "react/jsx-runtime";
10
- import { GlassCard as x } from "@burdenoff/fe-libs/ui";
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 S = {
14
+ var w = {
13
15
  limit: 8,
14
16
  offset: 0
15
- }, C = 700, w = "Simulated response — not a live model";
16
- function T(e) {
17
- let { prompt: t, titles: n, failed: r } = e;
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 A = [
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
- ], j = ({ size: e = "sm", active: n = !1, className: r }) => {
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__ */ b("span", {
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__ */ y("span", { className: t("absolute inset-0 rounded-full bg-action-primary-bg opacity-30 blur-md", n && "motion-safe:animate-ping") }), /* @__PURE__ */ y("span", {
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__ */ y(_, { className: a })
35
+ children: /* @__PURE__ */ b(v, { className: a })
60
36
  })]
61
37
  });
62
- }, M = ({ title: e, onOpen: n }) => {
63
- let { data: r, isLoading: o } = i(e.posterFileId), [s, c] = u(!1), l = !!e.posterFileId && !!r && !s, d = [a(e.type), e.releaseYear ? String(e.releaseYear) : null].filter(Boolean).join(" · ");
64
- return /* @__PURE__ */ b("button", {
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__ */ b("div", {
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__ */ y("img", {
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__ */ y("div", {
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__ */ y(f, { className: "h-6 w-6" })
82
- }), e.aiDisclosure && /* @__PURE__ */ b("span", {
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__ */ y(_, { className: "h-2.5 w-2.5" }), "AI"]
61
+ children: [/* @__PURE__ */ b(v, { className: "h-2.5 w-2.5" }), "AI"]
86
62
  })]
87
63
  }),
88
- /* @__PURE__ */ y("p", {
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
- d && /* @__PURE__ */ y("p", {
68
+ u && /* @__PURE__ */ b("p", {
93
69
  className: "line-clamp-1 text-[11px] text-text-secondary",
94
- children: d
70
+ children: u
95
71
  })
96
72
  ]
97
73
  });
98
- }, N = ({ message: e, onOpenTitle: n }) => e.role === "user" ? /* @__PURE__ */ y("div", {
74
+ }, M = ({ message: e, onOpenTitle: n }) => e.role === "user" ? /* @__PURE__ */ b("div", {
99
75
  className: "flex justify-end",
100
- children: /* @__PURE__ */ y("div", {
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__ */ y("p", {
78
+ children: /* @__PURE__ */ b("p", {
103
79
  className: "whitespace-pre-wrap break-words",
104
80
  children: e.text
105
81
  })
106
82
  })
107
- }) : /* @__PURE__ */ b("div", {
83
+ }) : /* @__PURE__ */ x("div", {
108
84
  className: "flex items-start gap-3",
109
- children: [/* @__PURE__ */ y(j, { size: "sm" }), /* @__PURE__ */ b("div", {
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__ */ b("div", {
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__ */ y("p", {
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.note && /* @__PURE__ */ b("p", {
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__ */ y(p, { className: "h-3 w-3" }), e.note]
97
+ children: [/* @__PURE__ */ b(m, { className: "h-3 w-3" }), e.note]
119
98
  })]
120
- }), e.titles && e.titles.length > 0 && /* @__PURE__ */ y("div", {
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__ */ y(M, {
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
- }), P = () => /* @__PURE__ */ b("div", {
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__ */ y(j, {
111
+ children: [/* @__PURE__ */ b(A, {
133
112
  size: "sm",
134
113
  active: !0
135
- }), /* @__PURE__ */ y("div", {
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__ */ y("span", {
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__ */ y("span", {
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
- }), F = ({ onPick: e, disabled: n, className: r }) => /* @__PURE__ */ y("div", {
128
+ }), P = ({ onPick: e, disabled: n, className: r }) => /* @__PURE__ */ b("div", {
150
129
  className: t("flex flex-wrap gap-2", r),
151
- children: A.map((r) => /* @__PURE__ */ b("button", {
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(v, { className: "h-3 w-3 text-action-primary-bg" }), r]
135
+ children: [/* @__PURE__ */ b(y, { className: "h-3 w-3 text-action-primary-bg" }), r]
157
136
  }, r))
158
- }), I = () => {
159
- let { navigateTo: i, currentUser: a } = e(), [f, p] = u([]), [_, v] = u(""), [w, E] = u(null), [O, A] = u(!1), M = l(null), I = r({ status: "PUBLISHED" }, S, w?.term), L = o((e) => i(`title/${e}`), [i]);
160
- s(() => {
161
- if (!w) return;
162
- A(!1);
163
- let e = window.setTimeout(() => A(!0), C);
164
- return () => window.clearTimeout(e);
165
- }, [w]), s(() => {
166
- if (!w || !O || I.isFetching) return;
167
- let e = I.isSuccess ? I.data.items : [], t = T({
168
- prompt: w.prompt,
169
- titles: e,
170
- failed: I.isError
171
- });
172
- p((n) => [...n, {
173
- id: w.turnId,
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: t.text,
176
- note: t.note,
166
+ text: I.text,
167
+ failed: I.failed,
168
+ note: t,
177
169
  titles: e
178
- }]), E(null);
170
+ }]), k(null), L(null), F("");
179
171
  }, [
180
- w,
181
- O,
182
- I.isFetching,
183
- I.isSuccess,
184
- I.isError,
185
- I.data
186
- ]), s(() => {
187
- M.current?.scrollTo({
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
- }, [f, w]);
192
- let R = o((e) => {
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 || w || (p((e) => [...e, {
195
- id: D(),
189
+ !t || D || (v((e) => [...e, {
190
+ id: E(),
196
191
  role: "user",
197
192
  text: t
198
- }]), v(""), E({
199
- turnId: D(),
193
+ }]), T(""), k({
194
+ turnId: E(),
200
195
  prompt: t,
201
- term: k(t)
196
+ term: O(t)
202
197
  }));
203
- }, [w]), z = (e) => {
204
- e.preventDefault(), R(_);
205
- }, B = (e) => {
206
- e.key === "Enter" && !e.shiftKey && (e.preventDefault(), R(_));
207
- }, V = () => {
208
- p([]), E(null), v("");
209
- }, H = c(() => {
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]), U = w !== null, W = f.length > 0 || U;
215
- return /* @__PURE__ */ y(n, {
209
+ }, [a]), q = D !== null, J = p.length > 0 || q;
210
+ return /* @__PURE__ */ b(n, {
216
211
  hero: !0,
217
- title: /* @__PURE__ */ b("span", {
212
+ title: /* @__PURE__ */ x("span", {
218
213
  className: "inline-flex items-center gap-2",
219
- children: [/* @__PURE__ */ y(m, { className: "h-6 w-6 text-action-primary-bg" }), "AI Watch-Concierge"]
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 real catalog picks back, instantly.",
222
- 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.",
223
- actions: W ? /* @__PURE__ */ b("button", {
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: V,
226
- disabled: U,
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__ */ y(h, { className: "h-4 w-4" }), "New chat"]
223
+ children: [/* @__PURE__ */ b(g, { className: "h-4 w-4" }), "New chat"]
229
224
  }) : void 0,
230
- children: /* @__PURE__ */ b("div", {
225
+ children: /* @__PURE__ */ x("div", {
231
226
  className: "mx-auto flex h-full max-w-3xl flex-col",
232
227
  children: [
233
- /* @__PURE__ */ b("div", {
228
+ /* @__PURE__ */ x("div", {
234
229
  role: "note",
235
- 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",
236
- children: [/* @__PURE__ */ y(d, { className: "h-4 w-4 shrink-0" }), /* @__PURE__ */ b("span", { children: [/* @__PURE__ */ y("strong", {
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: "Preview"
239
- }), " — AI responses are simulated. Title recommendations are pulled live from your real catalog."] })]
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__ */ y("div", {
242
- ref: M,
236
+ /* @__PURE__ */ b("div", {
237
+ ref: R,
243
238
  className: "flex-1 overflow-y-auto pr-1",
244
- children: W ? /* @__PURE__ */ b("div", {
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: [f.map((e) => /* @__PURE__ */ y(N, {
244
+ children: [p.map((e) => /* @__PURE__ */ b(M, {
250
245
  message: e,
251
- onOpenTitle: L
252
- }, e.id)), U && /* @__PURE__ */ y(P, {})]
253
- }) : /* @__PURE__ */ b("div", {
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__ */ y(j, {
266
+ /* @__PURE__ */ b(A, {
257
267
  size: "lg",
258
268
  active: !0,
259
269
  className: "mb-5"
260
270
  }),
261
- /* @__PURE__ */ y("h2", {
271
+ /* @__PURE__ */ b("h2", {
262
272
  className: "text-xl font-semibold text-text-primary sm:text-2xl",
263
- children: H ? `Hi ${H} — what are you in the mood for?` : "What are you in the mood for?"
273
+ children: K ? `Hi ${K} — what are you in the mood for?` : "What are you in the mood for?"
264
274
  }),
265
- /* @__PURE__ */ y("p", {
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__ */ y(F, {
270
- onPick: R,
271
- disabled: U,
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__ */ b("div", {
287
+ /* @__PURE__ */ x("div", {
278
288
  className: "shrink-0 pt-4",
279
289
  children: [
280
- W && /* @__PURE__ */ y(F, {
281
- onPick: R,
282
- disabled: U,
290
+ J && /* @__PURE__ */ b(P, {
291
+ onPick: H,
292
+ disabled: q,
283
293
  className: "mb-3"
284
294
  }),
285
- /* @__PURE__ */ y(x, {
295
+ /* @__PURE__ */ b(S, {
286
296
  treatment: "glass",
287
297
  className: "border-border-seam p-2",
288
- children: /* @__PURE__ */ b("form", {
289
- onSubmit: z,
298
+ children: /* @__PURE__ */ x("form", {
299
+ onSubmit: U,
290
300
  className: "flex items-end gap-2",
291
301
  children: [
292
- /* @__PURE__ */ y("label", {
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__ */ y("textarea", {
307
+ /* @__PURE__ */ b("textarea", {
298
308
  id: "concierge-input",
299
309
  rows: 1,
300
- value: _,
301
- onChange: (e) => v(e.target.value),
302
- onKeyDown: B,
310
+ value: y,
311
+ onChange: (e) => T(e.target.value),
312
+ onKeyDown: W,
303
313
  placeholder: "Ask for something to watch…",
304
- disabled: U,
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__ */ y("button", {
317
+ /* @__PURE__ */ b("button", {
308
318
  type: "submit",
309
- disabled: U || _.trim().length === 0,
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__ */ y(g, { className: "h-4 w-4" })
322
+ children: /* @__PURE__ */ b(_, { className: "h-4 w-4" })
313
323
  })
314
324
  ]
315
325
  })
316
326
  }),
317
- /* @__PURE__ */ y("p", {
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 { I as ConciergePage };
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",
3
+ "version": "2026.731.1",
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": [
@@ -26,15 +26,15 @@
26
26
  "types": "./dist/index.d.ts",
27
27
  "scripts": {
28
28
  "dev": "vite",
29
- "build": "tsc && vite build",
29
+ "build": "tsc && bash scripts/build-limits.sh --gate vite build",
30
30
  "schema:pull": "graphql-codegen --project schema-wspace && graphql-codegen --project schema-global",
31
31
  "codegen": "graphql-codegen --project wspace && graphql-codegen --project global",
32
32
  "codegen:all": "bun run schema:pull && bun run codegen",
33
33
  "lint": "eslint .",
34
34
  "lint:sanity": "if [ \"${CI:-}\" = \"true\" ]; then bun run lint; else bun run lint -- --cache --cache-location .eslintcache; fi",
35
35
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
36
- "type:check": "tsc --noEmit",
37
- "sanity": "bun run codegen && bun run lint:sanity && bun run format && bun run type:check && bun run build && bun run build-storybook",
36
+ "type:check": "bash scripts/build-limits.sh tsc --noEmit",
37
+ "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run codegen && bun run lint:sanity && bun run format && bun run type:check && bun run build && bun run build-storybook'",
38
38
  "storybook": "storybook dev -p 6006",
39
39
  "build-storybook": "storybook build",
40
40
  "test:storybook": "test-storybook",