@aranova/tracking-react 1.2.4 → 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.
package/dist/sales.js CHANGED
@@ -32,6 +32,7 @@ var sales_exports = {};
32
32
  __export(sales_exports, {
33
33
  AranovaApiError: () => AranovaApiError,
34
34
  NAMED_RANGES: () => NAMED_RANGES,
35
+ SALE_SOURCES: () => SALE_SOURCES,
35
36
  SUPPORTED_CURRENCIES: () => SUPPORTED_CURRENCIES,
36
37
  TRACKING_RANGES: () => TRACKING_RANGES,
37
38
  createSalesClient: () => createSalesClient,
@@ -40,6 +41,7 @@ __export(sales_exports, {
40
41
  formatDateInTz: () => formatDateInTz,
41
42
  formatMoney: () => formatMoney,
42
43
  fromMinor: () => fromMinor,
44
+ newBookingAttemptKey: () => newBookingAttemptKey,
43
45
  saleCreateSchema: () => saleCreateSchema,
44
46
  saleItemSchema: () => saleItemSchema,
45
47
  saleServiceSchema: () => saleServiceSchema,
@@ -221,12 +223,105 @@ function fireConversionWithConsent(input, options) {
221
223
  }
222
224
  }
223
225
 
224
- // ../tracking-core/src/ingest.ts
225
- var API_KEY_HEADER = "X-Aranova-Api-Key";
226
- var SDK_VERSION_HEADER = "X-Aranova-Sdk-Version";
227
- var SDK_PACKAGE_HEADER = "X-Aranova-Sdk-Package";
228
- var SDK_SURFACE_HEADER = "X-Aranova-Sdk-Surface";
229
- var SDK_ENVIRONMENT_HEADER = "X-Aranova-Sdk-Environment";
226
+ // ../../contracts/autoleap-booking-links.json
227
+ var autoleap_booking_links_default = {
228
+ hosts: ["myalp.io"]
229
+ };
230
+
231
+ // ../tracking-core/src/integrations-public.ts
232
+ var HANDOFF_HOSTS = new Set(autoleap_booking_links_default.hosts);
233
+ function getBookingIntegration(integrations) {
234
+ const booking = integrations?.autoleap?.booking;
235
+ if (!booking) return null;
236
+ const rules = booking.rules ? camelRules(booking.rules) : null;
237
+ return {
238
+ provider: "autoleap",
239
+ enabled: booking.enabled,
240
+ fallbackUrl: booking.fallback_url,
241
+ acceptsBookings: booking.accepts_bookings && rules !== null && booking.config_version !== null,
242
+ timezone: booking.timezone,
243
+ configVersion: booking.config_version,
244
+ rules,
245
+ options: booking.options.map((option) => ({ key: option.key, label: option.label }))
246
+ };
247
+ }
248
+ function camelRules(rules) {
249
+ return {
250
+ minDaysAhead: rules.min_days_ahead,
251
+ maxDaysAhead: rules.max_days_ahead,
252
+ hours: rules.hours,
253
+ closedDates: rules.closed_dates,
254
+ specialHours: rules.special_hours,
255
+ vehicleRequired: rules.vehicle_required,
256
+ dropOffOptions: rules.drop_off_options
257
+ };
258
+ }
259
+ function buildBookingHandoffUrl(fallbackUrl, prefill = {}) {
260
+ let url;
261
+ try {
262
+ url = new URL(fallbackUrl);
263
+ } catch {
264
+ return null;
265
+ }
266
+ if (url.protocol !== "https:" || !HANDOFF_HOSTS.has(url.hostname) || url.username || url.password || url.port) {
267
+ return null;
268
+ }
269
+ const [firstName, lastName] = prefill.firstName?.trim() ? [prefill.firstName, prefill.lastName] : splitFullName(prefill.name);
270
+ const values = [
271
+ ["firstName", firstName?.trim()],
272
+ ["lastName", lastName?.trim()],
273
+ ["phone", prefill.phone ? nationalDigits(prefill.phone) : ""],
274
+ ["email", prefill.email?.trim()]
275
+ ];
276
+ for (const [key, value] of values) {
277
+ if (value) url.searchParams.set(key, value);
278
+ }
279
+ return url.toString();
280
+ }
281
+ function splitFullName(name) {
282
+ const [first = "", ...rest] = (name ?? "").trim().split(/\s+/);
283
+ return [first, rest.join(" ")];
284
+ }
285
+ function nationalDigits(phone) {
286
+ const parsed = parsePhone(phone);
287
+ return (parsed.isValid ? parsed.national : phone).replace(/\D/g, "");
288
+ }
289
+
290
+ // ../tracking-core/src/uuid.ts
291
+ function uuidV4() {
292
+ const cryptoApi = globalThis.crypto;
293
+ if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
294
+ const bytes = new Uint8Array(16);
295
+ if (typeof cryptoApi?.getRandomValues === "function") cryptoApi.getRandomValues(bytes);
296
+ else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
297
+ bytes[6] = bytes[6] & 15 | 64;
298
+ bytes[8] = bytes[8] & 63 | 128;
299
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
300
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
301
+ }
302
+
303
+ // ../tracking-core/src/session.ts
304
+ var SESSION_STORAGE_KEY = "aranova_tracking_session";
305
+ var SESSION_IDLE_MS = 30 * 60 * 1e3;
306
+ function readLocalStorage(key) {
307
+ try {
308
+ return window.localStorage.getItem(key);
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+ function peekSessionId(now = Date.now()) {
314
+ if (typeof window === "undefined") return null;
315
+ const raw = readLocalStorage(SESSION_STORAGE_KEY);
316
+ if (!raw) return null;
317
+ try {
318
+ const parsed = JSON.parse(raw);
319
+ if (typeof parsed.id !== "string" || typeof parsed.last_event_at !== "number") return null;
320
+ return now - parsed.last_event_at <= SESSION_IDLE_MS ? parsed.id : null;
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
230
325
 
231
326
  // ../tracking-core/src/resources/http/errors.ts
232
327
  var AranovaApiError = class extends Error {
@@ -236,9 +331,17 @@ var AranovaApiError = class extends Error {
236
331
  this.status = options.status;
237
332
  this.code = options.code;
238
333
  this.requestId = options.requestId;
334
+ this.context = options.context;
239
335
  }
240
336
  };
241
337
 
338
+ // ../tracking-core/src/ingest.ts
339
+ var API_KEY_HEADER = "X-Aranova-Api-Key";
340
+ var SDK_VERSION_HEADER = "X-Aranova-Sdk-Version";
341
+ var SDK_PACKAGE_HEADER = "X-Aranova-Sdk-Package";
342
+ var SDK_SURFACE_HEADER = "X-Aranova-Sdk-Surface";
343
+ var SDK_ENVIRONMENT_HEADER = "X-Aranova-Sdk-Environment";
344
+
242
345
  // ../tracking-core/src/resources/http/request.ts
243
346
  function identityHeaders(config) {
244
347
  const headers = { [API_KEY_HEADER]: config.apiKey };
@@ -262,19 +365,24 @@ async function apiRequest(config, method, path, body, extraHeaders) {
262
365
  if (!response.ok) {
263
366
  let detail;
264
367
  let code;
368
+ let context;
265
369
  try {
266
370
  const parsed = await response.json();
267
371
  if (parsed && typeof parsed === "object") {
268
372
  const record3 = parsed;
269
373
  if (typeof record3.detail === "string") detail = record3.detail;
270
374
  if (typeof record3.code === "string") code = record3.code;
375
+ if (record3.context && typeof record3.context === "object") {
376
+ context = record3.context;
377
+ }
271
378
  }
272
379
  } catch {
273
380
  }
274
381
  throw new AranovaApiError(detail ?? response.statusText ?? "Request failed", {
275
382
  status: response.status,
276
383
  code,
277
- requestId: response.headers.get("x-request-id") ?? void 0
384
+ requestId: response.headers.get("x-request-id") ?? void 0,
385
+ context
278
386
  });
279
387
  }
280
388
  if (response.status === 204) return void 0;
@@ -284,6 +392,179 @@ async function apiRequest(config, method, path, body, extraHeaders) {
284
392
  // ../tracking-core/src/resources/sales/transport.ts
285
393
  var salesRequest = apiRequest;
286
394
 
395
+ // ../tracking-core/src/resources/sales/bookings.ts
396
+ var BOOKING_ERROR_CODES = {
397
+ unavailable: "booking_unavailable",
398
+ stale: "booking_config_stale",
399
+ rejected: "booking_rejected"
400
+ };
401
+ var POLL_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
402
+ var INVALID_DETAILS = "Please check your details and try again.";
403
+ var newBookingAttemptKey = uuidV4;
404
+ function sleep(ms) {
405
+ return new Promise((resolve) => {
406
+ setTimeout(resolve, ms);
407
+ });
408
+ }
409
+ function outcomeOf(body) {
410
+ if (body.status === "created")
411
+ return { status: "created", id: body.id, requestCode: body.request_code };
412
+ if (body.status === "pending") return { status: "pending", id: body.id };
413
+ return { status: "failed", id: body.id, fallbackUrl: body.fallback_url };
414
+ }
415
+ function formSubmitPayload(input, formId, labels) {
416
+ const vehicle = input.vehicle ? `${input.vehicle.year} ${input.vehicle.make} ${input.vehicle.model}` : "";
417
+ const field = (name, type, label, value) => ({
418
+ name,
419
+ type,
420
+ label,
421
+ value
422
+ });
423
+ return {
424
+ form: {
425
+ id: formId,
426
+ action: "/tracking/bookings",
427
+ fields: [
428
+ field("first_name", "text", "First name", input.firstName),
429
+ field("last_name", "text", "Last name", input.lastName),
430
+ // E.164 on the wire: it keys the customer and enhanced-conversion matching.
431
+ field("phone", "tel", "Phone", toE164(input.phone) ?? input.phone),
432
+ field("email", "email", "Email", input.email ?? ""),
433
+ field(
434
+ "service",
435
+ "select",
436
+ "Service",
437
+ input.services.map((key) => labels.get(key) ?? key).join(", ")
438
+ ),
439
+ field("vehicle", "text", "Vehicle", vehicle),
440
+ field(
441
+ "start",
442
+ "text",
443
+ "Requested time",
444
+ `${input.startDate} ${String(input.startHour).padStart(2, "0")}:00`
445
+ ),
446
+ field("note", "textarea", "Notes", input.notes ?? "")
447
+ ]
448
+ },
449
+ page: { path: typeof window === "undefined" ? "/" : window.location.pathname }
450
+ };
451
+ }
452
+ function runtimeOf(firing) {
453
+ return firing && "integrations" in firing ? firing : null;
454
+ }
455
+ function createSalesBookingsClient(config, firing) {
456
+ const submittedForms = /* @__PURE__ */ new Set();
457
+ const staleAttempts = /* @__PURE__ */ new Set();
458
+ function handoff(input) {
459
+ const booking = getBookingIntegration(runtimeOf(firing)?.integrations());
460
+ if (!booking) return null;
461
+ return buildBookingHandoffUrl(booking.fallbackUrl, {
462
+ firstName: input.firstName,
463
+ lastName: input.lastName,
464
+ phone: input.phone,
465
+ email: input.email
466
+ });
467
+ }
468
+ function withHandoff(outcome, input) {
469
+ if (outcome.status === "failed" && !outcome.fallbackUrl) {
470
+ return { ...outcome, fallbackUrl: handoff(input) };
471
+ }
472
+ return outcome;
473
+ }
474
+ async function poll(id) {
475
+ let outcome = { status: "pending", id };
476
+ for (const delay of POLL_DELAYS_MS) {
477
+ await sleep(delay);
478
+ try {
479
+ const path = `/bookings/${encodeURIComponent(id)}`;
480
+ outcome = outcomeOf(await salesRequest(config, "GET", path));
481
+ } catch {
482
+ continue;
483
+ }
484
+ if (outcome.status !== "pending") return outcome;
485
+ }
486
+ return outcome;
487
+ }
488
+ async function rejected(error, input, attemptKey, runtime) {
489
+ if (!(error instanceof AranovaApiError)) {
490
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
491
+ }
492
+ if (error.code === BOOKING_ERROR_CODES.stale) {
493
+ if (staleAttempts.has(attemptKey)) {
494
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
495
+ }
496
+ staleAttempts.add(attemptKey);
497
+ await runtime?.revalidate();
498
+ return { status: "stale", message: error.message };
499
+ }
500
+ if (error.code === BOOKING_ERROR_CODES.rejected) {
501
+ const reasons = error.context?.reasons;
502
+ return {
503
+ status: "invalid",
504
+ message: error.message,
505
+ reasons: Array.isArray(reasons) ? reasons.map(String) : []
506
+ };
507
+ }
508
+ if (error.code === "validation_error") {
509
+ return { status: "invalid", message: INVALID_DETAILS, reasons: [] };
510
+ }
511
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
512
+ }
513
+ return {
514
+ async submit(input, { attemptKey, formSubmit }) {
515
+ registerCapability("booking_integration");
516
+ const runtime = runtimeOf(firing);
517
+ const booking = getBookingIntegration(runtime?.integrations());
518
+ if (!booking?.acceptsBookings || booking.configVersion === null) {
519
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
520
+ }
521
+ if (formSubmit && !submittedForms.has(attemptKey)) {
522
+ submittedForms.add(attemptKey);
523
+ const labels = new Map(booking.options.map((option) => [option.key, option.label]));
524
+ formSubmit.tracking.trackEvent(
525
+ "form_submit",
526
+ formSubmitPayload(input, formSubmit.formId, labels)
527
+ );
528
+ }
529
+ const body = {
530
+ idempotency_key: attemptKey,
531
+ // The tracking client's own visit, so the booking and its form_submit share one session.
532
+ session_id: formSubmit?.tracking.getSessionId() ?? peekSessionId() ?? `no-session-${attemptKey}`,
533
+ config_version: booking.configVersion,
534
+ start_date: input.startDate,
535
+ start_hour: input.startHour,
536
+ first_name: input.firstName.trim(),
537
+ last_name: input.lastName.trim(),
538
+ phone: input.phone,
539
+ email: input.email?.trim() || null,
540
+ vehicle: input.vehicle ?? null,
541
+ drop_off: input.dropOff ?? null,
542
+ services: input.services,
543
+ notes: input.notes?.trim() || null,
544
+ marketing_sms_consent: input.marketingSmsConsent === true,
545
+ page_path: typeof window === "undefined" ? null : window.location.pathname
546
+ };
547
+ let response;
548
+ try {
549
+ response = await salesRequest(config, "POST", "/bookings", body);
550
+ } catch (error) {
551
+ return rejected(error, input, attemptKey, runtime);
552
+ }
553
+ const outcome = outcomeOf(response);
554
+ return withHandoff(outcome.status === "pending" ? await poll(outcome.id) : outcome, input);
555
+ },
556
+ async status(id) {
557
+ return outcomeOf(
558
+ await salesRequest(
559
+ config,
560
+ "GET",
561
+ `/bookings/${encodeURIComponent(id)}`
562
+ )
563
+ );
564
+ }
565
+ };
566
+ }
567
+
287
568
  // ../tracking-core/src/resources/intl/date.ts
288
569
  function formatDateInTz(iso3, timeZone, opts, locale) {
289
570
  const date = new Date(iso3);
@@ -493,7 +774,8 @@ function createSalesClient(config) {
493
774
  async config() {
494
775
  return salesRequest(config, "GET", "/business/config");
495
776
  }
496
- }
777
+ },
778
+ bookings: createSalesBookingsClient(config, config.firing)
497
779
  };
498
780
  }
499
781
 
@@ -502,6 +784,12 @@ var z2 = __toESM(require("zod/mini"));
502
784
 
503
785
  // ../tracking-core/src/resources/sales/generated/zod.gen.ts
504
786
  var z = __toESM(require("zod/mini"));
787
+ var zAutoLeapSaleFacts = z.object({
788
+ first_order: z.boolean(),
789
+ fleet_customer: z.boolean(),
790
+ invoice_code: z.string(),
791
+ invoice_id: z.string()
792
+ });
505
793
  var zErrorCode = z.enum([
506
794
  "missing_credentials",
507
795
  "invalid_api_key",
@@ -529,6 +817,9 @@ var zErrorCode = z.enum([
529
817
  "booking_not_reschedulable",
530
818
  "manage_token_invalid",
531
819
  "google_error",
820
+ "booking_unavailable",
821
+ "booking_config_stale",
822
+ "booking_rejected",
532
823
  "calendar_not_owned",
533
824
  "calendar_is_sandbox",
534
825
  "sandbox_exists",
@@ -570,6 +861,7 @@ var zSaleServiceInputSchema = z.strictObject({
570
861
  amount_cents: z.int().check(z.gte(0)),
571
862
  service: z.string()
572
863
  });
864
+ var zSaleSource = z.enum(["api", "admin", "autoleap"]);
573
865
  var zSmsAutomationState = z.enum([
574
866
  "needs_check",
575
867
  "failed",
@@ -601,7 +893,8 @@ var zSmsWithheldReason = z.enum([
601
893
  "number_not_active",
602
894
  "a2p_registration_required",
603
895
  "alert_recipient_cannot_receive_sms",
604
- "line_type_unresolved"
896
+ "line_type_unresolved",
897
+ "sale_source_sends_own_messages"
605
898
  ]);
606
899
  var zSaleSmsStatusSchema = z.object({
607
900
  can_send_now: z._default(z.boolean(), false),
@@ -710,6 +1003,8 @@ var zSaleEntitySchema = z.object({
710
1003
  sms_automation: z.nullable(zSaleSmsStatusSchema),
711
1004
  sms_consent: z._default(z.boolean(), false),
712
1005
  sms_consent_granted_at: z.nullable(z.iso.datetime({ offset: true })),
1006
+ source: zSaleSource,
1007
+ source_facts: z.nullable(zAutoLeapSaleFacts),
713
1008
  updated_at: z.iso.datetime({ offset: true }),
714
1009
  writeback: z.nullable(zSaleWritebackStatusSchema)
715
1010
  });
@@ -719,6 +1014,7 @@ var saleItemSchema = zSaleItemInputSchema;
719
1014
  var saleServiceSchema = zSaleServiceInputSchema;
720
1015
  var currencySchema = zSupportedCurrency;
721
1016
  var SUPPORTED_CURRENCIES = currencySchema.options;
1017
+ var SALE_SOURCES = zSaleSource.options;
722
1018
  var centsSchema = z2.int().check(z2.gte(0));
723
1019
  var metadataSchema = z2.record(z2.string(), z2.unknown());
724
1020
  var customerNameSchema = z2.string().check(z2.maxLength(200));
@@ -799,6 +1095,7 @@ async function fetchServices(config) {
799
1095
  0 && (module.exports = {
800
1096
  AranovaApiError,
801
1097
  NAMED_RANGES,
1098
+ SALE_SOURCES,
802
1099
  SUPPORTED_CURRENCIES,
803
1100
  TRACKING_RANGES,
804
1101
  createSalesClient,
@@ -807,6 +1104,7 @@ async function fetchServices(config) {
807
1104
  formatDateInTz,
808
1105
  formatMoney,
809
1106
  fromMinor,
1107
+ newBookingAttemptKey,
810
1108
  saleCreateSchema,
811
1109
  saleItemSchema,
812
1110
  saleServiceSchema,