@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.mjs CHANGED
@@ -170,12 +170,105 @@ 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
+ };
194
+ }
195
+ function camelRules(rules) {
196
+ return {
197
+ minDaysAhead: rules.min_days_ahead,
198
+ maxDaysAhead: rules.max_days_ahead,
199
+ hours: rules.hours,
200
+ closedDates: rules.closed_dates,
201
+ specialHours: rules.special_hours,
202
+ vehicleRequired: rules.vehicle_required,
203
+ dropOffOptions: rules.drop_off_options
204
+ };
205
+ }
206
+ function buildBookingHandoffUrl(fallbackUrl, prefill = {}) {
207
+ let url;
208
+ try {
209
+ url = new URL(fallbackUrl);
210
+ } catch {
211
+ return null;
212
+ }
213
+ if (url.protocol !== "https:" || !HANDOFF_HOSTS.has(url.hostname) || url.username || url.password || url.port) {
214
+ return null;
215
+ }
216
+ const [firstName, lastName] = prefill.firstName?.trim() ? [prefill.firstName, prefill.lastName] : splitFullName(prefill.name);
217
+ const values = [
218
+ ["firstName", firstName?.trim()],
219
+ ["lastName", lastName?.trim()],
220
+ ["phone", prefill.phone ? nationalDigits(prefill.phone) : ""],
221
+ ["email", prefill.email?.trim()]
222
+ ];
223
+ for (const [key, value] of values) {
224
+ if (value) url.searchParams.set(key, value);
225
+ }
226
+ return url.toString();
227
+ }
228
+ function splitFullName(name) {
229
+ const [first = "", ...rest] = (name ?? "").trim().split(/\s+/);
230
+ return [first, rest.join(" ")];
231
+ }
232
+ function nationalDigits(phone) {
233
+ const parsed = parsePhone(phone);
234
+ return (parsed.isValid ? parsed.national : phone).replace(/\D/g, "");
235
+ }
236
+
237
+ // ../tracking-core/src/uuid.ts
238
+ function uuidV4() {
239
+ const cryptoApi = globalThis.crypto;
240
+ if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
241
+ const bytes = new Uint8Array(16);
242
+ if (typeof cryptoApi?.getRandomValues === "function") cryptoApi.getRandomValues(bytes);
243
+ else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
244
+ bytes[6] = bytes[6] & 15 | 64;
245
+ bytes[8] = bytes[8] & 63 | 128;
246
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
247
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
248
+ }
249
+
250
+ // ../tracking-core/src/session.ts
251
+ var SESSION_STORAGE_KEY = "aranova_tracking_session";
252
+ var SESSION_IDLE_MS = 30 * 60 * 1e3;
253
+ function readLocalStorage(key) {
254
+ try {
255
+ return window.localStorage.getItem(key);
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+ function peekSessionId(now = Date.now()) {
261
+ if (typeof window === "undefined") return null;
262
+ const raw = readLocalStorage(SESSION_STORAGE_KEY);
263
+ if (!raw) return null;
264
+ try {
265
+ const parsed = JSON.parse(raw);
266
+ if (typeof parsed.id !== "string" || typeof parsed.last_event_at !== "number") return null;
267
+ return now - parsed.last_event_at <= SESSION_IDLE_MS ? parsed.id : null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
179
272
 
180
273
  // ../tracking-core/src/resources/http/errors.ts
181
274
  var AranovaApiError = class extends Error {
@@ -185,9 +278,17 @@ var AranovaApiError = class extends Error {
185
278
  this.status = options.status;
186
279
  this.code = options.code;
187
280
  this.requestId = options.requestId;
281
+ this.context = options.context;
188
282
  }
189
283
  };
190
284
 
285
+ // ../tracking-core/src/ingest.ts
286
+ var API_KEY_HEADER = "X-Aranova-Api-Key";
287
+ var SDK_VERSION_HEADER = "X-Aranova-Sdk-Version";
288
+ var SDK_PACKAGE_HEADER = "X-Aranova-Sdk-Package";
289
+ var SDK_SURFACE_HEADER = "X-Aranova-Sdk-Surface";
290
+ var SDK_ENVIRONMENT_HEADER = "X-Aranova-Sdk-Environment";
291
+
191
292
  // ../tracking-core/src/resources/http/request.ts
192
293
  function identityHeaders(config) {
193
294
  const headers = { [API_KEY_HEADER]: config.apiKey };
@@ -211,19 +312,24 @@ async function apiRequest(config, method, path, body, extraHeaders) {
211
312
  if (!response.ok) {
212
313
  let detail;
213
314
  let code;
315
+ let context;
214
316
  try {
215
317
  const parsed = await response.json();
216
318
  if (parsed && typeof parsed === "object") {
217
319
  const record3 = parsed;
218
320
  if (typeof record3.detail === "string") detail = record3.detail;
219
321
  if (typeof record3.code === "string") code = record3.code;
322
+ if (record3.context && typeof record3.context === "object") {
323
+ context = record3.context;
324
+ }
220
325
  }
221
326
  } catch {
222
327
  }
223
328
  throw new AranovaApiError(detail ?? response.statusText ?? "Request failed", {
224
329
  status: response.status,
225
330
  code,
226
- requestId: response.headers.get("x-request-id") ?? void 0
331
+ requestId: response.headers.get("x-request-id") ?? void 0,
332
+ context
227
333
  });
228
334
  }
229
335
  if (response.status === 204) return void 0;
@@ -233,6 +339,179 @@ async function apiRequest(config, method, path, body, extraHeaders) {
233
339
  // ../tracking-core/src/resources/sales/transport.ts
234
340
  var salesRequest = apiRequest;
235
341
 
342
+ // ../tracking-core/src/resources/sales/bookings.ts
343
+ var BOOKING_ERROR_CODES = {
344
+ unavailable: "booking_unavailable",
345
+ stale: "booking_config_stale",
346
+ rejected: "booking_rejected"
347
+ };
348
+ var POLL_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
349
+ var INVALID_DETAILS = "Please check your details and try again.";
350
+ var newBookingAttemptKey = uuidV4;
351
+ function sleep(ms) {
352
+ return new Promise((resolve) => {
353
+ setTimeout(resolve, ms);
354
+ });
355
+ }
356
+ function outcomeOf(body) {
357
+ if (body.status === "created")
358
+ return { status: "created", id: body.id, requestCode: body.request_code };
359
+ if (body.status === "pending") return { status: "pending", id: body.id };
360
+ return { status: "failed", id: body.id, fallbackUrl: body.fallback_url };
361
+ }
362
+ function formSubmitPayload(input, formId, labels) {
363
+ const vehicle = input.vehicle ? `${input.vehicle.year} ${input.vehicle.make} ${input.vehicle.model}` : "";
364
+ const field = (name, type, label, value) => ({
365
+ name,
366
+ type,
367
+ label,
368
+ value
369
+ });
370
+ return {
371
+ form: {
372
+ id: formId,
373
+ action: "/tracking/bookings",
374
+ fields: [
375
+ field("first_name", "text", "First name", input.firstName),
376
+ field("last_name", "text", "Last name", input.lastName),
377
+ // E.164 on the wire: it keys the customer and enhanced-conversion matching.
378
+ field("phone", "tel", "Phone", toE164(input.phone) ?? input.phone),
379
+ field("email", "email", "Email", input.email ?? ""),
380
+ field(
381
+ "service",
382
+ "select",
383
+ "Service",
384
+ input.services.map((key) => labels.get(key) ?? key).join(", ")
385
+ ),
386
+ field("vehicle", "text", "Vehicle", vehicle),
387
+ field(
388
+ "start",
389
+ "text",
390
+ "Requested time",
391
+ `${input.startDate} ${String(input.startHour).padStart(2, "0")}:00`
392
+ ),
393
+ field("note", "textarea", "Notes", input.notes ?? "")
394
+ ]
395
+ },
396
+ page: { path: typeof window === "undefined" ? "/" : window.location.pathname }
397
+ };
398
+ }
399
+ function runtimeOf(firing) {
400
+ return firing && "integrations" in firing ? firing : null;
401
+ }
402
+ function createSalesBookingsClient(config, firing) {
403
+ const submittedForms = /* @__PURE__ */ new Set();
404
+ const staleAttempts = /* @__PURE__ */ new Set();
405
+ function handoff(input) {
406
+ const booking = getBookingIntegration(runtimeOf(firing)?.integrations());
407
+ if (!booking) return null;
408
+ return buildBookingHandoffUrl(booking.fallbackUrl, {
409
+ firstName: input.firstName,
410
+ lastName: input.lastName,
411
+ phone: input.phone,
412
+ email: input.email
413
+ });
414
+ }
415
+ function withHandoff(outcome, input) {
416
+ if (outcome.status === "failed" && !outcome.fallbackUrl) {
417
+ return { ...outcome, fallbackUrl: handoff(input) };
418
+ }
419
+ return outcome;
420
+ }
421
+ async function poll(id) {
422
+ let outcome = { status: "pending", id };
423
+ for (const delay of POLL_DELAYS_MS) {
424
+ await sleep(delay);
425
+ try {
426
+ const path = `/bookings/${encodeURIComponent(id)}`;
427
+ outcome = outcomeOf(await salesRequest(config, "GET", path));
428
+ } catch {
429
+ continue;
430
+ }
431
+ if (outcome.status !== "pending") return outcome;
432
+ }
433
+ return outcome;
434
+ }
435
+ async function rejected(error, input, attemptKey, runtime) {
436
+ if (!(error instanceof AranovaApiError)) {
437
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
438
+ }
439
+ if (error.code === BOOKING_ERROR_CODES.stale) {
440
+ if (staleAttempts.has(attemptKey)) {
441
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
442
+ }
443
+ staleAttempts.add(attemptKey);
444
+ await runtime?.revalidate();
445
+ return { status: "stale", message: error.message };
446
+ }
447
+ if (error.code === BOOKING_ERROR_CODES.rejected) {
448
+ const reasons = error.context?.reasons;
449
+ return {
450
+ status: "invalid",
451
+ message: error.message,
452
+ reasons: Array.isArray(reasons) ? reasons.map(String) : []
453
+ };
454
+ }
455
+ if (error.code === "validation_error") {
456
+ return { status: "invalid", message: INVALID_DETAILS, reasons: [] };
457
+ }
458
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
459
+ }
460
+ return {
461
+ async submit(input, { attemptKey, formSubmit }) {
462
+ registerCapability("booking_integration");
463
+ const runtime = runtimeOf(firing);
464
+ const booking = getBookingIntegration(runtime?.integrations());
465
+ if (!booking?.acceptsBookings || booking.configVersion === null) {
466
+ return { status: "failed", id: null, fallbackUrl: handoff(input) };
467
+ }
468
+ if (formSubmit && !submittedForms.has(attemptKey)) {
469
+ submittedForms.add(attemptKey);
470
+ const labels = new Map(booking.options.map((option) => [option.key, option.label]));
471
+ formSubmit.tracking.trackEvent(
472
+ "form_submit",
473
+ formSubmitPayload(input, formSubmit.formId, labels)
474
+ );
475
+ }
476
+ const body = {
477
+ idempotency_key: attemptKey,
478
+ // The tracking client's own visit, so the booking and its form_submit share one session.
479
+ session_id: formSubmit?.tracking.getSessionId() ?? peekSessionId() ?? `no-session-${attemptKey}`,
480
+ config_version: booking.configVersion,
481
+ start_date: input.startDate,
482
+ start_hour: input.startHour,
483
+ first_name: input.firstName.trim(),
484
+ last_name: input.lastName.trim(),
485
+ phone: input.phone,
486
+ email: input.email?.trim() || null,
487
+ vehicle: input.vehicle ?? null,
488
+ drop_off: input.dropOff ?? null,
489
+ services: input.services,
490
+ notes: input.notes?.trim() || null,
491
+ marketing_sms_consent: input.marketingSmsConsent === true,
492
+ page_path: typeof window === "undefined" ? null : window.location.pathname
493
+ };
494
+ let response;
495
+ try {
496
+ response = await salesRequest(config, "POST", "/bookings", body);
497
+ } catch (error) {
498
+ return rejected(error, input, attemptKey, runtime);
499
+ }
500
+ const outcome = outcomeOf(response);
501
+ return withHandoff(outcome.status === "pending" ? await poll(outcome.id) : outcome, input);
502
+ },
503
+ async status(id) {
504
+ return outcomeOf(
505
+ await salesRequest(
506
+ config,
507
+ "GET",
508
+ `/bookings/${encodeURIComponent(id)}`
509
+ )
510
+ );
511
+ }
512
+ };
513
+ }
514
+
236
515
  // ../tracking-core/src/resources/intl/date.ts
237
516
  function formatDateInTz(iso3, timeZone, opts, locale) {
238
517
  const date = new Date(iso3);
@@ -442,7 +721,8 @@ function createSalesClient(config) {
442
721
  async config() {
443
722
  return salesRequest(config, "GET", "/business/config");
444
723
  }
445
- }
724
+ },
725
+ bookings: createSalesBookingsClient(config, config.firing)
446
726
  };
447
727
  }
448
728
 
@@ -451,6 +731,12 @@ import * as z2 from "zod/mini";
451
731
 
452
732
  // ../tracking-core/src/resources/sales/generated/zod.gen.ts
453
733
  import * as z from "zod/mini";
734
+ var zAutoLeapSaleFacts = z.object({
735
+ first_order: z.boolean(),
736
+ fleet_customer: z.boolean(),
737
+ invoice_code: z.string(),
738
+ invoice_id: z.string()
739
+ });
454
740
  var zErrorCode = z.enum([
455
741
  "missing_credentials",
456
742
  "invalid_api_key",
@@ -478,6 +764,9 @@ var zErrorCode = z.enum([
478
764
  "booking_not_reschedulable",
479
765
  "manage_token_invalid",
480
766
  "google_error",
767
+ "booking_unavailable",
768
+ "booking_config_stale",
769
+ "booking_rejected",
481
770
  "calendar_not_owned",
482
771
  "calendar_is_sandbox",
483
772
  "sandbox_exists",
@@ -519,6 +808,7 @@ var zSaleServiceInputSchema = z.strictObject({
519
808
  amount_cents: z.int().check(z.gte(0)),
520
809
  service: z.string()
521
810
  });
811
+ var zSaleSource = z.enum(["api", "admin", "autoleap"]);
522
812
  var zSmsAutomationState = z.enum([
523
813
  "needs_check",
524
814
  "failed",
@@ -550,7 +840,8 @@ var zSmsWithheldReason = z.enum([
550
840
  "number_not_active",
551
841
  "a2p_registration_required",
552
842
  "alert_recipient_cannot_receive_sms",
553
- "line_type_unresolved"
843
+ "line_type_unresolved",
844
+ "sale_source_sends_own_messages"
554
845
  ]);
555
846
  var zSaleSmsStatusSchema = z.object({
556
847
  can_send_now: z._default(z.boolean(), false),
@@ -659,6 +950,8 @@ var zSaleEntitySchema = z.object({
659
950
  sms_automation: z.nullable(zSaleSmsStatusSchema),
660
951
  sms_consent: z._default(z.boolean(), false),
661
952
  sms_consent_granted_at: z.nullable(z.iso.datetime({ offset: true })),
953
+ source: zSaleSource,
954
+ source_facts: z.nullable(zAutoLeapSaleFacts),
662
955
  updated_at: z.iso.datetime({ offset: true }),
663
956
  writeback: z.nullable(zSaleWritebackStatusSchema)
664
957
  });
@@ -668,6 +961,7 @@ var saleItemSchema = zSaleItemInputSchema;
668
961
  var saleServiceSchema = zSaleServiceInputSchema;
669
962
  var currencySchema = zSupportedCurrency;
670
963
  var SUPPORTED_CURRENCIES = currencySchema.options;
964
+ var SALE_SOURCES = zSaleSource.options;
671
965
  var centsSchema = z2.int().check(z2.gte(0));
672
966
  var metadataSchema = z2.record(z2.string(), z2.unknown());
673
967
  var customerNameSchema = z2.string().check(z2.maxLength(200));
@@ -747,6 +1041,7 @@ async function fetchServices(config) {
747
1041
  export {
748
1042
  AranovaApiError,
749
1043
  NAMED_RANGES,
1044
+ SALE_SOURCES,
750
1045
  SUPPORTED_CURRENCIES,
751
1046
  TRACKING_RANGES,
752
1047
  createSalesClient,
@@ -755,6 +1050,7 @@ export {
755
1050
  formatDateInTz,
756
1051
  formatMoney,
757
1052
  fromMinor,
1053
+ newBookingAttemptKey,
758
1054
  saleCreateSchema,
759
1055
  saleItemSchema,
760
1056
  saleServiceSchema,