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