apps/web/src/components/GlobalLayoutAi.tsx
1import { ALL_RESOURCE_TYPES } from '@local/config'
2import { DEFAULT_CONSENT_STATEMENT } from '@local/config/ai-prompts'
3import { getDocumentRaw } from '@local/sanity'
4import { createAsync, query, useLocation } from '@solidjs/router'
5import {
6 createMemo,
7 createSignal,
8 lazy,
9 onCleanup,
10 onMount,
11 Show,
12 Suspense,
13} from 'solid-js'
14import { isServer } from 'solid-js/web'
15import { getAiCapabilities, isAiVisible } from '~/lib/aiCapabilities.server'
16import {
17 type AiPageVisibility,
18 type AiVisibility,
19 aiPageVisible,
20} from '~/lib/aiPageVisibility'
21import { observeFunctionalConsent } from '~/lib/cookie-consent/consent-state'
22
23import {
24 isResourceDetailPathname,
25 normalizePathname,
26} from '~/lib/utils/pathname'
27
28function normalizeHexColor(raw: string | undefined): string | undefined {
29 if (!raw) return undefined
30 const v = raw.trim()
31 if (!v) return undefined
32 if (/^#[0-9A-Fa-f]{3,8}$/.test(v)) return v
33 if (/^[0-9A-Fa-f]{3,8}$/.test(v)) return `#${v}`
34 return undefined
35}
36
37type AiPageSettings = {
38 _type?: string
39 aiVisibility?: AiVisibility
40 aiDefaultMessage?: string
41 aiAccentColor?: { value?: string } | null
42 color?: { value?: string } | null
43} | null
44
45type AiGlobalSettings = {
46 defaultMessage?: string
47 pageVisibility?: AiPageVisibility
48 consentStatement?: string
49} | null
50
51/** Page + global AI settings in a single Sanity round trip. */
52const getAiSettings = query(async (pathname: string) => {
53 'use server'
54 const normalizedPathname = normalizePathname(pathname)
55
56 const resourceTypesWrapped = ALL_RESOURCE_TYPES.map(
57 (type) => `"${type}"`,
58 ).join(', ')
59
60 const capabilities = await getAiCapabilities()
61 const visible = isAiVisible(capabilities)
62 if (!visible) return { visible: false, page: null, global: null }
63 const settings = (await getDocumentRaw(
64 `{
65 "page": *[
66 _type in [
67 "home", "page", "event", "company", "profile", ${resourceTypesWrapped}
68 ] &&
69 (
70 fullSlug == $pathname ||
71 slug.fullUrl == $pathname ||
72 (_type == "home" && $pathname == "/")
73 )
74 ][0]{
75 _type,
76 aiVisibility,
77 aiDefaultMessage,
78 aiAccentColor {
79 value
80 },
81 color {
82 value
83 }
84 },
85 "global": *[_type == "settings.ai"][0]{
86 defaultMessage,
87 pageVisibility{homepage,editorial,paris,miami,other},
88 consentStatement
89 }
90 }`,
91 { pathname: normalizedPathname },
92 )) as { page?: AiPageSettings; global?: AiGlobalSettings } | null
93 return { ...settings, visible }
94}, 'ai-settings')
95
96const LazyAiModule = lazy(() =>
97 import('@local/ai/components').then((m) => ({ default: m.AiModule })),
98)
99
100/** AI shell: async Sanity data + lazy-loaded assistant UI (separate chunk). */
101export function GlobalLayoutAi() {
102 const location = useLocation()
103 const [verifiedVisible, setVerifiedVisible] = createSignal(false)
104 const [cookiesAccepted, setCookiesAccepted] = createSignal(false)
105 const [hasAiContent, setHasAiContent] = createSignal(false)
106 const [isFooterVisible, setIsFooterVisible] = createSignal(false)
107
108 const aiSettings = createAsync(() => getAiSettings(location.pathname), {
109 deferStream: true,
110 })
111
112 const assistantMessage = createMemo(() => {
113 const settings = aiSettings()
114 const pageMessage = settings?.page?.aiDefaultMessage?.trim()
115 if (pageMessage && pageMessage.length > 0) return pageMessage
116 return settings?.global?.defaultMessage
117 })
118
119 /** Per-page Sanity override, else event `color`, else default gray (no accent). */
120 const accentColor = createMemo(() => {
121 const page = aiSettings()?.page
122 const fromPage = normalizeHexColor(page?.aiAccentColor?.value)
123 if (fromPage) return fromPage
124 if (page?._type === 'event') return normalizeHexColor(page.color?.value)
125 return undefined
126 })
127
128 const isResourceDetail = createMemo(() =>
129 isResourceDetailPathname(location.pathname),
130 )
131
132 // Where the concierge is offered: pages with an AI-linked section, every
133 // event page (the concierge is built around the events, and an event page
134 // must not depend on an editor adding a prompt row to a section), and pages
135 // with their own AI settings in Sanity.
136 const isAiEnabled = createMemo(() => {
137 const page = aiSettings()?.page
138 return aiPageVisible({
139 path: location.pathname,
140 editorial: ALL_RESOURCE_TYPES.some((type) => type === page?._type),
141 page: page?.aiVisibility,
142 defaults: aiSettings()?.global?.pageVisibility,
143 legacy:
144 hasAiContent() ||
145 location.pathname.startsWith('/events/') ||
146 Boolean(page?.aiDefaultMessage?.trim() || page?.aiAccentColor?.value),
147 })
148 })
149
150 // The module lives in the bottom-right corner on every page (closed: just
151 // the button; open: the panel grows towards the centre from that corner).
152 const aiShellJustifyClass = () => 'justify-end'
153
154 /** Resource routes: extra right inset so the module isn’t flush to the viewport edge. */
155 const aiShellPaddingClass = createMemo(() =>
156 isResourceDetail() ? 'pt-20 pb-20 pl-20 pr-28 max-lg:pr-24' : 'p-20',
157 )
158
159 onMount(() => {
160 if (isServer) return
161
162 onCleanup(observeFunctionalConsent(setCookiesAccepted))
163
164 // Cached HTML must never authorize showing the widget. Recheck open tabs
165 // periodically; failures hide it, while the API independently blocks requests.
166 let disposed = false
167 let visibilityRequest = 0
168 const refreshVisibility = async () => {
169 const requestId = ++visibilityRequest
170 try {
171 const response = await fetch('/api/ai/availability', {
172 cache: 'no-store',
173 signal: AbortSignal.timeout(5000),
174 })
175 const body = response.ok ? await response.json() : null
176 if (!disposed && requestId === visibilityRequest)
177 setVerifiedVisible(body?.visible === true)
178 } catch {
179 if (!disposed && requestId === visibilityRequest) setVerifiedVisible(false)
180 }
181 }
182 void refreshVisibility()
183 const visibilityInterval = setInterval(refreshVisibility, 30_000)
184 window.addEventListener('focus', refreshVisibility)
185 onCleanup(() => {
186 disposed = true
187 clearInterval(visibilityInterval)
188 window.removeEventListener('focus', refreshVisibility)
189 })
190
191 let footerObserver: IntersectionObserver | undefined
192 let observedFooter: HTMLElement | null = null
193
194 const observeFooter = () => {
195 const footer = document.querySelector('[data-site-footer]')
196 if (!(footer instanceof HTMLElement) || footer === observedFooter) return
197 footerObserver?.disconnect()
198 observedFooter = footer
199 footerObserver = new IntersectionObserver(
200 ([entry]) => {
201 setIsFooterVisible(entry?.isIntersecting ?? false)
202 },
203 { threshold: 0.01 },
204 )
205 footerObserver.observe(footer)
206 }
207
208 const updateFromDom = () => {
209 setHasAiContent(document.querySelector('[data-ai-link]') != null)
210 observeFooter()
211 }
212
213 // Single rAF-throttled observer: DOM churn batches to one check per frame.
214 let rafId: number | undefined
215 const scheduleUpdate = () => {
216 if (rafId !== undefined) return
217 rafId = requestAnimationFrame(() => {
218 rafId = undefined
219 updateFromDom()
220 })
221 }
222
223 updateFromDom()
224 const observer = new MutationObserver(scheduleUpdate)
225 observer.observe(document.body, {
226 childList: true,
227 subtree: true,
228 attributes: true,
229 attributeFilter: ['data-ai-link'],
230 })
231
232 onCleanup(() => {
233 if (rafId !== undefined) cancelAnimationFrame(rafId)
234 observer.disconnect()
235 footerObserver?.disconnect()
236 })
237 })
238
239 return (
240 <div
241 class={`pointer-events-none fixed inset-0 z-50 flex items-end ${aiShellPaddingClass()} ${aiShellJustifyClass()}`}
242 >
243 <Suspense fallback={null}>
244 <Show
245 when={cookiesAccepted() && verifiedVisible() && aiSettings()?.visible}
246 >
247 <LazyAiModule
248 pathname={location.pathname}
249 enabled={isAiEnabled()}
250 defaultAssistantMessage={assistantMessage()}
251 consentStatement={
252 aiSettings()?.global?.consentStatement?.trim() ||
253 DEFAULT_CONSENT_STATEMENT
254 }
255 accentColor={accentColor()}
256 hideForFooter={isFooterVisible()}
257 />
258 </Show>
259 </Suspense>
260 </div>
261 )
262}
263