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