apps/web/src/lib/ticketAnswer.ts
1import type { UserProfile } from '@local/ai'
2import { requestsTicketQuote } from './aiFlows'
3import type { BadgePrice } from './livePricing.server'
4
5type Turn = { role: 'user' | 'assistant'; text: string }
6export type TicketAnswerKind = 'price' | 'comparison' | 'other'
7
8/** Conservative baseline: ambiguous requests remain with the concierge. */
9export function ticketAnswerKind(
10 message: string,
11 history: Turn[] = [],
12): TicketAnswerKind {
13 if (requestsTicketQuote(message)) return 'other'
14 // Startup-package pricing is distinct from the standard delegate catalogue.
15 if (/\bstart.?up\b/i.test(message)) return 'other'
16 const context = `${message} ${history
17 .slice(-6)
18 .map((t) => t.text)
19 .join(' ')}`
20 if (!/\b(?:ticket|badge|pass|admission|diamond|explorer)s?\b/i.test(context))
21 return 'other'
22 const lastAssistant =
23 history.findLast((turn) => turn.role === 'assistant')?.text || ''
24 if (
25 /\bwhich pass would you like\b/i.test(lastAssistant) &&
26 /^(?:(?:one|two|three|four|five|\d+) tickets?)(?: please)?[.!\s]*$/i.test(
27 message.trim(),
28 )
29 )
30 return 'comparison'
31 // A passing reference to price or an upgrade must not swallow the benefits question.
32 if (
33 /\b(?:benefits|perks|what(?:['’]s| is) included|what.{0,20}includes?)\b/i.test(
34 message,
35 )
36 )
37 return 'other'
38 if (
39 /\b(?:refund|invoice|cancel|transfer|discount|group|free|sponsor(?:ship|ing)?|exhibit(?:ing|ion)?|parking|hotels?|accommodation|agenda|articles?|venue)\b/i.test(
40 message,
41 )
42 )
43 return 'other'
44 if (
45 /\b(?:buy|book|purchase|send|submit)\b/i.test(message) &&
46 !/\b(?:cost|price|much)\b/i.test(message)
47 )
48 return 'other'
49 if (
50 /\b(?:cost|price|prices|pricing|how much|VAT|set me back)\b/i.test(message)
51 )
52 return 'price'
53 if (
54 /\b(?:explorer|diamond|general (?:admission|attendee))\b.*\b\d[\d,.]*\b.*(?:right|still|correct|\?)|\b(?:is|isn['’]t)\b.*\b(?:explorer|diamond)\b.*\d/i.test(
55 message,
56 )
57 )
58 return 'price'
59 if (
60 /\b(?:compare|comparison|difference|differences|recommend|choose|choosing|which is right|torn between|which (?:badge|pass) is right)\b|^(?:help(?: me)?(?: please)?|please help|not sure)[.!? ]*$/i.test(
61 message,
62 )
63 )
64 return 'comparison'
65 return 'other'
66}
67
68/** Money and application status are copied from a verified record, never generated. */
69export function ticketFactsReply(
70 event: 'paris' | 'miami',
71 kind: TicketAnswerKind,
72 message: string,
73 history: Turn[] = [],
74 records: BadgePrice[] = [],
75 profile?: Pick<
76 UserProfile,
77 'role' | 'company' | 'ticketQuantity' | 'ticketQuantityRange'
78 >,
79): string | undefined {
80 if (kind === 'other') return
81 const slug = `/events/unleash-${event}`
82 if (!records.length)
83 return `I don’t have a currently verified price for UNLEASH in ${event === 'paris' ? 'Paris' : 'Miami'}. Please check the [event page](${slug}) for the current ticket options; I won’t estimate a price.`
84 const mentioned = (text: string) =>
85 records.filter((p) =>
86 new RegExp(
87 p.id === 'general' ? 'general (?:admission|attendee)' : `\\b${p.id}\\b`,
88 'i',
89 ).test(text),
90 )
91 let selected = mentioned(message)
92 if (!selected.length && kind === 'price') {
93 const previous = [...history].reverse().find((t) => t.role === 'assistant')
94 const candidates = mentioned(previous?.text || '')
95 if (candidates.length === 1) selected = candidates
96 }
97 if (!selected.length) selected = records
98 const rows = selected.map(
99 (p) =>
100 `- [${p.badge}](${p.url}): **${p.displayPrice}**${p.amount === null ? '' : ' per pass'}${kind === 'comparison' ? ` — ${p.summary}` : ''}`,
101 )
102 const prior =
103 history.at(-1)?.role === 'user' && history.at(-1)?.text === message
104 ? history.slice(0, -1)
105 : history
106 const repeatedHelp =
107 kind === 'comparison' &&
108 /^(?:help(?: me)?(?: please)?|please help|not sure)[.!? ]*$/i.test(message) &&
109 (prior.some(
110 (t) =>
111 t.role === 'user' &&
112 /^(?:help(?: me)?(?: please)?|please help|not sure)[.!? ]*$/i.test(t.text),
113 ) ||
114 /What matters most to you: the main conference, company visits, or Day 1 leadership sessions\?/i.test(
115 prior.findLast((t) => t.role === 'assistant')?.text || '',
116 ))
117 if (repeatedHelp) {
118 if (
119 /This is a recommendation, not a selected or reserved pass/.test(
120 prior.findLast((t) => t.role === 'assistant')?.text || '',
121 )
122 ) {
123 const general = records.find((p) => p.id === 'general')
124 const diamond = records.find((p) => p.id === 'diamond')
125 if (general && diamond)
126 return `The key decision is Day 1: [${general.badge}](${general.url}) covers the main conference on Days 2–3; [${diamond.badge}](${diamond.url}) adds Day 1 Summits & Salons. Separate invitation-only programmes are not guaranteed.\n\nDo you want Day 1 leadership sessions included?`
127 }
128 const context = history
129 .filter((t) => t.role === 'user')
130 .map((t) => t.text)
131 .join(' ')
132 const id = /\b(?:day 1|leadership sessions?|summits?)\b/i.test(context)
133 ? 'diamond'
134 : /\b(?:company visits?|expeditions?)\b/i.test(context)
135 ? 'explorer'
136 : 'general'
137 const recommended = records.find((p) => p.id === id)
138 if (recommended)
139 return `${profile?.role ? `With your role as ${profile.role}, ` : ''}I’d start with [${recommended.badge}](${recommended.url}): ${recommended.summary} **${recommended.displayPrice}**. This is a recommendation, not a selected or reserved pass.\n\nWould that fit your plans?`
140 }
141 return (
142 (profile?.ticketQuantity || profile?.ticketQuantityRange
143 ? `${profile.ticketQuantityRange || profile.ticketQuantity} ${profile.ticketQuantity === 1 ? 'ticket' : 'tickets'} noted.\n\n`
144 : '') +
145 rows.join('\n') +
146 (kind === 'comparison'
147 ? '\n\nWhat matters most to you: the main conference, company visits, or Day 1 leadership sessions?'
148 : '\n\nThese are the verified published ticket terms; an enquiry does not reserve or purchase a pass.')
149 )
150}
151