@builtonveya/analytics-web 4.0.0

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,673 @@
1
+ /**
2
+ * Veya Analytics SDK - Core
3
+ * Platform-agnostic core that works on Web and React Native
4
+ *
5
+ * @version 4.0.0
6
+ */
7
+ // ============================================================================
8
+ // DEFAULT CONFIG
9
+ // ============================================================================
10
+ const DEFAULT_CONFIG = {
11
+ tenantSlug: '',
12
+ tenantId: '',
13
+ storeId: '',
14
+ storeName: '',
15
+ endpoint: 'https://analytics.veya.io/v1/events',
16
+ aiEndpoint: 'https://analytics.veya.io/v1/classify',
17
+ batchSize: 10,
18
+ flushInterval: 5000,
19
+ sessionTimeout: 30 * 60 * 1000,
20
+ debug: false,
21
+ autoDetect: true,
22
+ autoTrackProducts: true,
23
+ autoTrackCart: true,
24
+ interceptOloApi: true,
25
+ useAiClassification: true,
26
+ confidenceThreshold: 0.7,
27
+ platform: 'web'
28
+ };
29
+ // ============================================================================
30
+ // UTILITIES
31
+ // ============================================================================
32
+ export function generateUUID() {
33
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
34
+ const r = Math.random() * 16 | 0;
35
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
36
+ return v.toString(16);
37
+ });
38
+ }
39
+ export function getTimestamp() {
40
+ return new Date().toISOString();
41
+ }
42
+ // ============================================================================
43
+ // CORE ANALYTICS CLASS
44
+ // ============================================================================
45
+ export class VeyaAnalyticsCore {
46
+ constructor() {
47
+ this.initialized = false;
48
+ this.eventQueue = [];
49
+ this.flushTimer = null;
50
+ // Identifiers
51
+ this.sessionId = '';
52
+ this.visitorId = '';
53
+ this.customerId = null;
54
+ // State
55
+ this.sessionData = {
56
+ is_logged_in: false,
57
+ login_method: null,
58
+ logged_in_at: null,
59
+ login_funnel_step: null,
60
+ experiment_assignments: {}
61
+ };
62
+ this.cartState = {
63
+ items: [],
64
+ itemCount: 0,
65
+ total: 0
66
+ };
67
+ this.deviceInfo = null;
68
+ this.currentScreenName = '';
69
+ this.currentPageType = '';
70
+ this.currentFunnelStep = null;
71
+ // Storage adapter - must be set by platform-specific implementation
72
+ this.storage = null;
73
+ this.config = { ...DEFAULT_CONFIG };
74
+ }
75
+ // ============================================================================
76
+ // INITIALIZATION
77
+ // ============================================================================
78
+ async init(options) {
79
+ if (this.initialized) {
80
+ this.log('warn', 'SDK already initialized');
81
+ return this;
82
+ }
83
+ if (!options.tenantSlug && !options.tenantId) {
84
+ throw new Error('VeyaAnalytics: tenantSlug is required');
85
+ }
86
+ this.config = { ...DEFAULT_CONFIG, ...options };
87
+ // Sync: tenantSlug takes precedence, fall back to tenantId
88
+ if (!this.config.tenantSlug) {
89
+ this.config.tenantSlug = this.config.tenantId;
90
+ }
91
+ // Initialize visitor and session
92
+ await this.initVisitor();
93
+ await this.initSession();
94
+ // Get device info (implemented by platform-specific class)
95
+ this.deviceInfo = await this.getDeviceInfo();
96
+ // Start flush timer
97
+ this.startFlushTimer();
98
+ // Track session start
99
+ this.trackSessionStart();
100
+ this.initialized = true;
101
+ this.log('info', 'VeyaAnalytics initialized', {
102
+ tenantSlug: this.config.tenantSlug,
103
+ visitorId: this.visitorId,
104
+ sessionId: this.sessionId,
105
+ platform: this.config.platform
106
+ });
107
+ return this;
108
+ }
109
+ async initVisitor() {
110
+ if (!this.storage) {
111
+ this.visitorId = generateUUID();
112
+ return;
113
+ }
114
+ const stored = await this.storage.getItem('veya_visitor_id');
115
+ if (stored) {
116
+ this.visitorId = stored;
117
+ }
118
+ else {
119
+ this.visitorId = generateUUID();
120
+ await this.storage.setItem('veya_visitor_id', this.visitorId);
121
+ }
122
+ }
123
+ async initSession() {
124
+ if (!this.storage) {
125
+ this.sessionId = generateUUID();
126
+ return;
127
+ }
128
+ const stored = await this.storage.getItem('veya_session');
129
+ const now = Date.now();
130
+ if (stored) {
131
+ try {
132
+ const sessionData = JSON.parse(stored);
133
+ if ((now - sessionData.lastActivity) < this.config.sessionTimeout) {
134
+ this.sessionId = sessionData.sessionId;
135
+ this.sessionData = { ...this.sessionData, ...sessionData.data };
136
+ await this.saveSession();
137
+ return;
138
+ }
139
+ }
140
+ catch (e) { }
141
+ }
142
+ // New session
143
+ this.sessionId = generateUUID();
144
+ await this.saveSession();
145
+ }
146
+ async saveSession() {
147
+ if (!this.storage)
148
+ return;
149
+ await this.storage.setItem('veya_session', JSON.stringify({
150
+ sessionId: this.sessionId,
151
+ lastActivity: Date.now(),
152
+ data: this.sessionData
153
+ }));
154
+ }
155
+ // Override in platform-specific implementations
156
+ async getDeviceInfo() {
157
+ return {
158
+ device_type: 'unknown',
159
+ platform: this.config.platform || 'unknown',
160
+ os: 'unknown',
161
+ screen_width: 0,
162
+ screen_height: 0,
163
+ language: 'en',
164
+ timezone: 'UTC'
165
+ };
166
+ }
167
+ // ============================================================================
168
+ // CORE TRACKING
169
+ // ============================================================================
170
+ track(eventName, properties = {}) {
171
+ if (!this.initialized) {
172
+ this.log('warn', 'SDK not initialized');
173
+ return this;
174
+ }
175
+ const event = this.buildEvent(eventName, properties);
176
+ this.queueEvent(event);
177
+ this.log('debug', 'Event tracked', event);
178
+ return this;
179
+ }
180
+ buildEvent(eventName, properties) {
181
+ return {
182
+ event_id: generateUUID(),
183
+ event_name: eventName,
184
+ event_category: properties.category || this.inferCategory(eventName),
185
+ event_timestamp: getTimestamp(),
186
+ // Identifiers
187
+ tenant_id: this.config.tenantSlug || this.config.tenantId || '',
188
+ tenant_slug: this.config.tenantSlug || this.config.tenantId || '',
189
+ session_id: this.sessionId,
190
+ visitor_id: this.visitorId,
191
+ customer_id: this.customerId || undefined,
192
+ store_id: this.config.storeId || properties.store_id,
193
+ store_name: this.config.storeName || properties.store_name,
194
+ // Context
195
+ page_url: properties.page_url,
196
+ page_title: properties.page_title,
197
+ page_type: properties.page_type || this.currentPageType,
198
+ screen_name: properties.screen_name || this.currentScreenName,
199
+ referrer: properties.referrer,
200
+ // Device info
201
+ ...this.deviceInfo,
202
+ // Session state
203
+ is_logged_in: this.sessionData.is_logged_in,
204
+ login_method: this.sessionData.login_method || undefined,
205
+ // Funnel
206
+ funnel_step: properties.funnel_step || this.currentFunnelStep?.name,
207
+ funnel_step_number: properties.funnel_step_number || this.currentFunnelStep?.number,
208
+ // Cart
209
+ cart_value: this.cartState.total,
210
+ cart_item_count: this.cartState.itemCount,
211
+ // Experiments
212
+ experiment_assignments: this.sessionData.experiment_assignments,
213
+ // Custom properties
214
+ properties: this.sanitizeProperties(properties)
215
+ };
216
+ }
217
+ inferCategory(eventName) {
218
+ if (eventName.includes('cart'))
219
+ return 'cart';
220
+ if (eventName.includes('checkout') || eventName.includes('order'))
221
+ return 'checkout';
222
+ if (eventName.includes('product') || eventName.includes('menu'))
223
+ return 'catalog';
224
+ if (eventName.includes('login') || eventName.includes('auth'))
225
+ return 'auth';
226
+ if (eventName.includes('error'))
227
+ return 'error';
228
+ if (eventName.includes('screen') || eventName.includes('page') || eventName.includes('session'))
229
+ return 'navigation';
230
+ return 'engagement';
231
+ }
232
+ sanitizeProperties(properties) {
233
+ const sanitized = { ...properties };
234
+ // Remove internal properties
235
+ delete sanitized.category;
236
+ delete sanitized.page_type;
237
+ delete sanitized.store_id;
238
+ delete sanitized.page_url;
239
+ delete sanitized.page_title;
240
+ delete sanitized.screen_name;
241
+ delete sanitized.referrer;
242
+ delete sanitized.funnel_step;
243
+ delete sanitized.funnel_step_number;
244
+ // Remove PII
245
+ const piiFields = ['email', 'phone', 'password', 'ssn', 'credit_card', 'cvv'];
246
+ piiFields.forEach(field => delete sanitized[field]);
247
+ return sanitized;
248
+ }
249
+ // ============================================================================
250
+ // SESSION TRACKING
251
+ // ============================================================================
252
+ trackSessionStart() {
253
+ this.track('session_start', {
254
+ platform: this.config.platform
255
+ });
256
+ }
257
+ trackSessionEnd() {
258
+ this.track('session_end', {});
259
+ this.flush(true);
260
+ }
261
+ // ============================================================================
262
+ // SCREEN / PAGE TRACKING
263
+ // ============================================================================
264
+ screenView(screenName, properties = {}) {
265
+ this.currentScreenName = screenName;
266
+ this.currentPageType = this.inferPageType(screenName);
267
+ this.updateFunnelStep(this.currentPageType);
268
+ return this.track('screen_view', {
269
+ screen_name: screenName,
270
+ page_type: this.currentPageType,
271
+ ...properties
272
+ });
273
+ }
274
+ pageView(properties = {}) {
275
+ const pageType = properties.pageType || properties.page_type || 'unknown';
276
+ this.currentPageType = pageType;
277
+ this.updateFunnelStep(pageType);
278
+ return this.track('page_view', {
279
+ page_type: pageType,
280
+ ...properties
281
+ });
282
+ }
283
+ inferPageType(screenName) {
284
+ const name = screenName.toLowerCase();
285
+ if (name.includes('menu') || name.includes('order') || name.includes('food'))
286
+ return 'menu';
287
+ if (name.includes('cart') || name.includes('basket') || name.includes('bag'))
288
+ return 'cart';
289
+ if (name.includes('checkout') || name.includes('payment'))
290
+ return 'checkout';
291
+ if (name.includes('confirm') || name.includes('success') || name.includes('thank'))
292
+ return 'confirmation';
293
+ if (name.includes('product') || name.includes('item') || name.includes('detail'))
294
+ return 'product';
295
+ if (name.includes('location') || name.includes('store'))
296
+ return 'location';
297
+ if (name.includes('account') || name.includes('profile') || name.includes('login'))
298
+ return 'account';
299
+ if (name.includes('reward') || name.includes('loyalty') || name.includes('point'))
300
+ return 'rewards';
301
+ if (name.includes('home') || name.includes('landing'))
302
+ return 'home';
303
+ return 'other';
304
+ }
305
+ updateFunnelStep(pageType) {
306
+ const funnelMap = {
307
+ home: { name: 'landing', number: 1 },
308
+ location: { name: 'location', number: 1 },
309
+ menu: { name: 'menu', number: 2 },
310
+ product: { name: 'menu', number: 2 },
311
+ cart: { name: 'cart', number: 3 },
312
+ checkout: { name: 'checkout', number: 4 },
313
+ confirmation: { name: 'confirmation', number: 5 }
314
+ };
315
+ this.currentFunnelStep = funnelMap[pageType] || null;
316
+ }
317
+ // ============================================================================
318
+ // PRODUCT TRACKING
319
+ // ============================================================================
320
+ productView(product, properties = {}) {
321
+ return this.track('product_view', {
322
+ product_id: product.id || product.product_id,
323
+ product_name: product.name || product.product_name,
324
+ product_category: product.category,
325
+ product_price: product.price,
326
+ ...properties
327
+ });
328
+ }
329
+ productClick(product, properties = {}) {
330
+ return this.track('product_click', {
331
+ product_id: product.id || product.product_id,
332
+ product_name: product.name || product.product_name,
333
+ product_category: product.category,
334
+ product_price: product.price,
335
+ ...properties
336
+ });
337
+ }
338
+ // ============================================================================
339
+ // CART TRACKING
340
+ // ============================================================================
341
+ addToCart(product, quantity = 1, properties = {}) {
342
+ this.cartState.itemCount += quantity;
343
+ this.cartState.total += (product.price || 0) * quantity;
344
+ this.cartState.items.push({ ...product, quantity });
345
+ return this.track('add_to_cart', {
346
+ product_id: product.id || product.product_id,
347
+ product_name: product.name || product.product_name,
348
+ product_category: product.category,
349
+ product_price: product.price,
350
+ quantity,
351
+ ...properties
352
+ });
353
+ }
354
+ removeFromCart(product, quantity = 1, properties = {}) {
355
+ this.cartState.itemCount = Math.max(0, this.cartState.itemCount - quantity);
356
+ this.cartState.total = Math.max(0, this.cartState.total - (product.price || 0) * quantity);
357
+ return this.track('remove_from_cart', {
358
+ product_id: product.id || product.product_id,
359
+ product_name: product.name || product.product_name,
360
+ product_category: product.category,
361
+ product_price: product.price,
362
+ quantity,
363
+ ...properties
364
+ });
365
+ }
366
+ updateCartQuantity(product, oldQuantity, newQuantity) {
367
+ const diff = newQuantity - oldQuantity;
368
+ this.cartState.itemCount += diff;
369
+ this.cartState.total += (product.price || 0) * diff;
370
+ return this.track('update_cart_quantity', {
371
+ product_id: product.id || product.product_id,
372
+ product_name: product.name || product.product_name,
373
+ old_quantity: oldQuantity,
374
+ new_quantity: newQuantity
375
+ });
376
+ }
377
+ setCart(cart) {
378
+ this.cartState = {
379
+ items: cart.items || [],
380
+ itemCount: cart.itemCount || 0,
381
+ total: cart.total || 0
382
+ };
383
+ return this;
384
+ }
385
+ viewCart(properties = {}) {
386
+ return this.track('cart_view', {
387
+ cart_value: this.cartState.total,
388
+ cart_item_count: this.cartState.itemCount,
389
+ ...properties
390
+ });
391
+ }
392
+ // ============================================================================
393
+ // CHECKOUT TRACKING
394
+ // ============================================================================
395
+ checkoutStart(properties = {}) {
396
+ this.updateFunnelStep('checkout');
397
+ return this.track('checkout_start', {
398
+ cart_value: this.cartState.total,
399
+ cart_item_count: this.cartState.itemCount,
400
+ ...properties
401
+ });
402
+ }
403
+ checkoutStep(step, properties = {}) {
404
+ return this.track('checkout_step', {
405
+ checkout_step: step,
406
+ ...properties
407
+ });
408
+ }
409
+ checkoutComplete(order, properties = {}) {
410
+ this.updateFunnelStep('confirmation');
411
+ const event = this.track('checkout_complete', {
412
+ order_id: order.id || order.order_id,
413
+ olo_order_id: order.olo_order_id,
414
+ order_total: order.total,
415
+ subtotal: order.subtotal,
416
+ tax: order.tax,
417
+ tip: order.tip,
418
+ item_count: order.itemCount || order.item_count,
419
+ order_type: order.orderType || order.order_type,
420
+ payment_method: order.paymentMethod || order.payment_method,
421
+ ...properties
422
+ });
423
+ // Clear cart
424
+ this.cartState = { items: [], itemCount: 0, total: 0 };
425
+ return event;
426
+ }
427
+ // ============================================================================
428
+ // AUTH TRACKING
429
+ // ============================================================================
430
+ loginAttempt(method, properties = {}) {
431
+ return this.track('login_attempt', {
432
+ login_method: method,
433
+ funnel_step: this.currentFunnelStep?.name,
434
+ ...properties
435
+ });
436
+ }
437
+ loginSuccess(method, customerId, properties = {}) {
438
+ this.sessionData.is_logged_in = true;
439
+ this.sessionData.login_method = method;
440
+ this.sessionData.logged_in_at = getTimestamp();
441
+ this.sessionData.login_funnel_step = this.currentFunnelStep?.name || null;
442
+ if (customerId) {
443
+ this.customerId = customerId;
444
+ }
445
+ this.saveSession();
446
+ return this.track('login_success', {
447
+ login_method: method,
448
+ customer_id: customerId,
449
+ funnel_step: this.currentFunnelStep?.name,
450
+ ...properties
451
+ });
452
+ }
453
+ loginFailure(method, error, properties = {}) {
454
+ return this.track('login_failure', {
455
+ login_method: method,
456
+ error_message: error,
457
+ ...properties
458
+ });
459
+ }
460
+ logout(properties = {}) {
461
+ const previousCustomerId = this.customerId;
462
+ this.sessionData.is_logged_in = false;
463
+ this.customerId = null;
464
+ this.saveSession();
465
+ return this.track('logout', {
466
+ previous_customer_id: previousCustomerId,
467
+ ...properties
468
+ });
469
+ }
470
+ identify(customerId, traits = {}) {
471
+ this.customerId = customerId;
472
+ this.sessionData.is_logged_in = true;
473
+ this.saveSession();
474
+ return this.track('identify', {
475
+ customer_id: customerId,
476
+ traits: this.sanitizeProperties(traits)
477
+ });
478
+ }
479
+ // ============================================================================
480
+ // STORE SELECTION
481
+ // ============================================================================
482
+ storeSelect(store, properties = {}) {
483
+ const storeId = store.id || store.store_id;
484
+ const storeName = store.name || store.store_name;
485
+ if (storeId) {
486
+ this.config.storeId = storeId;
487
+ }
488
+ if (storeName) {
489
+ // Persist so every subsequent event carries the name (the warehouse
490
+ // resolves location names via ANY_VALUE(store_name) per store_id).
491
+ this.config.storeName = storeName;
492
+ }
493
+ return this.track('store_select', {
494
+ store_id: storeId,
495
+ store_name: storeName,
496
+ ...properties
497
+ });
498
+ }
499
+ setStore(storeId, storeName) {
500
+ this.config.storeId = storeId;
501
+ if (storeName) {
502
+ this.config.storeName = storeName;
503
+ }
504
+ return this;
505
+ }
506
+ // ============================================================================
507
+ // SEARCH
508
+ // ============================================================================
509
+ search(query, resultCount, properties = {}) {
510
+ return this.track('search', {
511
+ search_query: query,
512
+ result_count: resultCount,
513
+ ...properties
514
+ });
515
+ }
516
+ // ============================================================================
517
+ // LOYALTY / REWARDS
518
+ // ============================================================================
519
+ loyaltyLogin(customer, properties = {}) {
520
+ const customerId = customer.id || customer.customer_id;
521
+ if (customerId) {
522
+ this.customerId = customerId;
523
+ }
524
+ return this.track('loyalty_login', {
525
+ customer_id: customerId,
526
+ loyalty_tier: customer.tier,
527
+ points_balance: customer.points,
528
+ ...properties
529
+ });
530
+ }
531
+ rewardView(reward, properties = {}) {
532
+ return this.track('reward_view', {
533
+ reward_id: reward.id,
534
+ reward_name: reward.name,
535
+ reward_value: reward.value,
536
+ points_required: reward.points_required,
537
+ ...properties
538
+ });
539
+ }
540
+ rewardApply(reward, properties = {}) {
541
+ return this.track('reward_apply', {
542
+ reward_id: reward.id,
543
+ reward_name: reward.name,
544
+ reward_value: reward.value,
545
+ points_used: reward.points_used,
546
+ ...properties
547
+ });
548
+ }
549
+ rewardRemove(reward, properties = {}) {
550
+ return this.track('reward_remove', {
551
+ reward_id: reward.id,
552
+ reward_name: reward.name,
553
+ ...properties
554
+ });
555
+ }
556
+ // ============================================================================
557
+ // PROMO CODES
558
+ // ============================================================================
559
+ promoApply(promo, properties = {}) {
560
+ return this.track('promo_apply', {
561
+ promo_code: promo.code,
562
+ discount_amount: promo.discount,
563
+ success: promo.success,
564
+ error_message: promo.error,
565
+ ...properties
566
+ });
567
+ }
568
+ promoRemove(promoCode, properties = {}) {
569
+ return this.track('promo_remove', {
570
+ promo_code: promoCode,
571
+ ...properties
572
+ });
573
+ }
574
+ // ============================================================================
575
+ // EXPERIMENTS
576
+ // ============================================================================
577
+ setExperiment(experimentKey, variantKey) {
578
+ this.sessionData.experiment_assignments[experimentKey] = variantKey;
579
+ this.saveSession();
580
+ return this.track('experiment_assigned', {
581
+ experiment_key: experimentKey,
582
+ variant_key: variantKey
583
+ });
584
+ }
585
+ // ============================================================================
586
+ // ERRORS
587
+ // ============================================================================
588
+ error(errorType, errorMessage, context = {}) {
589
+ return this.track('error', {
590
+ error_type: errorType,
591
+ error_message: errorMessage,
592
+ ...context
593
+ });
594
+ }
595
+ // ============================================================================
596
+ // QUEUE & FLUSH
597
+ // ============================================================================
598
+ queueEvent(event) {
599
+ this.eventQueue.push(event);
600
+ if (this.eventQueue.length >= this.config.batchSize) {
601
+ this.flush();
602
+ }
603
+ }
604
+ startFlushTimer() {
605
+ this.flushTimer = setInterval(() => {
606
+ if (this.eventQueue.length > 0) {
607
+ this.flush();
608
+ }
609
+ }, this.config.flushInterval);
610
+ }
611
+ async flush(sync = false) {
612
+ if (this.eventQueue.length === 0)
613
+ return;
614
+ const events = [...this.eventQueue];
615
+ this.eventQueue = [];
616
+ const payload = {
617
+ batch_id: generateUUID(),
618
+ tenant_slug: this.config.tenantSlug || this.config.tenantId,
619
+ events,
620
+ sent_at: getTimestamp()
621
+ };
622
+ try {
623
+ await fetch(this.config.endpoint, {
624
+ method: 'POST',
625
+ headers: { 'Content-Type': 'application/json' },
626
+ body: JSON.stringify(payload)
627
+ });
628
+ this.log('debug', 'Flushed events', { count: events.length });
629
+ }
630
+ catch (error) {
631
+ this.log('error', 'Failed to send events', error);
632
+ // Re-queue events
633
+ this.eventQueue = [...events, ...this.eventQueue];
634
+ }
635
+ }
636
+ // ============================================================================
637
+ // UTILITIES
638
+ // ============================================================================
639
+ getSessionId() {
640
+ return this.sessionId;
641
+ }
642
+ getVisitorId() {
643
+ return this.visitorId;
644
+ }
645
+ getCustomerId() {
646
+ return this.customerId;
647
+ }
648
+ isLoggedIn() {
649
+ return this.sessionData.is_logged_in;
650
+ }
651
+ log(level, message, data) {
652
+ if (!this.config.debug && level === 'debug')
653
+ return;
654
+ const prefix = '[VeyaAnalytics]';
655
+ if (level === 'error') {
656
+ console.error(prefix, message, data || '');
657
+ }
658
+ else if (level === 'warn') {
659
+ console.warn(prefix, message, data || '');
660
+ }
661
+ else {
662
+ console.log(prefix, message, data || '');
663
+ }
664
+ }
665
+ destroy() {
666
+ if (this.flushTimer) {
667
+ clearInterval(this.flushTimer);
668
+ }
669
+ this.flush(true);
670
+ }
671
+ }
672
+ export default VeyaAnalyticsCore;
673
+ //# sourceMappingURL=VeyaAnalyticsCore.js.map