Setting the file. One moment.
Event Registration View · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page events/app/components/events/ EventRegistrationView.tsx
TypeScript · 375 lines · 16 KB
12 import type { EventDetail, RsvpFormControl, RsvpFormInput, TicketTier } from "../../wix/events/types" ;
13
14 const input =
15 "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-shadow focus:ring-2 focus:ring-primary" ;
16 const labelText = "mb-1 block text-xs font-semibold uppercase tracking-wider text-muted-foreground" ;
17 const cta =
18 "mt-5 rounded-control bg-primary px-8 py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50" ;
19 const stepper = "h-8 w-8 rounded-control border border-border text-foreground transition-colors hover:bg-secondary disabled:opacity-40" ;
20 const badge = "rounded-control bg-secondary px-2 py-0.5 text-xs font-medium text-muted-foreground" ;
21
22 function Stepper ({ label , qty , max , disabled , onChange } : { label : string ; qty : number ; max : number ; disabled : boolean ; onChange : ( q : number ) => void }) {
23 return (
24 < div className = "flex items-center gap-2" >
25 < button type = "button" aria-label ={ `Fewer ${ label }` } disabled ={ disabled || qty === 0 } onClick ={ () => onChange (qty - 1 ) } className ={ stepper } >
26 −
27 </ button >
28 < span className = "w-6 text-center text-sm tabular-nums" > { qty } </ span >
29 < button type = "button" aria-label ={ `More ${ label }` } disabled ={ disabled || qty >= max } onClick ={ () => onChange (qty + 1 ) } className ={ stepper } >
30 +
31 </ button >
32 </ div >
33 );
34 }
35
36 function Notes ({ notes } : { notes : string [] }) {
37 return notes. length ? < p className = "mt-0.5 text-xs text-muted-foreground" > { notes. join ( " · " ) } </ p > : null ;
38 }
39
40 function TierRow ({
41 tier ,
42 quantities ,
43 guestPrice ,
44 setQuantity ,
45 setGuestPrice ,
46 } : {
47 tier : TicketTier ;
48 quantities : Record < string , number >;
49 guestPrice : string ;
50 setQuantity : ( tierId : string , qty : number , optionId ?: string ) => void ;
51 setGuestPrice : ( tierId : string , value : string ) => void ;
52 }) {
53 const qty = quantities[tier.id] ?? 0 ;
54 return (
55 < div className = "rounded-lg border border-border p-4" >
56 < div className = "flex items-start justify-between gap-4" >
57 < div className = "min-w-0" >
58 < p className = "text-sm font-medium text-foreground" >
59 { tier.name }
60 { tier.soldOut && < span className ={ `${ badge } ml-2` } >Sold out</ span > }
61 {! tier.soldOut && tier.saleStatus === "SALE_ENDED" && < span className ={ `${ badge } ml-2` } >Sale ended</ span > }
62 </ p >
63 { tier.description && < p className = "mt-0.5 text-xs text-muted-foreground" > { tier.description } </ p > }
64 { tier.pricingType !== "OPTIONS" && tier.price && < p className = "mt-1 text-sm text-foreground" > { tier.price } </ p > }
65 { tier.pricingType === "GUEST" && (
66 < p className = "mt-1 text-sm text-foreground" > { tier.minPrice ? `Pay what you want — at least ${ tier . minPrice }` : "Pay what you want" } </ p >
67 ) }
68 < Notes notes ={ tier.notes } />
69 { tier.saleStartsLabel && < p className = "mt-1 text-xs text-muted-foreground" >Goes on sale { tier.saleStartsLabel } </ p > }
70 { tier.available && tier.saleEndsLabel && < p className = "mt-1 text-xs text-muted-foreground" >Sale ends { tier.saleEndsLabel } </ p > }
71 </ div >
72 { tier.pricingType !== "OPTIONS" && (
73 < Stepper label ={ tier.name } qty ={ qty } max ={ tier.limitPerCheckout } disabled ={! tier.available } onChange ={ ( q ) => setQuantity (tier.id, q) } />
74 ) }
75 </ div >
76 { tier.pricingType === "GUEST" && (
77 < label className = "mt-3 block max-w-xs" >
78 < span className ={ labelText } >Your amount { tier.currency ? ` (${ tier . currency })` : "" } </ span >
79 < input
80 type = "number"
81 inputMode = "decimal"
82 min ={ tier.minPriceValue || 0 }
83 step = "0.01"
84 value ={ guestPrice }
85 disabled ={! tier.available }
86 onChange ={ ( e ) => setGuestPrice (tier.id, e.target.value) }
87 className ={ input }
88 />
89 </ label >
90 ) }
91 { tier.pricingType === "OPTIONS" && (
92 < div className = "mt-3 space-y-2" >
93 { tier.options. map (( o ) => (
94 < div key ={ o.id } className = "flex items-center justify-between gap-4 border-t border-border pt-2" >
95 < div >
96 < p className = "text-sm text-foreground" > { o.name } </ p >
97 < p className = "text-sm text-foreground" > { o.price } </ p >
98 < Notes notes ={ o.notes } />
99 </ div >
100 < Stepper
101 label ={ `${ tier . name } ${ o . name }` }
102 qty ={ quantities[ `${ tier . id }:${ o . id }` ] ?? 0 }
103 max ={ tier.limitPerCheckout }
104 disabled ={! tier.available }
105 onChange ={ ( q ) => setQuantity (tier.id, q, o.id) }
106 />
107 </ div >
108 )) }
109 </ div >
110 ) }
111 </ div >
112 );
113 }
114
115 function FormInput ({ control , field , value , onChange } : { control : RsvpFormControl ; field : RsvpFormInput ; value : string | string [] | undefined ; onChange : ( v : string | string []) => void }) {
116 const text = Array. isArray (value) ? value. join ( ", " ) : (value ?? "" );
117 const required = field.mandatory ? < span aria-hidden = "true" > *</ span > : null ;
118 if (control.type === "CHECKBOX" ) {
119 const picked = Array. isArray (value) ? value : [];
120 return (
121 < fieldset >
122 < legend className ={ labelText } >
123 { field.label }
124 { required }
125 </ legend >
126 { field.options. map (( opt ) => (
127 < label key ={ opt } className = "flex items-center gap-2 py-0.5 text-sm text-foreground" >
128 < input
129 type = "checkbox"
130 checked ={ picked. includes (opt) }
131 onChange ={ ( e ) => onChange (e.target.checked ? [ ... picked, opt] : picked. filter (( p ) => p !== opt)) }
132 />
133 { opt }
134 </ label >
135 )) }
136 </ fieldset >
137 );
138 }
139 if (control.type === "RADIO" ) {
140 return (
141 < fieldset >
142 < legend className ={ labelText } >
143 { field.label }
144 { required }
145 </ legend >
146 { field.options. map (( opt ) => (
147 < label key ={ opt } className = "flex items-center gap-2 py-0.5 text-sm text-foreground" >
148 < input type = "radio" name ={ field.name } checked ={ text === opt } onChange ={ () => onChange (opt) } />
149 { opt }
150 </ label >
151 )) }
152 </ fieldset >
153 );
154 }
155 if (control.type === "DROPDOWN" || field.options. length ) {
156 return (
157 < label className = "block" >
158 < span className ={ labelText } >
159 { field.label }
160 { required }
161 </ span >
162 < select value ={ text } onChange ={ ( e ) => onChange (e.target.value) } className ={ input } >
163 < option value = "" >Select…</ option >
164 { field.options. map (( opt ) => (
165 < option key ={ opt } value ={ opt } >
166 { opt }
167 </ option >
168 )) }
169 </ select >
170 </ label >
171 );
172 }
173 if (control.type === "TEXTAREA" ) {
174 return (
175 < label className = "block" >
176 < span className ={ labelText } >
177 { field.label }
178 { required }
179 </ span >
180 < textarea value ={ text } maxLength ={ field.maxLength || undefined } onChange ={ ( e ) => onChange (e.target.value) } className ={ `${ input } min-h-24` } />
181 </ label >
182 );
183 }
184 const type = control.type === "DATE" || field.type === "DATE_TIME" ? "date" : field.type === "NUMBER" ? "number" : /mail/ i . test (field.name) ? "email" : "text" ;
185 return (
186 < label className = "block" >
187 < span className ={ labelText } >
188 { field.label }
189 { required }
190 </ span >
191 < input type ={ type } value ={ text } maxLength ={ field.maxLength || undefined } onChange ={ ( e ) => onChange (e.target.value) } className ={ input } />
192 </ label >
193 );
194 }
195
196 export default function EventRegistrationView ({ event } : { event : EventDetail }) {
197 const {
198 tiers ,
199 quantities ,
200 guestPrices ,
201 setQuantity ,
202 setGuestPrice ,
203 ticketCount ,
204 ticketLimitPerOrder ,
205 totals ,
206 canCheckout ,
207 checkout ,
208 rsvpForm ,
209 rsvpValues ,
210 setRsvpValue ,
211 guestCount ,
212 guestNames ,
213 setGuestCount ,
214 setGuestName ,
215 canRsvp ,
216 rsvp ,
217 submitting ,
218 confirmed ,
219 error ,
220 } = useEventRegistration (event);
221
222 if (confirmed && confirmed.kind === "rsvpConfirmed" ) {
223 return (
224 < div className = "rounded-lg border border-border bg-secondary p-8 text-center" >
225 < p className = "text-lg font-semibold" >
226 { confirmed.status === "WAITLIST" ? "You're on the waitlist" : confirmed.status === "NO" ? "Thanks for letting us know" : "You're in!" }
227 </ p >
228 < p className = "mt-2 text-sm text-muted-foreground" >
229 { confirmed.status === "WAITLIST"
230 ? `${ event . title } is full — we'll email you if a spot opens up.`
231 : confirmed.status === "NO"
232 ? "We've recorded that you can't make it."
233 : `See you at ${ event . title }${ event . dateLabel ? ` — ${ event . dateLabel }` : ""}. A confirmation email is on its way.` }
234 </ p >
235 </ div >
236 );
237 }
238
239 // Closed is exactly what Wix calls closed; a scheduled opening and a pause are their own states.
240 if (event.registrationClosed || event.registrationType === "NONE" ) {
241 return (
242 < div className = "rounded-lg border border-border p-6 text-sm text-muted-foreground" >
243 { event.soldOut ? "This event is sold out." : "Registration for this event is closed." }
244 </ div >
245 );
246 }
247 if (event.registrationStatus === "SCHEDULED_RSVP" ) {
248 return (
249 < div className = "rounded-lg border border-border p-6 text-sm text-muted-foreground" >
250 { event.registrationOpensAtLabel ? `Registration opens ${ event . registrationOpensAtLabel }.` : "Registration hasn't opened yet." }
251 </ div >
252 );
253 }
254 if (event.registrationPaused || ! event.registrationOpen) {
255 return < div className = "rounded-lg border border-border p-6 text-sm text-muted-foreground" >Registration is paused right now — check back soon.</ div >;
256 }
257
258 if (event.registrationType === "EXTERNAL" ) {
259 return (
260 < a href ={ event.externalUrl } className ={ `${ cta } inline-block no-underline` } target = "_blank" rel = "noreferrer" >
261 Register
262 </ a >
263 );
264 }
265
266 if (event.registrationType === "TICKETING" ) {
267 return (
268 < div >
269 < p className = "mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground" >Tickets</ p >
270 { tiers === null ? (
271 < div className = "space-y-3" aria-busy = "true" >
272 { Array. from ({ length: 2 }, ( _ , i ) => (
273 < div key ={ i } className = "h-16 animate-pulse rounded-lg bg-secondary" />
274 )) }
275 </ div >
276 ) : tiers. length === 0 ? (
277 < p className = "rounded-md border border-border p-4 text-sm text-muted-foreground" >Tickets aren't available right now.</ p >
278 ) : (
279 < div className = "space-y-3" >
280 { tiers. map (( t ) => (
281 < TierRow key ={ t.id } tier ={ t } quantities ={ quantities } guestPrice ={ guestPrices[t.id] ?? "" } setQuantity ={ setQuantity } setGuestPrice ={ setGuestPrice } />
282 )) }
283 </ div >
284 ) }
285 { totals && (
286 < dl className = "mt-4 space-y-1 text-sm text-muted-foreground" >
287 < div className = "flex justify-between" >
288 < dt >Subtotal</ dt >
289 < dd className = "tabular-nums" > { totals.subtotal } </ dd >
290 </ div >
291 { event.taxSettings && ! event.taxSettings.includedInPrice && (
292 < div className = "flex justify-between" >
293 < dt > { event.taxSettings.name } </ dt >
294 < dd className = "tabular-nums" > { totals.tax } </ dd >
295 </ div >
296 ) }
297 { (tiers ?? []). some (( t ) => t.feeType === "FEE_ADDED_AT_CHECKOUT" ) && (
298 < div className = "flex justify-between" >
299 < dt >Ticket service fee</ dt >
300 < dd className = "tabular-nums" > { totals.fee } </ dd >
301 </ div >
302 ) }
303 < div className = "flex justify-between font-medium text-foreground" >
304 < dt >Total</ dt >
305 < dd className = "tabular-nums" > { totals.total } </ dd >
306 </ div >
307 </ dl >
308 ) }
309 { ticketCount >= ticketLimitPerOrder && < p className = "mt-2 text-xs text-muted-foreground" >Up to { ticketLimitPerOrder } tickets per order.</ p > }
310 { error && < p className = "mt-3 text-sm text-destructive" > { error } </ p > }
311 < button type = "button" disabled ={! canCheckout || submitting } onClick ={ () => checkout (). catch (() => {}) } className ={ cta } >
312 { submitting ? "Reserving…" : ticketCount > 0 ? `Get ${ ticketCount } ticket${ ticketCount > 1 ? "s" : ""}` : "Get tickets" }
313 </ button >
314 </ div >
315 );
316 }
317
318 if (event.registrationType === "RSVP" ) {
319 const guest = rsvpForm.guestControl;
320 return (
321 < div >
322 < p className = "mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground" > { event.waitlistOnly ? "Waitlist" : "RSVP" } </ p >
323 { event.waitlistOnly && < p className = "mb-3 text-sm text-muted-foreground" >This event is full — join the waitlist and we'll email you if a spot opens up.</ p > }
324 { event.membersOnly && < p className = "mb-3 text-sm text-muted-foreground" >Registration is for site members — sign in to your account on this site first.</ p > }
325 < div className = "grid max-w-md gap-3" >
326 { rsvpForm.controls. map (( control ) =>
327 control.inputs. map (( field ) => (
328 < FormInput key ={ `${ control . id }:${ field . name }` } control ={ control } field ={ field } value ={ rsvpValues[field.name] } onChange ={ ( v ) => setRsvpValue (field.name, v) } />
329 )),
330 ) }
331 { guest && guest.maxGuests > 0 && (
332 <>
333 < label className = "block" >
334 < span className ={ labelText } > { guest.label } </ span >
335 < select value ={ guestCount } onChange ={ ( e ) => setGuestCount ( Number (e.target.value)) } className ={ input } >
336 { Array. from ({ length: guest.maxGuests + 1 }, ( _ , n ) => (
337 < option key ={ n } value ={ n } >
338 { n }
339 </ option >
340 )) }
341 </ select >
342 </ label >
343 { Array. from ({ length: guestCount }, ( _ , i ) => (
344 < label key ={ i } className = "block" >
345 < span className ={ labelText } >
346 { guest.namesLabel } { i + 1 }
347 </ span >
348 < input type = "text" value ={ guestNames[i] ?? "" } onChange ={ ( e ) => setGuestName (i, e.target.value) } className ={ input } />
349 </ label >
350 )) }
351 </>
352 ) }
353 </ div >
354 { error && < p className = "mt-3 text-sm text-destructive" > { error } </ p > }
355 < div className = "flex items-center gap-3" >
356 < button type = "button" disabled ={! canRsvp || submitting } onClick ={ () => rsvp ( true ). catch (() => {}) } className ={ cta } >
357 { submitting ? "Sending…" : event.waitlistOnly ? "Join the waitlist" : "Count me in" }
358 </ button >
359 { event.rsvpResponseType === "YES_AND_NO" && (
360 < button
361 type = "button"
362 disabled ={! canRsvp || submitting }
363 onClick ={ () => rsvp ( false ). catch (() => {}) }
364 className = "mt-5 rounded-control border border-border px-6 py-3 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:opacity-50"
365 >
366 Can't make it
367 </ button >
368 ) }
369 </ div >
370 </ div >
371 );
372 }
373
374 return < div className = "rounded-lg border border-border p-6 text-sm text-muted-foreground" >Registration isn't open for this event.</ div >;
375 }