@joryio/web-sdk 0.0.0-stage → 1.3.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. package/src/worker/joryio-sw.js +218 -0
@@ -0,0 +1,482 @@
1
+ /**
2
+ * E-Commerce tracking helpers for Joryio SDK
3
+ */
4
+
5
+ import type { JoryioSDK } from './sdk';
6
+ import { Logger } from '../utils/logger';
7
+
8
+ export interface EcommerceProduct {
9
+ productId: string;
10
+ name: string;
11
+ price: number;
12
+ quantity?: number;
13
+ category?: string;
14
+ brand?: string;
15
+ variant?: string;
16
+ variantId?: string;
17
+ sku?: string;
18
+ imageUrl?: string;
19
+ url?: string;
20
+ }
21
+
22
+ export interface EcommerceCartItem extends EcommerceProduct {
23
+ quantity: number;
24
+ }
25
+
26
+ export interface EcommerceOrder {
27
+ orderId: string;
28
+ items: EcommerceCartItem[];
29
+ value: number;
30
+ currency?: string;
31
+ shipping?: number;
32
+ tax?: number;
33
+ discount?: number;
34
+ coupon?: string;
35
+ }
36
+
37
+ export interface EcommerceConfig {
38
+ /** Default currency for every event (ISO 4217). Default `USD`. */
39
+ currency?: string;
40
+ }
41
+
42
+ /** A required id or label: a string that is not empty or only whitespace. */
43
+ const isText = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0;
44
+ const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
45
+ /** A finite number, or undefined - never NaN or Infinity on the wire. */
46
+ const finiteOrUndefined = (v: unknown): number | undefined => (isFiniteNumber(v) ? v : undefined);
47
+
48
+ /**
49
+ * E-Commerce tracking helper class
50
+ * Provides standardized e-commerce event tracking
51
+ *
52
+ * Every helper checks its required arguments and, when one is missing, logs
53
+ * a warning and sends NOTHING (SDK 1.3.0 contract item 42). ONE set on every
54
+ * Joryio SDK and wrapper - web, iOS, Android, React Native, Unity - pinned by
55
+ * the backend's ecommerce-guard-parity spec:
56
+ * - a product (and every cart/order line): a productId and a finite price;
57
+ * - cart calls: an items array and a finite cartValue;
58
+ * - purchase: an orderId, an items array (may be empty) and a finite value;
59
+ * - every other id or label the event is about (categoryId and
60
+ * categoryName, query, paymentMethod, shareMethod, orderId, couponCode);
61
+ * - an optional amount (cartValue on addToCart, shipping, tax, discount,
62
+ * refundAmount, discountAmount) may be left out, but one given must be
63
+ * finite - it is refused, not silently dropped.
64
+ * An id that is empty or only whitespace counts as missing. It used to throw into the host page on a missing `items` or a
65
+ * null product, and to send an "Order Completed" with no order id and a total
66
+ * of 0 that reporting counted as a real order. No helper ever throws.
67
+ */
68
+ export class EcommerceTracker {
69
+ private sdk: JoryioSDK;
70
+ private config: { currency: string };
71
+ private logger: Logger;
72
+
73
+ constructor(sdk: JoryioSDK, config?: EcommerceConfig | null, logger?: Logger) {
74
+ this.sdk = sdk;
75
+ this.config = {
76
+ currency: (config && typeof config.currency === 'string' && config.currency) || 'USD',
77
+ };
78
+ this.logger = logger || new Logger(false);
79
+ }
80
+
81
+ /** Run one helper: a missing argument is a warning and no event, never a throw. */
82
+ private guard(method: string, problem: string | null, send: () => void): void {
83
+ if (problem) {
84
+ this.logger.warn(`ecommerce.${method}() not tracked: ${problem}`);
85
+ return;
86
+ }
87
+ try {
88
+ send();
89
+ } catch (error) {
90
+ this.logger.error(`ecommerce.${method}() failed:`, error);
91
+ }
92
+ }
93
+
94
+ private productProblem(product: unknown, what = 'product'): string | null {
95
+ if (!product || typeof product !== 'object') return `a ${what} object is required`;
96
+ if (!isText((product as any).productId)) return `${what}.productId is required`;
97
+ if (!isFiniteNumber((product as any).price)) return `${what}.price must be a finite number`;
98
+ return null;
99
+ }
100
+
101
+ /** An items array whose every line (a null entry is skipped) is a valid product. */
102
+ private itemsProblem(items: unknown): string | null {
103
+ if (!Array.isArray(items)) return 'an items array is required';
104
+ for (const item of items) {
105
+ if (item == null) continue;
106
+ const problem = this.productProblem(item, 'item');
107
+ if (problem) return problem;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /** A cart: valid lines and a finite total. */
113
+ private cartProblem(items: unknown, cartValue: unknown): string | null {
114
+ return this.itemsProblem(items) ?? (isFiniteNumber(cartValue) ? null : 'cartValue must be a finite number');
115
+ }
116
+
117
+ /** Text arguments, each required: the first one missing, or null. */
118
+ private textProblem(args: Record<string, unknown>): string | null {
119
+ for (const [name, value] of Object.entries(args)) {
120
+ if (!isText(value)) return `a ${name} is required`;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /** Optional amounts: absent is fine, present must be finite. */
126
+ private amountsProblem(args: Record<string, unknown>): string | null {
127
+ for (const [name, value] of Object.entries(args)) {
128
+ if (value !== undefined && value !== null && !isFiniteNumber(value)) return `${name} must be a finite number`;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /** The lines to describe (null entries skipped; the rest passed the guard). */
134
+ private validItems(items: EcommerceCartItem[]): EcommerceCartItem[] {
135
+ return items.filter((item) => item && typeof item === 'object');
136
+ }
137
+
138
+ private itemCount(items: EcommerceCartItem[]): number {
139
+ return items.reduce((sum, item) => sum + (isFiniteNumber(item.quantity) ? item.quantity : 0), 0);
140
+ }
141
+
142
+ /**
143
+ * Track when a user views a product
144
+ */
145
+ viewProduct(product: EcommerceProduct): void {
146
+ this.guard('viewProduct', this.productProblem(product), () => this.sdk.track('Product Viewed', {
147
+ product_id: product.productId,
148
+ name: product.name,
149
+ price: product.price,
150
+ category: product.category,
151
+ brand: product.brand,
152
+ variant: product.variant,
153
+ variant_id: product.variantId,
154
+ sku: product.sku,
155
+ image_url: product.imageUrl,
156
+ url: product.url,
157
+ currency: this.config.currency,
158
+ }));
159
+ }
160
+
161
+ /**
162
+ * Track when a user views a category/collection page
163
+ */
164
+ viewCategory(categoryId: string, categoryName: string, properties?: Record<string, any>): void {
165
+ this.guard('viewCategory', this.textProblem({ categoryId, categoryName }), () =>
166
+ this.sdk.track('Product List Viewed', {
167
+ category_id: categoryId,
168
+ category_name: categoryName,
169
+ ...(properties && typeof properties === 'object' ? properties : {}),
170
+ }));
171
+ }
172
+
173
+ /**
174
+ * Track when a user searches for products
175
+ */
176
+ search(query: string, resultCount?: number, properties?: Record<string, any>): void {
177
+ this.guard('search', this.textProblem({ query }), () =>
178
+ this.sdk.track('Products Searched', {
179
+ query,
180
+ result_count: finiteOrUndefined(resultCount),
181
+ ...(properties && typeof properties === 'object' ? properties : {}),
182
+ }));
183
+ }
184
+
185
+ /**
186
+ * Track when a user adds an item to cart
187
+ */
188
+ addToCart(item: EcommerceCartItem, cartValue?: number): void {
189
+ const problem = this.productProblem(item, 'item') ?? this.amountsProblem({ cartValue });
190
+ this.guard('addToCart', problem, () => this.sdk.track('Product Added', {
191
+ product_id: item.productId,
192
+ name: item.name,
193
+ price: item.price,
194
+ quantity: item.quantity,
195
+ category: item.category,
196
+ brand: item.brand,
197
+ variant: item.variant,
198
+ variant_id: item.variantId,
199
+ sku: item.sku,
200
+ image_url: item.imageUrl,
201
+ url: item.url,
202
+ item_total: finiteOrUndefined(item.price * item.quantity),
203
+ cart_value: finiteOrUndefined(cartValue),
204
+ currency: this.config.currency,
205
+ }));
206
+ }
207
+
208
+ /**
209
+ * Track when a user removes an item from cart
210
+ */
211
+ removeFromCart(item: EcommerceProduct, quantity: number = 1): void {
212
+ this.guard('removeFromCart', this.productProblem(item, 'item'), () =>
213
+ this.sdk.track('Product Removed', {
214
+ product_id: item.productId,
215
+ name: item.name,
216
+ price: item.price,
217
+ quantity: isFiniteNumber(quantity) ? quantity : 1,
218
+ variant_id: item.variantId,
219
+ sku: item.sku,
220
+ currency: this.config.currency,
221
+ }));
222
+ }
223
+
224
+ /**
225
+ * Track when a user updates their cart
226
+ */
227
+ /**
228
+ * Track the cart being viewed.
229
+ *
230
+ * `Cart Viewed` has been in the canonical event list all along, but no
231
+ * tracker had a method for it - so the one way to send it was to type the
232
+ * name by hand, which is the habit these trackers exist to remove. Same
233
+ * payload as updateCart, since the cart is the same thing either way.
234
+ */
235
+ cartViewed(items: EcommerceCartItem[], cartValue: number): void {
236
+ this.guard('cartViewed', this.cartProblem(items, cartValue), () => {
237
+ const valid = this.validItems(items);
238
+ this.sdk.track('Cart Viewed', {
239
+ items: valid.map((item) => ({
240
+ product_id: item.productId,
241
+ name: item.name,
242
+ price: item.price,
243
+ quantity: item.quantity,
244
+ variant_id: item.variantId,
245
+ sku: item.sku,
246
+ })),
247
+ item_count: this.itemCount(valid),
248
+ cart_value: finiteOrUndefined(cartValue),
249
+ currency: this.config.currency,
250
+ });
251
+ });
252
+ }
253
+
254
+ updateCart(items: EcommerceCartItem[], cartValue: number): void {
255
+ this.guard('updateCart', this.cartProblem(items, cartValue), () => {
256
+ const valid = this.validItems(items);
257
+ this.sdk.track('Cart Updated', {
258
+ items: valid.map((item) => ({
259
+ product_id: item.productId,
260
+ name: item.name,
261
+ price: item.price,
262
+ quantity: item.quantity,
263
+ variant_id: item.variantId,
264
+ sku: item.sku,
265
+ })),
266
+ item_count: this.itemCount(valid),
267
+ cart_value: finiteOrUndefined(cartValue),
268
+ currency: this.config.currency,
269
+ });
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Track when a user starts checkout
275
+ */
276
+ startCheckout(items: EcommerceCartItem[], cartValue: number, properties?: Record<string, any>): void {
277
+ this.guard('startCheckout', this.cartProblem(items, cartValue), () => {
278
+ const valid = this.validItems(items);
279
+ this.sdk.track('Checkout Started', {
280
+ items: valid.map((item) => ({
281
+ product_id: item.productId,
282
+ name: item.name,
283
+ price: item.price,
284
+ quantity: item.quantity,
285
+ variant_id: item.variantId,
286
+ sku: item.sku,
287
+ })),
288
+ item_count: this.itemCount(valid),
289
+ value: finiteOrUndefined(cartValue),
290
+ currency: this.config.currency,
291
+ ...(properties && typeof properties === 'object' ? properties : {}),
292
+ });
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Track when a user adds payment info
298
+ */
299
+ addPaymentInfo(paymentMethod: string, properties?: Record<string, any>): void {
300
+ this.guard('addPaymentInfo', this.textProblem({ paymentMethod }), () =>
301
+ this.sdk.track('Payment Info Entered', {
302
+ payment_method: paymentMethod,
303
+ ...(properties && typeof properties === 'object' ? properties : {}),
304
+ }));
305
+ }
306
+
307
+ /**
308
+ * Track when an order is placed
309
+ */
310
+ purchase(order: EcommerceOrder): void {
311
+ // Never a zero or empty order: no order id, no finite value or no items
312
+ // array is a warning and nothing sent (contract item 42).
313
+ const problem = !order || typeof order !== 'object'
314
+ ? 'an order object is required'
315
+ : !isText(order.orderId)
316
+ ? 'order.orderId is required'
317
+ : !isFiniteNumber(order.value)
318
+ ? 'order.value must be a finite number'
319
+ : this.itemsProblem(order.items) ??
320
+ this.amountsProblem({ shipping: order.shipping, tax: order.tax, discount: order.discount });
321
+ this.guard('purchase', problem, () => {
322
+ const items = this.validItems(order.items);
323
+ this.sdk.track('Order Completed', {
324
+ // `orderId` and `total`, NOT `order_id` and `value`.
325
+ //
326
+ // The revenue pipeline reads exactly these two keys - see
327
+ // purchase-vocabulary.ts, which calls `value`/`revenue` "somebody else's
328
+ // convention" and warns that documenting `order_id` "would have told
329
+ // customers to send a key nothing reads". Every purchase tracked through
330
+ // this tracker therefore landed as ZERO revenue with no order id for
331
+ // attribution, on all three SDKs, for as long as it has existed.
332
+ //
333
+ // Not a crash, not a warning: a $0 order that looks like an order.
334
+ orderId: order.orderId,
335
+ items: items.map((item) => ({
336
+ product_id: item.productId,
337
+ name: item.name,
338
+ price: item.price,
339
+ quantity: item.quantity,
340
+ category: item.category,
341
+ brand: item.brand,
342
+ variant_id: item.variantId,
343
+ sku: item.sku,
344
+ })),
345
+ item_count: this.itemCount(items),
346
+ total: order.value,
347
+ currency: order.currency || this.config.currency,
348
+ shipping: finiteOrUndefined(order.shipping),
349
+ tax: finiteOrUndefined(order.tax),
350
+ discount: finiteOrUndefined(order.discount),
351
+ coupon: order.coupon,
352
+ });
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Track order fulfillment (shipped)
358
+ */
359
+ orderFulfilled(orderId: string, trackingNumber?: string, carrier?: string): void {
360
+ this.guard('orderFulfilled', isText(orderId) ? null : 'an orderId is required', () =>
361
+ this.sdk.track('Order Fulfilled', {
362
+ order_id: orderId,
363
+ tracking_number: trackingNumber,
364
+ carrier,
365
+ }));
366
+ }
367
+
368
+ /**
369
+ * Track order delivery
370
+ */
371
+ orderDelivered(orderId: string): void {
372
+ this.guard('orderDelivered', isText(orderId) ? null : 'an orderId is required', () =>
373
+ this.sdk.track('Order Delivered', {
374
+ order_id: orderId,
375
+ }));
376
+ }
377
+
378
+ /**
379
+ * Track order cancellation
380
+ */
381
+ orderCancelled(orderId: string, reason?: string): void {
382
+ this.guard('orderCancelled', isText(orderId) ? null : 'an orderId is required', () =>
383
+ this.sdk.track('Order Cancelled', {
384
+ order_id: orderId,
385
+ reason,
386
+ }));
387
+ }
388
+
389
+ /**
390
+ * Track order refund
391
+ */
392
+ orderRefunded(orderId: string, refundAmount?: number, reason?: string): void {
393
+ const problem = this.textProblem({ orderId }) ?? this.amountsProblem({ refundAmount });
394
+ this.guard('orderRefunded', problem, () =>
395
+ this.sdk.track('Order Refunded', {
396
+ order_id: orderId,
397
+ refund_amount: finiteOrUndefined(refundAmount),
398
+ reason,
399
+ }));
400
+ }
401
+
402
+ /**
403
+ * Track product added to wishlist
404
+ */
405
+ addToWishlist(product: EcommerceProduct): void {
406
+ this.guard('addToWishlist', this.productProblem(product), () =>
407
+ this.sdk.track('Product Added to Wishlist', {
408
+ product_id: product.productId,
409
+ name: product.name,
410
+ price: product.price,
411
+ category: product.category,
412
+ brand: product.brand,
413
+ currency: this.config.currency,
414
+ }));
415
+ }
416
+
417
+ /**
418
+ * Track product removed from wishlist
419
+ */
420
+ removeFromWishlist(product: EcommerceProduct): void {
421
+ this.guard('removeFromWishlist', this.productProblem(product), () =>
422
+ this.sdk.track('Product Removed from Wishlist', {
423
+ product_id: product.productId,
424
+ name: product.name,
425
+ }));
426
+ }
427
+
428
+ /**
429
+ * Track product shared
430
+ */
431
+ shareProduct(product: EcommerceProduct, shareMethod: string): void {
432
+ this.guard('shareProduct', this.productProblem(product) ?? this.textProblem({ shareMethod }), () =>
433
+ this.sdk.track('Product Shared', {
434
+ product_id: product.productId,
435
+ name: product.name,
436
+ share_method: shareMethod,
437
+ }));
438
+ }
439
+
440
+ /**
441
+ * Track coupon applied
442
+ */
443
+ applyCoupon(couponCode: string, discountAmount?: number, discountType?: 'fixed' | 'percentage'): void {
444
+ const problem = this.textProblem({ couponCode }) ?? this.amountsProblem({ discountAmount });
445
+ this.guard('applyCoupon', problem, () =>
446
+ this.sdk.track('Coupon Applied', {
447
+ coupon_code: couponCode,
448
+ discount_amount: finiteOrUndefined(discountAmount),
449
+ discount_type: discountType,
450
+ }));
451
+ }
452
+
453
+ /**
454
+ * Track coupon removed
455
+ */
456
+ removeCoupon(couponCode: string): void {
457
+ this.guard('removeCoupon', isText(couponCode) ? null : 'a couponCode is required', () =>
458
+ this.sdk.track('Coupon Removed', {
459
+ coupon_code: couponCode,
460
+ }));
461
+ }
462
+
463
+ /**
464
+ * Track product review submitted
465
+ */
466
+ submitReview(productId: string, rating: number, reviewText?: string): void {
467
+ const problem = !isText(productId) ? 'a productId is required' : !isFiniteNumber(rating) ? 'rating must be a finite number' : null;
468
+ this.guard('submitReview', problem, () =>
469
+ this.sdk.track('Product Reviewed', {
470
+ product_id: productId,
471
+ rating,
472
+ review_text: reviewText,
473
+ }));
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Create an e-commerce tracker instance
479
+ */
480
+ export function createEcommerceTracker(sdk: JoryioSDK, config?: EcommerceConfig | null, logger?: Logger): EcommerceTracker {
481
+ return new EcommerceTracker(sdk, config, logger);
482
+ }