apps/web/src/lib/livePricing.server.ts
1import { getPublishedSanityClient } from '@local/sanity/client'
2import { sanityFetch } from '@local/sanity/utils/fetch'
3import { getInwinkToken } from '@local/utils'
4import { z } from 'zod'
5import {
6 type PriceProduct,
7 productSchema,
8 type ResolvedPrice,
9 resolvePublicPrice,
10} from './livePricing'
11
12const groupSchema = z.object({
13 eventId: z.string(),
14 passes: z.array(
15 z.object({
16 title: z.string(),
17 inwinkCategoryId: z.string().nullish(),
18 tax: z.enum(['none', 'vat', 'sales-tax']).nullish(),
19 pricingOverride: z
20 .object({ price: z.number().finite().nonnegative().nullish() })
21 .nullish(),
22 }),
23 ),
24})
25const rowsSchema = z.array(
26 z.object({ slug: z.string(), groups: z.array(groupSchema).nullable() }),
27)
28const configs = new Map<
29 string,
30 { expires: number; rows: z.infer<typeof rowsSchema> }
31>()
32const products = new Map<string, { expires: number; data: PriceProduct[] }>()
33export function clearLivePricingCache() {
34 configs.clear()
35 products.clear()
36}
37export async function loadPriceConfiguration(eventId = '', slug = '') {
38 const key = eventId || slug
39 const cached = configs.get(key)
40 if (cached && cached.expires > Date.now()) return cached.rows
41 const raw = await sanityFetch(
42 getPublishedSanityClient().withConfig({ useCdn: false }),
43 `*[_type == "page" && (fullSlug == $slug || ($eventId != "" && $eventId in slices[].subsections[].eventId))]{"slug":fullSlug,"groups":array::compact(slices[].subsections[][_type == "eventPasses"]{eventId,passes[]{title,inwinkCategoryId,tax,pricingOverride{price}}})}`,
44 { eventId, slug },
45 { tag: 'badge-pages' },
46 )
47 const rows = rowsSchema.parse(raw)
48 configs.set(key, { expires: Date.now() + 60_000, rows })
49 return rows
50}
51export async function loadPublicProducts(
52 eventId: string,
53): Promise<PriceProduct[]> {
54 const cached = products.get(eventId)
55 if (cached && cached.expires > Date.now()) return cached.data
56 const token = await getInwinkToken()
57 const all: PriceProduct[] = []
58 for (let index = 0; index < 20; index++) {
59 const response = await fetch(
60 `https://my.inwink.com/api/event/${encodeURIComponent(eventId)}/Product/query`,
61 {
62 method: 'POST',
63 signal: AbortSignal.timeout(8000),
64 headers: {
65 Authorization: `Bearer ${token}`,
66 'Content-Type': 'application/json',
67 },
68 body: JSON.stringify({
69 selects: { $all: true },
70 filters: { showcms: true },
71 page: { index, size: 100 },
72 }),
73 },
74 )
75 if (!response.ok) throw new Error('Registration prices unavailable')
76 const page = z
77 .object({ data: z.array(productSchema) })
78 .parse(await response.json()).data
79 all.push(...page)
80 if (page.length < 100) {
81 products.set(eventId, { expires: Date.now() + 60_000, data: all })
82 return all
83 }
84 }
85 throw new Error('Registration product pagination limit exceeded')
86}
87
88/** Re-evaluate date bands on every read, even when upstream data is cached. */
89export async function loadEventPublicPrices(eventId: string) {
90 const rows = await loadPriceConfiguration(eventId)
91 const canonical = rows.filter((r) => r.slug.endsWith('/badges'))
92 const groups = (canonical.length ? canonical : rows)
93 .flatMap((r) => r.groups || [])
94 .filter((g) => g.eventId === eventId)
95 const result: Record<string, ResolvedPrice> = {}
96 if (!groups.length) return result
97 const data = await loadPublicProducts(eventId)
98 for (const group of groups)
99 for (const pass of group.passes) {
100 const category = pass.inwinkCategoryId
101 if (!category) continue
102 const price = resolvePublicPrice(data, eventId, {
103 category,
104 tax: pass.tax ?? undefined,
105 override: pass.pricingOverride?.price ?? undefined,
106 applicationOnly: /^explorer(?: badge)?$/i.test(pass.title),
107 })
108 const previous = result[category]
109 if (
110 previous &&
111 [
112 previous.status,
113 previous.amount,
114 previous.currency,
115 previous.taxLabel,
116 previous.productId,
117 ].join('|') !==
118 [
119 price.status,
120 price.amount,
121 price.currency,
122 price.taxLabel,
123 price.productId,
124 ].join('|')
125 )
126 result[category] = {
127 status: 'conflict',
128 amount: null,
129 currency: null,
130 taxLabel: '',
131 displayPrice: 'Price unavailable',
132 reason: 'Conflicting CMS display policies',
133 }
134 else result[category] = price
135 }
136 return result
137}
138
139const details = [
140 {
141 id: 'general',
142 badge: 'General Attendee',
143 path: 'general-attendee',
144 title: /general (?:admission|attendee)/i,
145 summary:
146 'Days 2–3 conference access, 7 stages, exhibition and official networking. Day 1 premium programmes are not included.',
147 },
148 {
149 id: 'explorer',
150 badge: 'Explorer',
151 path: 'explorer',
152 title: /^explorer(?: badge)?$/i,
153 summary:
154 'Day 1 Expedition, Executive Dinner and full three-day access. Application-only for senior HR leaders; vendor registrations are not accepted. Approval is not automatic.',
155 },
156 {
157 id: 'diamond',
158 badge: 'Diamond',
159 path: 'diamond',
160 title: /^diamond(?: badge)?$/i,
161 summary:
162 'General Admission access plus Day 1 Summits & Salons and premium benefits. Separate invitation-only programmes are not guaranteed.',
163 },
164]
165export async function loadBadgePricing(eventSlug: string) {
166 // These curated badge inclusions apply only to the Paris edition.
167 if (eventSlug !== '/events/unleash-paris') return []
168 const rows = await loadPriceConfiguration('', `${eventSlug}/badges`)
169 const groups = rows.flatMap((r) => r.groups || [])
170 if (groups.length !== 1) return []
171 const group = groups[0]
172 let live: Record<string, ResolvedPrice> = {}
173 try {
174 live = await loadEventPublicPrices(group.eventId)
175 } catch {
176 /* Unknown prices stay unavailable; application policy still applies. */
177 }
178 return details.flatMap((detail) => {
179 const pass = group.passes.find((p) => detail.title.test(p.title))
180 if (!pass) return []
181 const price =
182 detail.id === 'explorer'
183 ? resolvePublicPrice([], group.eventId, { applicationOnly: true })
184 : (live[pass.inwinkCategoryId || ''] ?? {
185 status: 'unavailable' as const,
186 amount: null,
187 currency: null,
188 taxLabel: '',
189 displayPrice: 'Price unavailable',
190 })
191 return [
192 {
193 id: detail.id,
194 badge: detail.badge,
195 summary: detail.summary,
196 url: `${eventSlug}/attend/${detail.path}`,
197 sourceUrl: `${eventSlug}/badges`,
198 eventId: group.eventId,
199 verifiedAt: new Date().toISOString(),
200 ...price,
201 },
202 ]
203 })
204}
205export type BadgePrice = Awaited<ReturnType<typeof loadBadgePricing>>[number]
206