apps/web/src/lib/badges.ts

1import { getPublishedSanityClient } from '@local/sanity/client'
2import { sanityFetch } from '@local/sanity/utils/fetch'
3import { extractSlicesText, pageTitle } from '~/lib/pageCatalogue'
4import { type BadgePrice, loadBadgePricing } from './livePricing.server'
5
6/**
7 * Deterministic badge / pass lookup for the concierge. Similarity search over
8 * the event pages answers "what does the Explorer badge include?" well but
9 * "do you have info on tickets?" badly — the FAQ file wins and the badges
10 * never get named. This reads the badge pages themselves (Badges overview,
11 * every /attend/* page) straight from Sanity so a ticket question always has
12 * the full set of names, audience and inclusions. Individual prices come only
13 * from the shared live registration-price resolver and published CMS policy.
14 */
15
16export type BadgePage = {
17	title: string
18	url: string
19	/** Visitor-facing copy for inclusions and eligibility; not an individual price source. */
20	content: string
21}
22
23export type BadgeLookup = {
24	pricing: BadgePrice[]
25	pricingRule: string
26	event: string
27	badgesPage: string | null
28	attendPage: string | null
29	pages: BadgePage[]
30}
31
32/** Keep each page's copy bounded so six pages fit one tool result comfortably. */
33const CONTENT_MAX_CHARS = 2600
34
35const BADGES_CACHE_TTL_MS = 10 * 60_000
36const badgesCache = new Map<string, { value: BadgeLookup; loadedAt: number }>()
37
38/** Pure shaping step, separated from the fetch so it can be unit-tested. */
39export function summarizeBadgePages(
40	eventSlug: string,
41	docs: Array<{
42		_type: string
43		slug?: string
44		agentTitle?: string
45		title?: string
46		description?: string
47		slices?: unknown
48	}>,
49): BadgeLookup {
50	const pages: BadgePage[] = []
51	for (const doc of docs) {
52		const slug = doc.slug?.trim() ?? ''
53		if (!slug) continue
54		const content = extractSlicesText(doc.slices)
55			.split('\n')
56			.filter(
57				(line) =>
58					!/(?:[€$£]\s*\d|\b(?:EUR|USD|GBP)\s*\d|\d[\d,.]*\s*(?:EUR|USD|GBP|euros?|dollars?|pounds?)\b)/i.test(
59						line,
60					),
61			)
62			.join('\n')
63		pages.push({
64			title: pageTitle(doc, slug),
65			url: slug,
66			content:
67				content.length > CONTENT_MAX_CHARS
68					? `${content.slice(0, CONTENT_MAX_CHARS).trimEnd()}…`
69					: content,
70		})
71	}
72	// Overview first, then the attend hub, then the individual badge pages —
73	// the order the model should present them in.
74	const rank = (url: string) =>
75		url === `${eventSlug}/badges` ? 0 : url === `${eventSlug}/attend` ? 1 : 2
76	pages.sort((a, b) => rank(a.url) - rank(b.url) || a.url.localeCompare(b.url))
77	return {
78		pricing: [],
79		pricingRule:
80			'Individual badge prices may ONLY be quoted from pricing above, never from page descriptions, SEO, content excerpts, search results or past conversation. Empty pricing means no verified price is available. Copy displayPrice exactly, including + VAT. Each price belongs only to its own badge id/URL; never transfer prices between badges. Preserve application-only status and never guarantee application approval. Do not invent discounts, tax treatment or eligibility. Page content supports inclusions and application links, not individual price claims.',
81		event: eventSlug,
82		badgesPage: pages.find((p) => p.url === `${eventSlug}/badges`)?.url ?? null,
83		attendPage: pages.find((p) => p.url === `${eventSlug}/attend`)?.url ?? null,
84		pages,
85	}
86}
87
88export async function loadBadgePages(eventSlug: string): Promise<BadgeLookup> {
89	const cached = badgesCache.get(eventSlug)
90	if (cached && Date.now() - cached.loadedAt < BADGES_CACHE_TTL_MS) {
91		return { ...cached.value, pricing: await loadBadgePricing(eventSlug) }
92	}
93	const docs = await sanityFetch<
94		Array<{
95			_type: string
96			slug?: string
97			agentTitle?: string
98			title?: string
99			description?: string
100			slices?: unknown
101		}>
102	>(
103		getPublishedSanityClient(),
104		`*[_type == "page" && defined(fullSlug) && (fullSlug == $badges || fullSlug == $attend || string::startsWith(fullSlug, $attendPrefix))]{
105      _type,
106      "slug": fullSlug,
107      agentTitle,
108      title,
109      slices
110    }`,
111		{
112			badges: `${eventSlug}/badges`,
113			attend: `${eventSlug}/attend`,
114			attendPrefix: `${eventSlug}/attend/`,
115		},
116		{ tag: 'badge-pages' },
117	)
118	const value = {
119		...summarizeBadgePages(eventSlug, docs),
120		pricing: await loadBadgePricing(eventSlug),
121	}
122	badgesCache.set(eventSlug, { value, loadedAt: Date.now() })
123	return value
124}
125