@aranova/tracking-react 1.2.5 → 1.4.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.
package/dist/sales.mjs CHANGED
@@ -170,12 +170,106 @@ function fireConversionWithConsent(input, options) {
170
170
  }
171
171
  }
172
172
 
173
- // ../tracking-core/src/ingest.ts
174
- var API_KEY_HEADER = "X-Aranova-Api-Key";
175
- var SDK_VERSION_HEADER = "X-Aranova-Sdk-Version";
176
- var SDK_PACKAGE_HEADER = "X-Aranova-Sdk-Package";
177
- var SDK_SURFACE_HEADER = "X-Aranova-Sdk-Surface";
178
- var SDK_ENVIRONMENT_HEADER = "X-Aranova-Sdk-Environment";
173
+ // ../../contracts/autoleap-booking-links.json
174
+ var autoleap_booking_links_default = {
175
+ hosts: ["myalp.io"]
176
+ };
177
+
178
+ // ../tracking-core/src/integrations-public.ts
179
+ var HANDOFF_HOSTS = new Set(autoleap_booking_links_default.hosts);
180
+ function getBookingIntegration(integrations) {
181
+ const booking = integrations?.autoleap?.booking;
182
+ if (!booking) return null;
183
+ const rules = booking.rules ? camelRules(booking.rules) : null;
184
+ return {
185
+ provider: "autoleap",
186
+ enabled: booking.enabled,
187
+ fallbackUrl: booking.fallback_url,
188
+ acceptsBookings: booking.accepts_bookings && rules !== null && booking.config_version !== null,
189
+ timezone: booking.timezone,
190
+ configVersion: booking.config_version,
191
+ rules,
192
+ options: booking.options.map((option) => ({ key: option.key, label: option.label })),
193
+ mode: booking.mode ?? "live"
194
+ };
195
+ }
196
+ function camelRules(rules) {
197
+ return {
198
+ minDaysAhead: rules.min_days_ahead,
199
+ maxDaysAhead: rules.max_days_ahead,
200
+ hours: rules.hours,
201
+ closedDates: rules.closed_dates,
202
+ specialHours: rules.special_hours,
203
+ vehicleRequired: rules.vehicle_required,
204
+ dropOffOptions: rules.drop_off_options
205
+ };
206
+ }
207
+ function buildBookingHandoffUrl(fallbackUrl, prefill = {}) {
208
+ let url;
209
+ try {
210
+ url = new URL(fallbackUrl);
211
+ } catch {
212
+ return null;
213
+ }
214
+ if (url.protocol !== "https:" || !HANDOFF_HOSTS.has(url.hostname) || url.username || url.password || url.port) {
215
+ return null;
216
+ }
217
+ const [firstName, lastName] = prefill.firstName?.trim() ? [prefill.firstName, prefill.lastName] : splitFullName(prefill.name);
218
+ const values = [
219
+ ["firstName", firstName?.trim()],
220
+ ["lastName", lastName?.trim()],
221
+ ["phone", prefill.phone ? nationalDigits(prefill.phone) : ""],
222
+ ["email", prefill.email?.trim()]
223
+ ];
224
+ for (const [key, value] of values) {
225
+ if (value) url.searchParams.set(key, value);
226
+ }
227
+ return url.toString();
228
+ }
229
+ function splitFullName(name) {
230
+ const [first = "", ...rest] = (name ?? "").trim().split(/\s+/);
231
+ return [first, rest.join(" ")];
232
+ }
233
+ function nationalDigits(phone) {
234
+ const parsed = parsePhone(phone);
235
+ return (parsed.isValid ? parsed.national : phone).replace(/\D/g, "");
236
+ }
237
+
238
+ // ../tracking-core/src/uuid.ts
239
+ function uuidV4() {
240
+ const cryptoApi = globalThis.crypto;
241
+ if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
242
+ const bytes = new Uint8Array(16);
243
+ if (typeof cryptoApi?.getRandomValues === "function") cryptoApi.getRandomValues(bytes);
244
+ else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
245
+ bytes[6] = bytes[6] & 15 | 64;
246
+ bytes[8] = bytes[8] & 63 | 128;
247
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
248
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
249
+ }
250
+
251
+ // ../tracking-core/src/session.ts
252
+ var SESSION_STORAGE_KEY = "aranova_tracking_session";
253
+ var SESSION_IDLE_MS = 30 * 60 * 1e3;
254
+ function readLocalStorage(key) {
255
+ try {
256
+ return window.localStorage.getItem(key);
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+ function peekSessionId(now = Date.now()) {
262
+ if (typeof window === "undefined") return null;
263
+ const raw = readLocalStorage(SESSION_STORAGE_KEY);
264
+ if (!raw) return null;
265
+ try {
266
+ const parsed = JSON.parse(raw);
267
+ if (typeof parsed.id !== "string" || typeof parsed.last_event_at !== "number") return null;
268
+ return now - parsed.last_event_at <= SESSION_IDLE_MS ? parsed.id : null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
179
273
 
180
274
  // ../tracking-core/src/resources/http/errors.ts
181
275
  var AranovaApiError = class extends Error {
@@ -185,9 +279,66 @@ var AranovaApiError = class extends Error {
185
279
  this.status = options.status;
186
280
  this.code = options.code;
187
281
  this.requestId = options.requestId;
282
+ this.context = options.context;
188
283
  }
189
284
  };
190
285
 
286
+ // ../tracking-core/src/resources/intl/date.ts
287
+ function formatDateInTz(iso3, timeZone, opts, locale) {
288
+ const date = new Date(iso3);
289
+ if (Number.isNaN(date.getTime())) return iso3;
290
+ return new Intl.DateTimeFormat(locale, {
291
+ year: "numeric",
292
+ month: "short",
293
+ day: "2-digit",
294
+ hour: "2-digit",
295
+ minute: "2-digit",
296
+ ...opts,
297
+ timeZone
298
+ }).format(date);
299
+ }
300
+
301
+ // ../tracking-core/src/resources/sales/money.ts
302
+ var MINOR_UNIT_EXPONENT = {
303
+ USD: 2,
304
+ CAD: 2
305
+ };
306
+ function exponentFor(currency) {
307
+ return MINOR_UNIT_EXPONENT[currency] ?? 2;
308
+ }
309
+ function toMinor(amount, currency) {
310
+ return Math.round(amount * 10 ** exponentFor(currency));
311
+ }
312
+ function fromMinor(cents, currency) {
313
+ return cents / 10 ** exponentFor(currency);
314
+ }
315
+ function formatMoney(cents, currency, locale) {
316
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(
317
+ fromMinor(cents, currency)
318
+ );
319
+ }
320
+
321
+ // ../tracking-core/src/resources/tracking-config-runtime.ts
322
+ var DEFAULT_CDN_BASE_URL = "https://demos.aranova.io";
323
+ function trackingConfigKey(ref) {
324
+ if (ref.siteId) return `tracking-config/v1/sites/${ref.siteId}-${ref.environment}.json`;
325
+ return `tracking-config/v1/${ref.businessId}-${ref.environment}.json`;
326
+ }
327
+ function resolveTrackingConfigUrl(ref) {
328
+ if (ref.cdnUrl) return ref.cdnUrl;
329
+ const configured = ref.cdnBaseUrl?.trim();
330
+ const base = (configured || DEFAULT_CDN_BASE_URL).replace(/\/+$/, "");
331
+ return `${base}/${trackingConfigKey(ref)}`;
332
+ }
333
+ var RETRY_MAX_MS = 5 * 6e4;
334
+
335
+ // ../tracking-core/src/ingest.ts
336
+ var API_KEY_HEADER = "X-Aranova-Api-Key";
337
+ var SDK_VERSION_HEADER = "X-Aranova-Sdk-Version";
338
+ var SDK_PACKAGE_HEADER = "X-Aranova-Sdk-Package";
339
+ var SDK_SURFACE_HEADER = "X-Aranova-Sdk-Surface";
340
+ var SDK_ENVIRONMENT_HEADER = "X-Aranova-Sdk-Environment";
341
+
191
342
  // ../tracking-core/src/resources/http/request.ts
192
343
  function identityHeaders(config) {
193
344
  const headers = { [API_KEY_HEADER]: config.apiKey };
@@ -211,19 +362,24 @@ async function apiRequest(config, method, path, body, extraHeaders) {
211
362
  if (!response.ok) {
212
363
  let detail;
213
364
  let code;
365
+ let context;
214
366
  try {
215
367
  const parsed = await response.json();
216
368
  if (parsed && typeof parsed === "object") {
217
369
  const record3 = parsed;
218
370
  if (typeof record3.detail === "string") detail = record3.detail;
219
371
  if (typeof record3.code === "string") code = record3.code;
372
+ if (record3.context && typeof record3.context === "object") {
373
+ context = record3.context;
374
+ }
220
375
  }
221
376
  } catch {
222
377
  }
223
378
  throw new AranovaApiError(detail ?? response.statusText ?? "Request failed", {
224
379
  status: response.status,
225
380
  code,
226
- requestId: response.headers.get("x-request-id") ?? void 0
381
+ requestId: response.headers.get("x-request-id") ?? void 0,
382
+ context
227
383
  });
228
384
  }
229
385
  if (response.status === 204) return void 0;
@@ -233,39 +389,212 @@ async function apiRequest(config, method, path, body, extraHeaders) {
233
389
  // ../tracking-core/src/resources/sales/transport.ts
234
390
  var salesRequest = apiRequest;
235
391
 
236
- // ../tracking-core/src/resources/intl/date.ts
237
- function formatDateInTz(iso3, timeZone, opts, locale) {
238
- const date = new Date(iso3);
239
- if (Number.isNaN(date.getTime())) return iso3;
240
- return new Intl.DateTimeFormat(locale, {
241
- year: "numeric",
242
- month: "short",
243
- day: "2-digit",
244
- hour: "2-digit",
245
- minute: "2-digit",
246
- ...opts,
247
- timeZone
248
- }).format(date);
249
- }
250
-
251
- // ../tracking-core/src/resources/sales/money.ts
252
- var MINOR_UNIT_EXPONENT = {
253
- USD: 2,
254
- CAD: 2
392
+ // ../tracking-core/src/resources/sales/bookings.ts
393
+ var BOOKING_SIMULATE_HEADER = "X-Aranova-Simulate";
394
+ var BOOKING_ERROR_CODES = {
395
+ unavailable: "booking_unavailable",
396
+ stale: "booking_config_stale",
397
+ rejected: "booking_rejected"
255
398
  };
256
- function exponentFor(currency) {
257
- return MINOR_UNIT_EXPONENT[currency] ?? 2;
399
+ var POLL_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
400
+ var INVALID_DETAILS = "Please check your details and try again.";
401
+ var newBookingAttemptKey = uuidV4;
402
+ function sleep(ms) {
403
+ return new Promise((resolve) => {
404
+ setTimeout(resolve, ms);
405
+ });
258
406
  }
259
- function toMinor(amount, currency) {
260
- return Math.round(amount * 10 ** exponentFor(currency));
407
+ function outcomeOf(body) {
408
+ const mode = body.mode ?? "live";
409
+ if (body.status === "created")
410
+ return { status: "created", id: body.id, requestCode: body.request_code, mode };
411
+ if (body.status === "pending") return { status: "pending", id: body.id, mode };
412
+ return { status: "failed", id: body.id, fallbackUrl: body.fallback_url, mode };
261
413
  }
262
- function fromMinor(cents, currency) {
263
- return cents / 10 ** exponentFor(currency);
414
+ function formSubmitPayload(input, formId, labels) {
415
+ const vehicle = input.vehicle ? `${input.vehicle.year} ${input.vehicle.make} ${input.vehicle.model}` : "";
416
+ const field = (name, type, label, value) => ({
417
+ name,
418
+ type,
419
+ label,
420
+ value
421
+ });
422
+ return {
423
+ form: {
424
+ id: formId,
425
+ action: "/tracking/bookings",
426
+ fields: [
427
+ field("first_name", "text", "First name", input.firstName),
428
+ field("last_name", "text", "Last name", input.lastName),
429
+ // E.164 on the wire: it keys the customer and enhanced-conversion matching.
430
+ field("phone", "tel", "Phone", toE164(input.phone) ?? input.phone),
431
+ field("email", "email", "Email", input.email ?? ""),
432
+ field(
433
+ "service",
434
+ "select",
435
+ "Service",
436
+ input.services.map((key) => labels.get(key) ?? key).join(", ")
437
+ ),
438
+ field("vehicle", "text", "Vehicle", vehicle),
439
+ field(
440
+ "start",
441
+ "text",
442
+ "Requested time",
443
+ `${input.startDate} ${String(input.startHour).padStart(2, "0")}:00`
444
+ ),
445
+ field("note", "textarea", "Notes", input.notes ?? "")
446
+ ]
447
+ },
448
+ page: { path: typeof window === "undefined" ? "/" : window.location.pathname }
449
+ };
264
450
  }
265
- function formatMoney(cents, currency, locale) {
266
- return new Intl.NumberFormat(locale, { style: "currency", currency }).format(
267
- fromMinor(cents, currency)
268
- );
451
+ function runtimeOf(firing) {
452
+ return firing && "integrations" in firing ? firing : null;
453
+ }
454
+ function isLocalUrl(url) {
455
+ try {
456
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+ function bookingSetupMismatch(endpoint, runtime) {
462
+ if (!runtime || !isLocalUrl(endpoint)) return null;
463
+ const configUrl = resolveTrackingConfigUrl(runtime.ref);
464
+ if (isLocalUrl(configUrl)) return null;
465
+ return `[aranova] Bookings go to the local API (${endpoint}) but the runtime config comes from ${configUrl}. Set NEXT_PUBLIC_ARANOVA_CDN_BASE_URL to the local bucket (http://localhost:9100/aranova-demos) so both come from one backend.`;
466
+ }
467
+ function createSalesBookingsClient(config, firing) {
468
+ const submittedForms = /* @__PURE__ */ new Set();
469
+ const staleAttempts = /* @__PURE__ */ new Set();
470
+ let isSetupChecked = false;
471
+ function warnOnSetupMismatch(runtime) {
472
+ if (isSetupChecked) return;
473
+ isSetupChecked = true;
474
+ const warning = bookingSetupMismatch(config.endpoint, runtime);
475
+ if (warning) console.warn(warning);
476
+ }
477
+ function handoff(input) {
478
+ const booking = getBookingIntegration(runtimeOf(firing)?.integrations());
479
+ if (!booking) return null;
480
+ return buildBookingHandoffUrl(booking.fallbackUrl, {
481
+ firstName: input.firstName,
482
+ lastName: input.lastName,
483
+ phone: input.phone,
484
+ email: input.email
485
+ });
486
+ }
487
+ function failedBeforeSend(input) {
488
+ const mode = getBookingIntegration(runtimeOf(firing)?.integrations())?.mode ?? "live";
489
+ return { status: "failed", id: null, fallbackUrl: handoff(input), mode };
490
+ }
491
+ function withHandoff(outcome, input) {
492
+ if (outcome.status === "failed" && !outcome.fallbackUrl) {
493
+ return { ...outcome, fallbackUrl: handoff(input) };
494
+ }
495
+ return outcome;
496
+ }
497
+ async function poll(id, mode) {
498
+ let outcome = { status: "pending", id, mode };
499
+ for (const delay of POLL_DELAYS_MS) {
500
+ await sleep(delay);
501
+ try {
502
+ const path = `/bookings/${encodeURIComponent(id)}`;
503
+ outcome = outcomeOf(await salesRequest(config, "GET", path));
504
+ } catch {
505
+ continue;
506
+ }
507
+ if (outcome.status !== "pending") return outcome;
508
+ }
509
+ return outcome;
510
+ }
511
+ async function rejected(error, input, attemptKey, runtime) {
512
+ if (!(error instanceof AranovaApiError)) {
513
+ return failedBeforeSend(input);
514
+ }
515
+ if (error.code === BOOKING_ERROR_CODES.stale) {
516
+ if (staleAttempts.has(attemptKey)) return failedBeforeSend(input);
517
+ staleAttempts.add(attemptKey);
518
+ await runtime?.revalidate();
519
+ return { status: "stale", message: error.message };
520
+ }
521
+ if (error.code === BOOKING_ERROR_CODES.rejected) {
522
+ const reasons = error.context?.reasons;
523
+ return {
524
+ status: "invalid",
525
+ message: error.message,
526
+ reasons: Array.isArray(reasons) ? reasons.map(String) : []
527
+ };
528
+ }
529
+ if (error.code === "validation_error") {
530
+ return { status: "invalid", message: INVALID_DETAILS, reasons: [] };
531
+ }
532
+ return failedBeforeSend(input);
533
+ }
534
+ return {
535
+ async submit(input, { attemptKey, formSubmit, simulate }) {
536
+ registerCapability("booking_integration");
537
+ const runtime = runtimeOf(firing);
538
+ warnOnSetupMismatch(runtime);
539
+ const booking = getBookingIntegration(runtime?.integrations());
540
+ if (!booking?.acceptsBookings || booking.configVersion === null) {
541
+ return failedBeforeSend(input);
542
+ }
543
+ if (formSubmit && !submittedForms.has(attemptKey)) {
544
+ submittedForms.add(attemptKey);
545
+ const labels = new Map(booking.options.map((option) => [option.key, option.label]));
546
+ formSubmit.tracking.trackEvent(
547
+ "form_submit",
548
+ formSubmitPayload(input, formSubmit.formId, labels)
549
+ );
550
+ }
551
+ const body = {
552
+ idempotency_key: attemptKey,
553
+ // The tracking client's own visit, so the booking and its form_submit share one session.
554
+ session_id: formSubmit?.tracking.getSessionId() ?? peekSessionId() ?? `no-session-${attemptKey}`,
555
+ config_version: booking.configVersion,
556
+ start_date: input.startDate,
557
+ start_hour: input.startHour,
558
+ first_name: input.firstName.trim(),
559
+ last_name: input.lastName.trim(),
560
+ phone: input.phone,
561
+ email: input.email?.trim() || null,
562
+ vehicle: input.vehicle ?? null,
563
+ drop_off: input.dropOff ?? null,
564
+ services: input.services,
565
+ notes: input.notes?.trim() || null,
566
+ marketing_sms_consent: input.marketingSmsConsent === true,
567
+ page_path: typeof window === "undefined" ? null : window.location.pathname
568
+ };
569
+ let response;
570
+ try {
571
+ const headers = simulate ? { [BOOKING_SIMULATE_HEADER]: simulate } : void 0;
572
+ response = await salesRequest(
573
+ config,
574
+ "POST",
575
+ "/bookings",
576
+ body,
577
+ headers
578
+ );
579
+ } catch (error) {
580
+ return rejected(error, input, attemptKey, runtime);
581
+ }
582
+ const outcome = outcomeOf(response);
583
+ return withHandoff(
584
+ outcome.status === "pending" ? await poll(outcome.id, outcome.mode) : outcome,
585
+ input
586
+ );
587
+ },
588
+ async status(id) {
589
+ return outcomeOf(
590
+ await salesRequest(
591
+ config,
592
+ "GET",
593
+ `/bookings/${encodeURIComponent(id)}`
594
+ )
595
+ );
596
+ }
597
+ };
269
598
  }
270
599
 
271
600
  // ../tracking-core/src/resources/sales/client.ts
@@ -442,7 +771,8 @@ function createSalesClient(config) {
442
771
  async config() {
443
772
  return salesRequest(config, "GET", "/business/config");
444
773
  }
445
- }
774
+ },
775
+ bookings: createSalesBookingsClient(config, config.firing)
446
776
  };
447
777
  }
448
778
 
@@ -451,6 +781,12 @@ import * as z2 from "zod/mini";
451
781
 
452
782
  // ../tracking-core/src/resources/sales/generated/zod.gen.ts
453
783
  import * as z from "zod/mini";
784
+ var zAutoLeapSaleFacts = z.object({
785
+ first_order: z.boolean(),
786
+ fleet_customer: z.boolean(),
787
+ invoice_code: z.string(),
788
+ invoice_id: z.string()
789
+ });
454
790
  var zErrorCode = z.enum([
455
791
  "missing_credentials",
456
792
  "invalid_api_key",
@@ -478,6 +814,9 @@ var zErrorCode = z.enum([
478
814
  "booking_not_reschedulable",
479
815
  "manage_token_invalid",
480
816
  "google_error",
817
+ "booking_unavailable",
818
+ "booking_config_stale",
819
+ "booking_rejected",
481
820
  "calendar_not_owned",
482
821
  "calendar_is_sandbox",
483
822
  "sandbox_exists",
@@ -519,6 +858,7 @@ var zSaleServiceInputSchema = z.strictObject({
519
858
  amount_cents: z.int().check(z.gte(0)),
520
859
  service: z.string()
521
860
  });
861
+ var zSaleSource = z.enum(["api", "admin", "autoleap"]);
522
862
  var zSmsAutomationState = z.enum([
523
863
  "needs_check",
524
864
  "failed",
@@ -550,7 +890,8 @@ var zSmsWithheldReason = z.enum([
550
890
  "number_not_active",
551
891
  "a2p_registration_required",
552
892
  "alert_recipient_cannot_receive_sms",
553
- "line_type_unresolved"
893
+ "line_type_unresolved",
894
+ "sale_source_sends_own_messages"
554
895
  ]);
555
896
  var zSaleSmsStatusSchema = z.object({
556
897
  can_send_now: z._default(z.boolean(), false),
@@ -659,6 +1000,8 @@ var zSaleEntitySchema = z.object({
659
1000
  sms_automation: z.nullable(zSaleSmsStatusSchema),
660
1001
  sms_consent: z._default(z.boolean(), false),
661
1002
  sms_consent_granted_at: z.nullable(z.iso.datetime({ offset: true })),
1003
+ source: zSaleSource,
1004
+ source_facts: z.nullable(zAutoLeapSaleFacts),
662
1005
  updated_at: z.iso.datetime({ offset: true }),
663
1006
  writeback: z.nullable(zSaleWritebackStatusSchema)
664
1007
  });
@@ -668,6 +1011,7 @@ var saleItemSchema = zSaleItemInputSchema;
668
1011
  var saleServiceSchema = zSaleServiceInputSchema;
669
1012
  var currencySchema = zSupportedCurrency;
670
1013
  var SUPPORTED_CURRENCIES = currencySchema.options;
1014
+ var SALE_SOURCES = zSaleSource.options;
671
1015
  var centsSchema = z2.int().check(z2.gte(0));
672
1016
  var metadataSchema = z2.record(z2.string(), z2.unknown());
673
1017
  var customerNameSchema = z2.string().check(z2.maxLength(200));
@@ -747,6 +1091,7 @@ async function fetchServices(config) {
747
1091
  export {
748
1092
  AranovaApiError,
749
1093
  NAMED_RANGES,
1094
+ SALE_SOURCES,
750
1095
  SUPPORTED_CURRENCIES,
751
1096
  TRACKING_RANGES,
752
1097
  createSalesClient,
@@ -755,6 +1100,7 @@ export {
755
1100
  formatDateInTz,
756
1101
  formatMoney,
757
1102
  fromMinor,
1103
+ newBookingAttemptKey,
758
1104
  saleCreateSchema,
759
1105
  saleItemSchema,
760
1106
  saleServiceSchema,