@getmicdrop/venue-calendar 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Event Transformation Utilities
3
+ *
4
+ * Single source of truth for transforming API event data into display-ready formats.
5
+ * Consolidates duplicate transformation logic from utils.js and api.js.
6
+ */
7
+
8
+ import {
9
+ getIANATimezone,
10
+ formatEventTime,
11
+ formatTimeRange,
12
+ getDateParts as getDatePartsNew,
13
+ } from './datetime.js';
14
+
15
+ /** CDN base URL for image assets */
16
+ const CDN_BASE_URL = 'https://moxy.sfo3.digitaloceanspaces.com';
17
+
18
+ /**
19
+ * Default placeholder image
20
+ * Using a data URI SVG to avoid third-party dependencies
21
+ */
22
+ const PLACEHOLDER_IMAGE = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" fill="none">
23
+ <rect width="200" height="300" fill="#E5E7EB"/>
24
+ <rect x="60" y="90" width="80" height="80" rx="10" fill="#D1D5DB"/>
25
+ <circle cx="85" cy="115" r="10" fill="#9CA3AF"/>
26
+ <path d="M70 155 L100 125 L130 155 L130 155" stroke="#9CA3AF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
27
+ <text x="100" y="210" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" fill="#6B7280">Event Image</text>
28
+ </svg>`);
29
+
30
+ // ============================================================================
31
+ // Shared Helpers
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Prepend CDN URL to relative image paths.
36
+ *
37
+ * @param {string} imagePath - Image path (relative or absolute)
38
+ * @returns {string} Full image URL
39
+ */
40
+ export function getCDNImageUrl(imagePath) {
41
+ if (!imagePath) return '';
42
+ if (imagePath.startsWith('/')) {
43
+ return `${CDN_BASE_URL}${imagePath}`;
44
+ }
45
+ return imagePath;
46
+ }
47
+
48
+ /**
49
+ * Extract image URL from event data with fallbacks.
50
+ *
51
+ * @param {Object} event - Event object from API
52
+ * @returns {string} Resolved image URL
53
+ */
54
+ export function getEventImageUrl(event) {
55
+ const rawImage = event.image || event.coverImage || event.imageUrl || event.image_url || '';
56
+ return getCDNImageUrl(rawImage) || PLACEHOLDER_IMAGE;
57
+ }
58
+
59
+ /**
60
+ * Calculate ticket status based on capacity and dates.
61
+ *
62
+ * @param {Object} ticket - Ticket object
63
+ * @returns {string} Status: 'available', 'low', 'sold_out', 'not_started', 'ended'
64
+ */
65
+ export function calculateTicketStatus(ticket) {
66
+ const now = new Date();
67
+
68
+ if (ticket.saleStartDate && new Date(ticket.saleStartDate) > now) {
69
+ return 'not_started';
70
+ }
71
+ if (ticket.saleEndDate && new Date(ticket.saleEndDate) < now) {
72
+ return 'ended';
73
+ }
74
+
75
+ const remaining = ticket.remainingCapacity ?? ticket.quantityRemaining ?? ticket.quantity ?? 0;
76
+ const total = ticket.totalCapacity ?? ticket.quantity ?? remaining;
77
+
78
+ if (remaining === 0) return 'sold_out';
79
+ if (total > 0 && remaining / total < 0.1) return 'low';
80
+ return 'available';
81
+ }
82
+
83
+ /**
84
+ * Filter tickets to only public/online sales.
85
+ *
86
+ * @param {Array} tickets - Array of ticket objects
87
+ * @param {Object} options - Filter options
88
+ * @param {boolean} options.excludeDoorOnly - Exclude "at the door only" tickets (salesChannel === 2)
89
+ * @param {boolean} options.publicOnly - Only include visibility === 0 tickets
90
+ * @returns {Array} Filtered tickets
91
+ */
92
+ export function filterPublicTickets(tickets, options = {}) {
93
+ if (!Array.isArray(tickets)) return [];
94
+
95
+ let filtered = tickets;
96
+
97
+ if (options.excludeDoorOnly) {
98
+ filtered = filtered.filter(t => t.salesChannel !== 2);
99
+ }
100
+
101
+ if (options.publicOnly) {
102
+ filtered = filtered.filter(t => t.visibility === 0);
103
+ }
104
+
105
+ return filtered;
106
+ }
107
+
108
+ /**
109
+ * Calculate scarcity data for an event's tickets.
110
+ *
111
+ * @param {Array} tickets - Array of ticket objects (should be pre-filtered)
112
+ * @returns {Object} { totalRemaining, totalCapacity, isSoldOut }
113
+ */
114
+ export function calculateScarcity(tickets) {
115
+ if (!Array.isArray(tickets) || tickets.length === 0) {
116
+ return { totalRemaining: 0, totalCapacity: 0, isSoldOut: false };
117
+ }
118
+
119
+ let totalRemaining = 0;
120
+ let totalCapacity = 0;
121
+
122
+ for (const ticket of tickets) {
123
+ const remaining = ticket.remainingCapacity ?? ticket.quantityRemaining ?? ticket.quantity ?? 0;
124
+ const capacity = ticket.totalCapacity ?? ticket.quantity ?? remaining;
125
+ totalRemaining += remaining;
126
+ totalCapacity += capacity;
127
+ }
128
+
129
+ return {
130
+ totalRemaining,
131
+ totalCapacity,
132
+ isSoldOut: totalRemaining === 0,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Find the lowest price among tickets.
138
+ *
139
+ * @param {Array} tickets - Array of ticket objects
140
+ * @returns {number} Lowest price or 0
141
+ */
142
+ export function findLowestPrice(tickets) {
143
+ if (!Array.isArray(tickets) || tickets.length === 0) return 0;
144
+
145
+ const prices = tickets
146
+ .map(t => t.price ?? t.ticketPrice ?? 0)
147
+ .filter(p => p > 0);
148
+
149
+ return prices.length > 0 ? Math.min(...prices) : 0;
150
+ }
151
+
152
+ // ============================================================================
153
+ // Date/Time Formatting
154
+ // Now uses consolidated datetime module from @getmicdrop/svelte-components
155
+ // ============================================================================
156
+
157
+ /**
158
+ * Format a datetime for display in a specific timezone.
159
+ * @deprecated Use formatEventTime or formatEventDateTime from datetime.js
160
+ *
161
+ * @param {string} isoString - ISO datetime string
162
+ * @param {string} timeZone - IANA timezone
163
+ * @param {Object} options - Intl.DateTimeFormat options
164
+ * @returns {string} Formatted string
165
+ */
166
+ export function formatInTimezone(isoString, timeZone, options) {
167
+ if (!isoString) return '';
168
+
169
+ const date = new Date(isoString);
170
+ if (isNaN(date.getTime())) return '';
171
+
172
+ return new Intl.DateTimeFormat('en-US', { ...options, timeZone }).format(date);
173
+ }
174
+
175
+ /**
176
+ * Format event time range for display.
177
+ * Uses formatTimeRange from datetime module.
178
+ *
179
+ * @param {Object} event - Event object with startDateTime/endDateTime
180
+ * @param {string} timeZone - IANA timezone
181
+ * @returns {string} Formatted time range (e.g., "8:00 PM - 10:00 PM")
182
+ */
183
+ export function formatEventTimeRange(event, timeZone) {
184
+ if (!event.startDateTime) return '';
185
+
186
+ try {
187
+ if (!event.endDateTime) {
188
+ return formatEventTime(event.startDateTime, timeZone);
189
+ }
190
+ return formatTimeRange(event.startDateTime, event.endDateTime, timeZone);
191
+ } catch {
192
+ // Fallback to legacy implementation for invalid data
193
+ const formatOptions = {
194
+ hour: 'numeric',
195
+ minute: '2-digit',
196
+ hour12: true,
197
+ };
198
+
199
+ const startTime = formatInTimezone(event.startDateTime, timeZone, formatOptions);
200
+
201
+ if (!event.endDateTime) return startTime;
202
+
203
+ const endTime = formatInTimezone(event.endDateTime, timeZone, formatOptions);
204
+ return startTime && endTime ? `${startTime} - ${endTime}` : startTime;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get date parts for display (day, month, dateOfMonth).
210
+ * Uses getDateParts from datetime module.
211
+ *
212
+ * @param {string} isoString - ISO datetime string
213
+ * @param {string} timeZone - IANA timezone
214
+ * @returns {Object} { day, month, dateOfMonth }
215
+ */
216
+ export function getDateParts(isoString, timeZone) {
217
+ if (!isoString) return { day: '', month: '', dateOfMonth: '' };
218
+
219
+ try {
220
+ const parts = getDatePartsNew(isoString, timeZone);
221
+ return {
222
+ day: parts.day,
223
+ month: parts.month,
224
+ dateOfMonth: String(parts.date),
225
+ };
226
+ } catch {
227
+ // Fallback to legacy implementation for invalid data
228
+ return {
229
+ day: formatInTimezone(isoString, timeZone, { weekday: 'short' }),
230
+ month: formatInTimezone(isoString, timeZone, { month: 'short' }),
231
+ dateOfMonth: formatInTimezone(isoString, timeZone, { day: 'numeric' }),
232
+ };
233
+ }
234
+ }
235
+
236
+ // ============================================================================
237
+ // Main Transform Functions
238
+ // ============================================================================
239
+
240
+ /**
241
+ * Transform modes for different use cases.
242
+ */
243
+ export const TransformMode = {
244
+ /** Minimal data for list/card views */
245
+ BROWSE: 'browse',
246
+ /** Full data for detail pages */
247
+ DETAIL: 'detail',
248
+ };
249
+
250
+ /**
251
+ * Transform an API event into display-ready format.
252
+ *
253
+ * @param {Object} apiEvent - Raw event from API
254
+ * @param {Object} options - Transform options
255
+ * @param {string} options.mode - Transform mode: 'browse' or 'detail'
256
+ * @returns {Object} Transformed event
257
+ */
258
+ export function transformEvent(apiEvent, options = {}) {
259
+ const { mode = TransformMode.DETAIL } = options;
260
+
261
+ if (!apiEvent) {
262
+ return mode === TransformMode.BROWSE
263
+ ? getEmptyBrowseEvent()
264
+ : getEmptyDetailEvent();
265
+ }
266
+
267
+ const timeZone = getIANATimezone(apiEvent.timeZone);
268
+ const imageUrl = getEventImageUrl(apiEvent);
269
+
270
+ // Base properties shared by both modes
271
+ const base = {
272
+ id: apiEvent.id || apiEvent.ID,
273
+ name: apiEvent.title || apiEvent.name || '',
274
+ image: imageUrl,
275
+ timeZone,
276
+ startDateTime: apiEvent.startDateTime || null,
277
+ };
278
+
279
+ if (mode === TransformMode.BROWSE) {
280
+ return transformBrowseEvent(apiEvent, base, timeZone);
281
+ }
282
+
283
+ return transformDetailEvent(apiEvent, base, timeZone, imageUrl);
284
+ }
285
+
286
+ /**
287
+ * Transform for browse/list views (minimal data).
288
+ */
289
+ function transformBrowseEvent(apiEvent, base, timeZone) {
290
+ // Calculate date in venue timezone
291
+ let startDate = '';
292
+ if (apiEvent.startDateTime) {
293
+ const dt = new Date(apiEvent.startDateTime);
294
+ startDate = dt.toLocaleDateString('en-CA', {
295
+ timeZone,
296
+ year: 'numeric',
297
+ month: '2-digit',
298
+ day: '2-digit',
299
+ });
300
+ }
301
+
302
+ // Filter and calculate scarcity
303
+ const publicTickets = filterPublicTickets(apiEvent.availableTickets || [], { excludeDoorOnly: true });
304
+ const scarcity = calculateScarcity(publicTickets);
305
+
306
+ return {
307
+ ...base,
308
+ date: startDate || apiEvent.date || '',
309
+ status: apiEvent.status || 'On Sale',
310
+ timeline: formatEventTimeRange(apiEvent, timeZone),
311
+ description: apiEvent.description || apiEvent.eventSummary || '',
312
+ venueId: apiEvent.venueId || apiEvent.venue_id,
313
+ eventSeriesId: apiEvent.eventSeriesId || null,
314
+ // Scarcity data
315
+ ticketsRemaining: scarcity.totalRemaining,
316
+ ticketsTotal: scarcity.totalCapacity,
317
+ isSoldOut: scarcity.isSoldOut,
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Transform for detail views (full data).
323
+ */
324
+ function transformDetailEvent(apiEvent, base, timeZone, imageUrl) {
325
+ const allTickets = (apiEvent.availableTickets || []).map(ticket => ({
326
+ ...ticket,
327
+ status: calculateTicketStatus(ticket),
328
+ }));
329
+ const publicTickets = filterPublicTickets(allTickets, { publicOnly: true });
330
+ const dateParts = getDateParts(apiEvent.startDateTime, timeZone);
331
+
332
+ return {
333
+ ...base,
334
+ date: apiEvent.startDateTime ? formatDateCustom(apiEvent.startDateTime) : null,
335
+ endDateTime: apiEvent.endDateTime,
336
+ description: apiEvent.description,
337
+ timeline: formatInTimezone(apiEvent.startDateTime, timeZone, {
338
+ hour: 'numeric',
339
+ minute: '2-digit',
340
+ hour12: true,
341
+ }),
342
+ location: apiEvent.venue?.googleLocationNameCache || apiEvent.venue?.address || '',
343
+ ...dateParts,
344
+ coverImage: imageUrl,
345
+ price: findLowestPrice(apiEvent.availableTickets),
346
+ status: 'Only 2 left!', // TODO: Calculate from scarcity
347
+ eventSummary: apiEvent.eventSummary,
348
+ password: apiEvent.password,
349
+ availableTickets: allTickets,
350
+ publicTickets,
351
+ doorsOpenTime: apiEvent.doorsOpenTime,
352
+ venue: apiEvent.venue,
353
+ venueID: apiEvent.venueId,
354
+ eventID: apiEvent.ID,
355
+ stage: apiEvent.stage,
356
+ performers: apiEvent.performers,
357
+ ticketType: apiEvent.ticketType,
358
+ eventTicketingType: apiEvent.eventTicketingType,
359
+ purchasedTickets: apiEvent.purchasedTickets,
360
+ ShowImage: imageUrl,
361
+ displayStartTime: apiEvent.displayStartTime,
362
+ displayEndTime: apiEvent.displayEndTime,
363
+ displayDoorsTime: apiEvent.displayDoorsTime,
364
+ ageRestriction: apiEvent.ageRestriction || null,
365
+ minimumAge: apiEvent.ageRestriction || null,
366
+ ageRequirement: apiEvent.ageRestriction ? `${apiEvent.ageRestriction}` : null,
367
+ hasAgeRestriction: !!apiEvent.ageRestriction,
368
+ displayAgeRestriction: apiEvent.displayAgeRestriction ?? false,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Format date to custom format (for backwards compatibility).
374
+ */
375
+ function formatDateCustom(isoString) {
376
+ if (!isoString) return null;
377
+ const date = new Date(isoString);
378
+ if (isNaN(date.getTime())) return null;
379
+
380
+ const options = {
381
+ weekday: 'short',
382
+ month: 'short',
383
+ day: 'numeric',
384
+ year: 'numeric',
385
+ };
386
+ return new Intl.DateTimeFormat('en-US', options).format(date);
387
+ }
388
+
389
+ /**
390
+ * Empty event for browse mode.
391
+ */
392
+ function getEmptyBrowseEvent() {
393
+ return {
394
+ id: null,
395
+ name: '',
396
+ date: '',
397
+ image: '',
398
+ status: 'unavailable',
399
+ timeline: '',
400
+ description: '',
401
+ venueId: null,
402
+ timeZone: 'UTC',
403
+ eventSeriesId: null,
404
+ startDateTime: null,
405
+ ticketsRemaining: 0,
406
+ ticketsTotal: 0,
407
+ isSoldOut: false,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Empty event for detail mode.
413
+ */
414
+ function getEmptyDetailEvent() {
415
+ return {
416
+ id: null,
417
+ name: '',
418
+ date: null,
419
+ startDateTime: null,
420
+ endDateTime: null,
421
+ description: '',
422
+ timeline: '',
423
+ location: '',
424
+ day: '',
425
+ month: '',
426
+ dateOfMonth: null,
427
+ image: '',
428
+ coverImage: '',
429
+ price: 0,
430
+ status: 'unavailable',
431
+ eventSummary: '',
432
+ password: '',
433
+ availableTickets: [],
434
+ publicTickets: [],
435
+ doorsOpenTime: null,
436
+ venue: null,
437
+ venueID: null,
438
+ eventID: null,
439
+ stage: null,
440
+ performers: [],
441
+ ticketType: 0,
442
+ eventTicketingType: 0,
443
+ purchasedTickets: [],
444
+ ShowImage: '',
445
+ displayStartTime: null,
446
+ displayEndTime: null,
447
+ ageRestriction: null,
448
+ minimumAge: null,
449
+ ageRequirement: null,
450
+ hasAgeRestriction: false,
451
+ timeZone: 'UTC',
452
+ };
453
+ }
454
+
455
+ // ============================================================================
456
+ // Backward Compatibility Exports
457
+ // ============================================================================
458
+
459
+ /**
460
+ * @deprecated Use transformEvent(event, { mode: TransformMode.BROWSE }) instead.
461
+ */
462
+ export function transformEventData(apiEvent) {
463
+ return transformEvent(apiEvent, { mode: TransformMode.BROWSE });
464
+ }