@bacano/sdk 1.1.20 → 1.2.1

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/react/forms.js CHANGED
@@ -1,13 +1,27 @@
1
1
  // libs/sdk/src/react/forms.tsx
2
2
  import {
3
3
  useCallback as useCallback2,
4
- useEffect as useEffect2,
4
+ useEffect as useEffect3,
5
5
  useId,
6
- useMemo,
7
- useRef as useRef2,
6
+ useMemo as useMemo2,
7
+ useRef as useRef3,
8
8
  useState as useState2
9
9
  } from "react";
10
10
 
11
+ // libs/sdk/src/errors.ts
12
+ var WebsiteError = class extends Error {
13
+ code;
14
+ status;
15
+ retryAfterSeconds;
16
+ constructor(message, code = "WEBSITE_ERROR", options) {
17
+ super(message);
18
+ this.name = "WebsiteError";
19
+ this.code = code;
20
+ this.status = options?.status ?? null;
21
+ this.retryAfterSeconds = options?.retryAfterSeconds ?? null;
22
+ }
23
+ };
24
+
11
25
  // libs/sdk/src/react/form-field-control.tsx
12
26
  import { jsx, jsxs } from "react/jsx-runtime";
13
27
  function BacanoFormFieldControl(props) {
@@ -96,8 +110,110 @@ function BacanoFormFieldControl(props) {
96
110
  );
97
111
  }
98
112
 
99
- // libs/sdk/src/react/hooks.ts
100
- import { useCallback, useContext, useEffect, useRef, useState } from "react";
113
+ // libs/sdk/src/react/form-sections.tsx
114
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
115
+ function FormFields(props) {
116
+ return props.fields.map((field) => {
117
+ const controlId = `${props.idPrefix}-${field.key}`;
118
+ const value = props.values[field.key] ?? (field.type === "checkbox" ? false : field.type === "multi_select" ? [] : "");
119
+ return /* @__PURE__ */ jsxs2("div", { className: props.classNames?.field, children: [
120
+ /* @__PURE__ */ jsx2(
121
+ "label",
122
+ {
123
+ id: `${controlId}-label`,
124
+ htmlFor: field.type === "radio" ? void 0 : controlId,
125
+ className: props.classNames?.label,
126
+ children: field.label
127
+ }
128
+ ),
129
+ /* @__PURE__ */ jsx2(
130
+ BacanoFormFieldControl,
131
+ {
132
+ field,
133
+ id: controlId,
134
+ value,
135
+ className: props.classNames?.input,
136
+ onChange: (nextValue) => props.onChange(field.key, nextValue)
137
+ }
138
+ ),
139
+ field.helpText ? /* @__PURE__ */ jsx2("p", { className: props.classNames?.helpText, children: field.helpText }) : null
140
+ ] }, field.key);
141
+ });
142
+ }
143
+ function FormSections(props) {
144
+ return props.sections.map((section) => /* @__PURE__ */ jsxs2("section", { className: props.classNames?.section, children: [
145
+ /* @__PURE__ */ jsx2("h3", { className: props.classNames?.sectionTitle, children: section.title }),
146
+ section.description ? /* @__PURE__ */ jsx2("p", { className: props.classNames?.sectionDescription, children: section.description }) : null,
147
+ /* @__PURE__ */ jsx2(
148
+ FormFields,
149
+ {
150
+ fields: section.fields,
151
+ idPrefix: props.idPrefix,
152
+ values: props.values,
153
+ classNames: props.classNames,
154
+ onChange: props.onChange
155
+ }
156
+ )
157
+ ] }, section.key));
158
+ }
159
+
160
+ // libs/sdk/src/react/form-tracking.ts
161
+ import { useContext, useEffect, useMemo, useRef } from "react";
162
+
163
+ // libs/sdk/src/marketing/interactions.ts
164
+ function newMarketingEventId() {
165
+ return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`;
166
+ }
167
+
168
+ // libs/sdk/src/marketing/form-interactions.ts
169
+ function createFormInteractionTracker(getRuntime, formKey) {
170
+ let visible = false;
171
+ let started = false;
172
+ let attempt = null;
173
+ const completedSteps = /* @__PURE__ */ new Set();
174
+ function emit(eventName, context = {}) {
175
+ try {
176
+ getRuntime().track({ eventName, formKey, ...context });
177
+ } catch {
178
+ }
179
+ }
180
+ return {
181
+ setVisible(next) {
182
+ if (next && !visible && getRuntime().enabled) emit("form_open");
183
+ visible = next;
184
+ },
185
+ start() {
186
+ if (started || !getRuntime().enabled) return;
187
+ started = true;
188
+ emit("form_start");
189
+ },
190
+ stepComplete(stepKey) {
191
+ if (completedSteps.has(stepKey) || !getRuntime().enabled) return;
192
+ completedSteps.add(stepKey);
193
+ emit("form_step_complete", { stepKey });
194
+ },
195
+ beginSubmit() {
196
+ if (attempt) return false;
197
+ attempt = newMarketingEventId();
198
+ emit("form_submit_attempt", { eventId: attempt });
199
+ return true;
200
+ },
201
+ fail(errorCategory) {
202
+ if (!attempt) return;
203
+ emit("form_submit_error", { eventId: attempt, errorCategory });
204
+ attempt = null;
205
+ },
206
+ complete(result) {
207
+ if (!attempt) return;
208
+ if (result?.accepted && typeof result.responseId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
209
+ result.responseId
210
+ )) {
211
+ emit("form_submit_success", { eventId: result.responseId });
212
+ }
213
+ attempt = null;
214
+ }
215
+ };
216
+ }
101
217
 
102
218
  // libs/sdk/src/react/context-registry.ts
103
219
  import { createContext } from "react";
@@ -125,6 +241,37 @@ function getOrCreateReactContext(key, defaultValue) {
125
241
  return context;
126
242
  }
127
243
 
244
+ // libs/sdk/src/react/marketing-context.ts
245
+ var NOOP_MARKETING_RUNTIME = {
246
+ enabled: false,
247
+ track: () => void 0,
248
+ captureAttribution: () => null,
249
+ getAttribution: () => null,
250
+ destroy: () => void 0
251
+ };
252
+ var BacanoMarketingContext = getOrCreateReactContext(
253
+ "bacano-marketing-v1",
254
+ NOOP_MARKETING_RUNTIME
255
+ );
256
+
257
+ // libs/sdk/src/react/form-tracking.ts
258
+ function useBacanoFormTracking(formKey, options = {}) {
259
+ const runtime = useContext(BacanoMarketingContext);
260
+ const current = useRef(runtime);
261
+ current.current = runtime;
262
+ const tracker = useMemo(
263
+ () => createFormInteractionTracker(() => current.current, formKey),
264
+ [formKey]
265
+ );
266
+ useEffect(() => {
267
+ tracker.setVisible((options.visible ?? true) && runtime.enabled);
268
+ }, [tracker, options.visible, runtime.enabled]);
269
+ return tracker;
270
+ }
271
+
272
+ // libs/sdk/src/react/hooks.ts
273
+ import { useCallback, useContext as useContext2, useEffect as useEffect2, useRef as useRef2, useState } from "react";
274
+
128
275
  // libs/sdk/src/react/context.ts
129
276
  var BacanoReactContext = getOrCreateReactContext("bacano-client-v1", {
130
277
  client: null,
@@ -134,11 +281,11 @@ var BacanoReactContext = getOrCreateReactContext("bacano-client-v1", {
134
281
 
135
282
  // libs/sdk/src/react/hooks.ts
136
283
  function useBacanoState() {
137
- return useContext(BacanoReactContext);
284
+ return useContext2(BacanoReactContext);
138
285
  }
139
286
 
140
287
  // libs/sdk/src/react/forms.tsx
141
- import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
288
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
142
289
  var EMPTY_FORM_FIELDS = [];
143
290
  var EMPTY_FORM_SECTIONS = [];
144
291
  var EMPTY_FORM_STEPS = [];
@@ -151,30 +298,34 @@ function useBacanoForm(formKey, options) {
151
298
  const enabled = options?.enabled ?? true;
152
299
  const initialForm = options?.initialForm?.key === formKey ? options.initialForm : null;
153
300
  const [form, setForm] = useState2(initialForm);
154
- const formRef = useRef2(initialForm);
301
+ const formRef = useRef3(initialForm);
302
+ const requestVersion = useRef3(0);
155
303
  const [loading, setLoading] = useState2(enabled && !initialForm);
156
304
  const [error, setError] = useState2(null);
157
305
  const load = useCallback2(
158
306
  async (force = false) => {
159
307
  if (!enabled || !client) return;
308
+ const version = ++requestVersion.current;
160
309
  setLoading(formRef.current === null);
161
310
  setError(null);
162
311
  try {
163
312
  const nextForm = await client.forms.getDefinition(formKey, { force });
313
+ if (version !== requestVersion.current) return;
164
314
  formRef.current = nextForm;
165
315
  setForm(nextForm);
166
316
  } catch (loadError) {
317
+ if (version !== requestVersion.current) return;
167
318
  if (!formRef.current) setForm(null);
168
319
  setError(
169
320
  loadError instanceof Error ? loadError : new Error(String(loadError))
170
321
  );
171
322
  } finally {
172
- setLoading(false);
323
+ if (version === requestVersion.current) setLoading(false);
173
324
  }
174
325
  },
175
326
  [client, enabled, formKey]
176
327
  );
177
- useEffect2(() => {
328
+ useEffect3(() => {
178
329
  if (!enabled) {
179
330
  formRef.current = null;
180
331
  setForm(null);
@@ -183,59 +334,18 @@ function useBacanoForm(formKey, options) {
183
334
  return;
184
335
  }
185
336
  if (client) void load();
337
+ return () => {
338
+ requestVersion.current += 1;
339
+ };
186
340
  }, [client, enabled, load]);
187
341
  return {
188
- form,
189
- loading: enabled && form === null && (clientLoading || loading),
342
+ form: enabled ? form?.key === formKey ? form : initialForm : null,
343
+ loading: enabled && form?.key !== formKey && !initialForm && (clientLoading || loading),
190
344
  error: clientError ?? error,
191
345
  refresh: async () => load(true)
192
346
  };
193
347
  }
194
- function FormFields(props) {
195
- return props.fields.map((field) => {
196
- const controlId = `${props.idPrefix}-${field.key}`;
197
- const value = props.values[field.key] ?? (field.type === "checkbox" ? false : field.type === "multi_select" ? [] : "");
198
- return /* @__PURE__ */ jsxs2("div", { className: props.classNames?.field, children: [
199
- /* @__PURE__ */ jsx2(
200
- "label",
201
- {
202
- id: `${controlId}-label`,
203
- htmlFor: field.type === "radio" ? void 0 : controlId,
204
- className: props.classNames?.label,
205
- children: field.label
206
- }
207
- ),
208
- /* @__PURE__ */ jsx2(
209
- BacanoFormFieldControl,
210
- {
211
- field,
212
- id: controlId,
213
- value,
214
- className: props.classNames?.input,
215
- onChange: (nextValue) => props.onChange(field.key, nextValue)
216
- }
217
- ),
218
- field.helpText ? /* @__PURE__ */ jsx2("p", { className: props.classNames?.helpText, children: field.helpText }) : null
219
- ] }, field.key);
220
- });
221
- }
222
- function FormSections(props) {
223
- return props.sections.map((section) => /* @__PURE__ */ jsxs2("section", { className: props.classNames?.section, children: [
224
- /* @__PURE__ */ jsx2("h3", { className: props.classNames?.sectionTitle, children: section.title }),
225
- section.description ? /* @__PURE__ */ jsx2("p", { className: props.classNames?.sectionDescription, children: section.description }) : null,
226
- /* @__PURE__ */ jsx2(
227
- FormFields,
228
- {
229
- fields: section.fields,
230
- idPrefix: props.idPrefix,
231
- values: props.values,
232
- classNames: props.classNames,
233
- onChange: props.onChange
234
- }
235
- )
236
- ] }, section.key));
237
- }
238
- function BacanoForm(props) {
348
+ function BacanoFormInstance(props) {
239
349
  const reactId = useId();
240
350
  const idPrefix = `${props.formKey}-${reactId.replace(/:/g, "")}`;
241
351
  const { client } = useBacanoState();
@@ -248,11 +358,14 @@ function BacanoForm(props) {
248
358
  const [submitted, setSubmitted] = useState2(false);
249
359
  const [submissionError, setSubmissionError] = useState2(null);
250
360
  const [honeypot, setHoneypot] = useState2("");
251
- const idempotencyKey = useRef2(crypto.randomUUID());
361
+ const tracking = useBacanoFormTracking(props.formKey, {
362
+ visible: (props.visible ?? true) && !loading && form?.status === "ACTIVE" && !submitted
363
+ });
364
+ const idempotencyKey = useRef3(crypto.randomUUID());
252
365
  const fields = form?.definition.fields ?? EMPTY_FORM_FIELDS;
253
366
  const sections = form?.definition.sections ?? EMPTY_FORM_SECTIONS;
254
367
  const steps = form?.definition.steps ?? EMPTY_FORM_STEPS;
255
- const fieldKeys = useMemo(
368
+ const fieldKeys = useMemo2(
256
369
  () => new Set(
257
370
  [
258
371
  ...fields,
@@ -270,7 +383,7 @@ function BacanoForm(props) {
270
383
  ([key]) => fieldKeys.has(key)
271
384
  )
272
385
  );
273
- useEffect2(() => {
386
+ useEffect3(() => {
274
387
  setStep(0);
275
388
  setValues(
276
389
  (current) => Object.fromEntries(
@@ -282,27 +395,37 @@ function BacanoForm(props) {
282
395
  setHoneypot("");
283
396
  idempotencyKey.current = crypto.randomUUID();
284
397
  }, [fieldKeys, form?.version]);
285
- if (loading) return /* @__PURE__ */ jsx2(Fragment, { children: props.loadingFallback ?? null });
398
+ if (loading) return /* @__PURE__ */ jsx3(Fragment, { children: props.loadingFallback ?? null });
286
399
  if (error && !form)
287
- return /* @__PURE__ */ jsx2(Fragment, { children: props.errorFallback?.(error) ?? props.unavailableFallback ?? null });
288
- if (!form) return /* @__PURE__ */ jsx2(Fragment, { children: props.unavailableFallback ?? null });
400
+ return /* @__PURE__ */ jsx3(Fragment, { children: props.errorFallback?.(error) ?? props.unavailableFallback ?? null });
401
+ if (!form) return /* @__PURE__ */ jsx3(Fragment, { children: props.unavailableFallback ?? null });
289
402
  if (form.status === "PAUSED") {
290
- return /* @__PURE__ */ jsx2("div", { className: props.classNames?.message, "data-bacano-form-unavailable": true, children: props.unavailableFallback ?? form.definition.unavailableMessage });
403
+ return /* @__PURE__ */ jsx3("div", { className: props.classNames?.message, "data-bacano-form-unavailable": true, children: props.unavailableFallback ?? form.definition.unavailableMessage });
291
404
  }
292
405
  if (submitted) {
293
- return /* @__PURE__ */ jsx2("div", { className: props.classNames?.message, "data-bacano-form-success": true, children: form.definition.successMessage });
406
+ return /* @__PURE__ */ jsx3("div", { className: props.classNames?.message, "data-bacano-form-success": true, children: form.definition.successMessage });
294
407
  }
295
408
  const isMultiStep = steps.length > 0;
296
409
  const activeStep = isMultiStep ? steps[step] : null;
297
410
  const isLastStep = !isMultiStep || step === steps.length - 1;
298
411
  const setFieldValue = (key, value) => {
412
+ if (value !== "" && value !== false && (!Array.isArray(value) || value.length > 0))
413
+ tracking.start();
299
414
  const nextValues = { ...values, [key]: value };
300
415
  setValues(nextValues);
301
416
  props.onValuesChange?.(nextValues);
302
417
  };
303
418
  const submit = async (event) => {
304
419
  event.preventDefault();
420
+ if (submitting || submitted) return;
421
+ if (isLastStep && !tracking.beginSubmit()) return;
422
+ if (!event.currentTarget.checkValidity()) {
423
+ if (isLastStep) tracking.fail("validation");
424
+ event.currentTarget.reportValidity();
425
+ return;
426
+ }
305
427
  if (!isLastStep) {
428
+ if (activeStep) tracking.stepComplete(activeStep.key);
306
429
  setStep((current) => current + 1);
307
430
  return;
308
431
  }
@@ -310,8 +433,12 @@ function BacanoForm(props) {
310
433
  setSubmissionError(null);
311
434
  try {
312
435
  let responseId = null;
436
+ let officialResult;
313
437
  if (props.onSubmit) {
314
- await props.onSubmit(values, form);
438
+ officialResult = await props.onSubmit(values, form);
439
+ if (officialResult && !officialResult.accepted)
440
+ throw new Error(officialResult.message);
441
+ responseId = officialResult?.responseId ?? null;
315
442
  } else {
316
443
  if (!client) throw new Error("Bacano client is not ready");
317
444
  const result = await client.forms.submitResponse({
@@ -323,11 +450,16 @@ function BacanoForm(props) {
323
450
  honeypot
324
451
  });
325
452
  if (!result.accepted) throw new Error(result.message);
453
+ officialResult = result;
326
454
  responseId = result.responseId;
327
455
  }
456
+ tracking.complete(officialResult);
328
457
  setSubmitted(true);
329
458
  props.onSubmitted?.(responseId);
330
459
  } catch (submitError) {
460
+ tracking.fail(
461
+ submitError instanceof WebsiteError && submitError.code === "NETWORK_ERROR" ? "network" : "rejected"
462
+ );
331
463
  setSubmissionError(
332
464
  submitError instanceof Error ? submitError : new Error(String(submitError))
333
465
  );
@@ -335,15 +467,16 @@ function BacanoForm(props) {
335
467
  setSubmitting(false);
336
468
  }
337
469
  };
338
- return /* @__PURE__ */ jsxs2(
470
+ return /* @__PURE__ */ jsxs3(
339
471
  "form",
340
472
  {
473
+ noValidate: true,
341
474
  className: props.classNames?.root,
342
475
  "data-bacano-form": form.key,
343
476
  "data-bacano-form-version": form.version,
344
477
  onSubmit: (event) => void submit(event),
345
478
  children: [
346
- /* @__PURE__ */ jsxs2(
479
+ /* @__PURE__ */ jsxs3(
347
480
  "div",
348
481
  {
349
482
  "aria-hidden": "true",
@@ -355,8 +488,8 @@ function BacanoForm(props) {
355
488
  clip: "rect(0 0 0 0)"
356
489
  },
357
490
  children: [
358
- /* @__PURE__ */ jsx2("label", { htmlFor: `${form.key}-company-website`, children: "Empresa" }),
359
- /* @__PURE__ */ jsx2(
491
+ /* @__PURE__ */ jsx3("label", { htmlFor: `${form.key}-company-website`, children: "Empresa" }),
492
+ /* @__PURE__ */ jsx3(
360
493
  "input",
361
494
  {
362
495
  id: `${form.key}-company-website`,
@@ -370,12 +503,12 @@ function BacanoForm(props) {
370
503
  ]
371
504
  }
372
505
  ),
373
- /* @__PURE__ */ jsx2("h2", { className: props.classNames?.title, children: form.name }),
374
- form.description ? /* @__PURE__ */ jsx2("p", { className: props.classNames?.description, children: form.description }) : null,
375
- activeStep ? /* @__PURE__ */ jsxs2("section", { className: props.classNames?.step, "data-bacano-form-step": true, children: [
376
- /* @__PURE__ */ jsx2("h3", { className: props.classNames?.stepTitle, children: activeStep.title }),
377
- activeStep.description ? /* @__PURE__ */ jsx2("p", { className: props.classNames?.stepDescription, children: activeStep.description }) : null,
378
- /* @__PURE__ */ jsx2(
506
+ /* @__PURE__ */ jsx3("h2", { className: props.classNames?.title, children: form.name }),
507
+ form.description ? /* @__PURE__ */ jsx3("p", { className: props.classNames?.description, children: form.description }) : null,
508
+ activeStep ? /* @__PURE__ */ jsxs3("section", { className: props.classNames?.step, "data-bacano-form-step": true, children: [
509
+ /* @__PURE__ */ jsx3("h3", { className: props.classNames?.stepTitle, children: activeStep.title }),
510
+ activeStep.description ? /* @__PURE__ */ jsx3("p", { className: props.classNames?.stepDescription, children: activeStep.description }) : null,
511
+ /* @__PURE__ */ jsx3(
379
512
  FormFields,
380
513
  {
381
514
  fields: activeStep.fields,
@@ -385,7 +518,7 @@ function BacanoForm(props) {
385
518
  onChange: setFieldValue
386
519
  }
387
520
  ),
388
- /* @__PURE__ */ jsx2(
521
+ /* @__PURE__ */ jsx3(
389
522
  FormSections,
390
523
  {
391
524
  sections: activeStep.sections,
@@ -395,8 +528,8 @@ function BacanoForm(props) {
395
528
  onChange: setFieldValue
396
529
  }
397
530
  )
398
- ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
399
- /* @__PURE__ */ jsx2(
531
+ ] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
532
+ /* @__PURE__ */ jsx3(
400
533
  FormFields,
401
534
  {
402
535
  fields,
@@ -406,7 +539,7 @@ function BacanoForm(props) {
406
539
  onChange: setFieldValue
407
540
  }
408
541
  ),
409
- /* @__PURE__ */ jsx2(
542
+ /* @__PURE__ */ jsx3(
410
543
  FormSections,
411
544
  {
412
545
  sections,
@@ -417,8 +550,8 @@ function BacanoForm(props) {
417
550
  }
418
551
  )
419
552
  ] }),
420
- /* @__PURE__ */ jsxs2("div", { className: props.classNames?.actions, children: [
421
- isMultiStep && step > 0 ? /* @__PURE__ */ jsx2(
553
+ /* @__PURE__ */ jsxs3("div", { className: props.classNames?.actions, children: [
554
+ isMultiStep && step > 0 ? /* @__PURE__ */ jsx3(
422
555
  "button",
423
556
  {
424
557
  type: "button",
@@ -426,7 +559,7 @@ function BacanoForm(props) {
426
559
  children: "Anterior"
427
560
  }
428
561
  ) : null,
429
- /* @__PURE__ */ jsx2(
562
+ /* @__PURE__ */ jsx3(
430
563
  "button",
431
564
  {
432
565
  type: "submit",
@@ -436,7 +569,7 @@ function BacanoForm(props) {
436
569
  }
437
570
  )
438
571
  ] }),
439
- submissionError ? /* @__PURE__ */ jsx2(
572
+ submissionError ? /* @__PURE__ */ jsx3(
440
573
  "div",
441
574
  {
442
575
  role: "alert",
@@ -449,7 +582,11 @@ function BacanoForm(props) {
449
582
  }
450
583
  );
451
584
  }
585
+ function BacanoForm(props) {
586
+ return /* @__PURE__ */ jsx3(BacanoFormInstance, { ...props }, props.formKey);
587
+ }
452
588
  export {
453
589
  BacanoForm,
454
- useBacanoForm
590
+ useBacanoForm,
591
+ useBacanoFormTracking
455
592
  };
@@ -6,5 +6,6 @@ export interface BacanoMarketingProviderProps {
6
6
  children: ReactNode;
7
7
  scriptLoadStrategy?: WebsiteMarketingScriptLoadStrategy;
8
8
  diagnostic?: (diagnostic: WebsiteMarketingDiagnostic) => void;
9
+ measurementAllowed?: boolean;
9
10
  }
10
- export declare function BacanoMarketingProvider({ publicConfig, children, scriptLoadStrategy, diagnostic, }: BacanoMarketingProviderProps): JSX.Element;
11
+ export declare function BacanoMarketingProvider({ publicConfig, children, scriptLoadStrategy, diagnostic, measurementAllowed, }: BacanoMarketingProviderProps): JSX.Element;