@leadping/consent 1.0.4 → 1.0.5

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.
@@ -5,9 +5,14 @@ export declare class ConsentCapture {
5
5
  private static active?;
6
6
  private readonly browser;
7
7
  private readonly buffer;
8
+ private readonly deadline;
8
9
  private transport?;
9
10
  /** Resolves when the service session is available and validated. Recording starts before this. */
10
11
  readonly ready: Promise<void>;
12
+ /** Allocated as soon as the session is ready; this does not assert that submission is saved. */
13
+ get sessionId(): string | undefined;
14
+ /** The server-selected duration limit, available after ready resolves. */
15
+ get maxReplaySeconds(): number | undefined;
11
16
  private session?;
12
17
  private readonly observedDisclosure;
13
18
  private disposed;
@@ -1,6 +1,8 @@
1
1
  import { record } from 'rrweb';
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { CaptureTransport } from './transport.js';
4
+ import { ReplayDeadline } from './replay-deadline.js';
5
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
4
6
  /** One form recording per document, matching rrweb's document-level recorder. */
5
7
  export class ConsentCapture {
6
8
  options;
@@ -18,9 +20,14 @@ export class ConsentCapture {
18
20
  maxTouchPoints: navigator.maxTouchPoints,
19
21
  };
20
22
  buffer = new EventBuffer();
23
+ deadline = new ReplayDeadline();
21
24
  transport;
22
25
  /** Resolves when the service session is available and validated. Recording starts before this. */
23
26
  ready;
27
+ /** Allocated as soon as the session is ready; this does not assert that submission is saved. */
28
+ get sessionId() { return this.session?.sessionId; }
29
+ /** The server-selected duration limit, available after ready resolves. */
30
+ get maxReplaySeconds() { return this.session?.maxReplaySeconds; }
24
31
  session;
25
32
  observedDisclosure;
26
33
  disposed = false;
@@ -52,13 +59,11 @@ export class ConsentCapture {
52
59
  if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin ||
53
60
  session.form.disclosure !== this.observedDisclosure)
54
61
  throw new Error('Form does not match the approved disclosure.');
55
- const remaining = Date.parse(session.expiresAt) - Date.now();
56
- if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1000)
57
- throw new Error('Invalid or expired capture session.');
62
+ this.deadline.configure(session.expiresAt, session.maxReplaySeconds);
58
63
  this.session = session;
59
64
  this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + session.sessionId, session.uploadToken);
60
65
  if (!this.stopped)
61
- this.expiry = setTimeout(() => this.fail(new Error('Capture session expired.')), remaining);
66
+ this.expiry = setTimeout(() => this.fail(new ReplayLimitExceededError()), this.deadline.remainingMilliseconds);
62
67
  });
63
68
  void this.ready.then(this.backgroundFlush).catch(error => this.fail(error));
64
69
  ConsentCapture.active = this;
@@ -115,14 +120,24 @@ export class ConsentCapture {
115
120
  async finishCore(recipient) {
116
121
  if (this.stopped || this.failure)
117
122
  throw this.failure ?? new Error('Recorder is stopped.');
123
+ try {
124
+ this.deadline.assertWithinLimit();
125
+ }
126
+ catch (error) {
127
+ this.fail(error);
128
+ throw error;
129
+ }
118
130
  const observedDisclosure = this.options.disclosure.textContent ?? '';
119
131
  if (observedDisclosure !== this.observedDisclosure)
120
132
  throw new Error('The disclosure changed during capture. Start a new approved form session.');
121
133
  const accepted = this.options.checkbox.checked;
122
134
  record.addCustomEvent('leadping.submit', { accepted });
123
135
  this.stop();
136
+ this.options.onProgress?.('uploading');
124
137
  await this.flush();
125
- await this.transport.put('/submission', JSON.stringify({
138
+ this.deadline.assertWithinLimit();
139
+ this.options.onProgress?.('submitting');
140
+ await this.transport.put(this.options.issueCertificate ? '/submission/certificate' : '/submission', JSON.stringify({
126
141
  lastBatchNumber: this.buffer.lastBatchNumber, lastEventNumber: this.buffer.lastEventNumber,
127
142
  accepted, observedDisclosure, recipient, browser: this.browser,
128
143
  }));
@@ -147,7 +162,7 @@ export class ConsentCapture {
147
162
  const batch = this.buffer.seal();
148
163
  if (!batch)
149
164
  return;
150
- // UTF-16 string budget including queued/retrying batches. One in-flight compressed copy is additional.
165
+ // UTF-16 string budget including queued/retrying batches. Up to three compressed copies are additional.
151
166
  if (this.queuedBytes + batch.body.length * 2 > 2 * 1024 * 1024)
152
167
  throw new Error('Recording upload backlog exceeded 2 MiB.');
153
168
  this.pending.push(batch);
@@ -157,19 +172,25 @@ export class ConsentCapture {
157
172
  // Defer so this.sending is assigned before this function can finish.
158
173
  await Promise.resolve();
159
174
  try {
160
- while (this.head < this.pending.length) {
161
- const batch = this.pending[this.head];
162
- await this.transport.put('/batches/' + batch.number, batch.body);
163
- this.queuedBytes -= batch.body.length * 2;
164
- this.pending[this.head] = undefined;
165
- this.head++;
166
- if (this.head === this.pending.length) {
167
- this.pending.length = 0;
168
- this.head = 0;
175
+ // Batches are immutable and individually numbered. Completion order need not match
176
+ // recording order; submission still waits for every durable acknowledgment.
177
+ await Promise.all(Array.from({ length: 3 }, async () => {
178
+ while (!this.failure && !this.disposed && this.head < this.pending.length) {
179
+ const index = this.head++;
180
+ const batch = this.pending[index];
181
+ await this.transport.put('/batches/' + batch.number, batch.body);
182
+ this.queuedBytes -= batch.body.length * 2;
183
+ this.pending[index] = undefined;
169
184
  }
185
+ }));
186
+ // A concurrent flush may append after the last worker exits but before this continuation.
187
+ if (this.head === this.pending.length) {
188
+ this.pending.length = 0;
189
+ this.head = 0;
170
190
  }
171
191
  }
172
192
  catch (error) {
193
+ this.transport?.dispose();
173
194
  this.fail(error instanceof Error ? error : new Error('Capture upload failed.'));
174
195
  throw this.failure;
175
196
  }
@@ -177,7 +198,12 @@ export class ConsentCapture {
177
198
  this.sending = undefined;
178
199
  }
179
200
  }
180
- consentChanged = () => record.addCustomEvent('leadping.consent', { accepted: this.options.checkbox.checked });
201
+ consentChanged = () => {
202
+ record.addCustomEvent('leadping.consent', { accepted: this.options.checkbox.checked });
203
+ // Start draining while the visitor moves from consenting to submitting.
204
+ if (this.options.checkbox.checked)
205
+ this.backgroundFlush();
206
+ };
181
207
  visibilityChanged = () => { if (document.visibilityState === 'hidden')
182
208
  this.backgroundFlush(); };
183
209
  backgroundFlush = () => { void this.flush().catch(error => this.fail(error)); };
@@ -1,3 +1,4 @@
1
1
  export { ConsentCapture } from './capture.js';
2
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
3
  /** Attach the Leadping-hosted capture and submission flow to a form. */
3
4
  export declare function attach(script: HTMLScriptElement): Promise<void>;
package/dist/esm/embed.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { CaptureRejectedError } from './capture-rejected-error.js';
2
2
  import { ConsentCapture } from './capture.js';
3
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
3
4
  export { ConsentCapture } from './capture.js';
5
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
4
6
  /** Attach the Leadping-hosted capture and submission flow to a form. */
5
7
  export async function attach(script) {
6
8
  const domainId = script.dataset.domainId;
@@ -20,7 +22,8 @@ export async function attach(script) {
20
22
  let capture;
21
23
  let submitted = false;
22
24
  const fail = (error) => {
23
- status.textContent = 'Unable to complete consent capture. Please reload to start again.';
25
+ status.textContent = error instanceof ReplayLimitExceededError ? error.message
26
+ : 'Unable to complete consent capture. Please reload to start again.';
24
27
  form.dispatchEvent(new CustomEvent('leadping:error', { detail: error }));
25
28
  };
26
29
  const origin = new URL(script.dataset.apiUrl ?? 'https://consent.leadping.ai');
@@ -31,6 +34,9 @@ export async function attach(script) {
31
34
  if (portal.protocol !== 'https:' || portal.username || portal.password || portal.search || portal.hash || portal.pathname !== '/')
32
35
  throw new Error('Leadping requires an HTTPS portal origin.');
33
36
  const endpoint = `${origin.origin}/api/consent`;
37
+ const progress = (stage) => {
38
+ form.dispatchEvent(new CustomEvent('leadping:progress', { detail: { stage } }));
39
+ };
34
40
  const post = async (path, body) => {
35
41
  const attempts = path.endsWith('/sessions') ? 5 : 1;
36
42
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -71,15 +77,11 @@ export async function attach(script) {
71
77
  if (typeof value === 'string' && value)
72
78
  recipient[key] = value;
73
79
  }
74
- let issuingCertificate = false;
75
80
  try {
76
81
  const consentCapture = await capture.finish(recipient);
77
- issuingCertificate = true;
78
- const certificate = await (await post('/certificates', consentCapture)).json();
79
- if (!/^[0-9a-f-]{36}$/i.test(certificate.certificateId))
80
- throw new Error('Invalid certificate response.');
82
+ const certificate = { certificateId: consentCapture.sessionId };
81
83
  const certificateUrl = `${portal.origin}/${certificate.certificateId}`;
82
- status.textContent = `Certificate received: ${certificate.certificateId}. `;
84
+ status.textContent = `Submission saved. Certificate: ${certificate.certificateId}. `;
83
85
  const link = document.createElement('a');
84
86
  link.href = certificateUrl;
85
87
  link.textContent = 'View certificate and replay';
@@ -90,8 +92,8 @@ export async function attach(script) {
90
92
  }
91
93
  catch (error) {
92
94
  fail(error);
93
- if (issuingCertificate)
94
- status.textContent = 'Certificate issuance could not be confirmed. Please check before starting another recording.';
95
+ if (!(error instanceof ReplayLimitExceededError))
96
+ status.textContent = 'Submission could not be confirmed. Keep the certificate ID and check before starting another recording.';
95
97
  }
96
98
  finally {
97
99
  capture.dispose();
@@ -105,12 +107,25 @@ export async function attach(script) {
105
107
  const observedDisclosure = disclosure.textContent ?? '';
106
108
  capture = new ConsentCapture({ apiUrl: origin.origin + '/api',
107
109
  session: async () => (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl, disclosure: observedDisclosure })).json(),
108
- form, disclosure, checkbox, onError: fail });
110
+ form, disclosure, checkbox, issueCertificate: true, onError: fail, onProgress: stage => {
111
+ status.textContent = stage === 'uploading' ? 'Saving recording…' : 'Saving submission and requesting certificate…';
112
+ progress(stage);
113
+ } });
109
114
  window.addEventListener('pagehide', () => { capture?.dispose(); }, { once: true });
110
115
  await capture.ready;
116
+ const certificateId = capture.sessionId;
117
+ const certificateUrl = `${portal.origin}/${certificateId}`;
118
+ for (const [name, value] of Object.entries({ leadping_certificate_id: certificateId, leadping_certificate_url: certificateUrl })) {
119
+ const input = document.createElement('input');
120
+ input.type = 'hidden';
121
+ input.name = name;
122
+ input.value = value;
123
+ form.append(input);
124
+ }
111
125
  if (!submitted)
112
126
  status.textContent = '';
113
- form.dispatchEvent(new CustomEvent('leadping:ready'));
127
+ form.dispatchEvent(new CustomEvent('leadping:ready', { detail: { certificateId, certificateUrl,
128
+ maxReplaySeconds: capture.maxReplaySeconds } }));
114
129
  }
115
130
  catch (error) {
116
131
  capture?.dispose();
@@ -42,7 +42,7 @@ export class EventBuffer {
42
42
  seal() {
43
43
  if (this.events.length === 0)
44
44
  return;
45
- if (this.nextBatch >= 256)
45
+ if (this.nextBatch >= 1024)
46
46
  throw new Error('Recording exceeded the session batch limit.');
47
47
  const first = this.nextEvent - this.events.length;
48
48
  const body = `{"firstEventNumber":${first},"events":[${this.events.join(',')}]}`;
@@ -1,2 +1,3 @@
1
1
  export { ConsentCapture } from './capture.js';
2
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
3
  export type { CaptureOptions, CaptureReference, CaptureSession, ConsentForm, Recipient } from './types.js';
package/dist/esm/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { ConsentCapture } from './capture.js';
2
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
@@ -0,0 +1,10 @@
1
+ /** Checks elapsed time even when a suspended tab has not run its expiry timer. */
2
+ export declare class ReplayDeadline {
3
+ private readonly startedAt;
4
+ private readonly startedTick;
5
+ private expiresAt;
6
+ private maximumMs;
7
+ configure(expiresAt: string, maxReplaySeconds: number): void;
8
+ get remainingMilliseconds(): number;
9
+ assertWithinLimit(): void;
10
+ }
@@ -0,0 +1,23 @@
1
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
+ /** Checks elapsed time even when a suspended tab has not run its expiry timer. */
3
+ export class ReplayDeadline {
4
+ startedAt = Date.now();
5
+ startedTick = performance.now();
6
+ expiresAt = Infinity;
7
+ maximumMs = 1800_000;
8
+ configure(expiresAt, maxReplaySeconds) {
9
+ const deadline = Date.parse(expiresAt);
10
+ if (!Number.isFinite(deadline) || !Number.isInteger(maxReplaySeconds) || maxReplaySeconds < 1 || maxReplaySeconds > 1800)
11
+ throw new Error('Invalid recording time limit.');
12
+ this.expiresAt = deadline;
13
+ this.maximumMs = maxReplaySeconds * 1000;
14
+ this.assertWithinLimit();
15
+ }
16
+ get remainingMilliseconds() {
17
+ return Math.min(this.expiresAt - Date.now(), this.maximumMs - (Date.now() - this.startedAt), this.maximumMs - (performance.now() - this.startedTick));
18
+ }
19
+ assertWithinLimit() {
20
+ if (this.remainingMilliseconds <= 0)
21
+ throw new ReplayLimitExceededError();
22
+ }
23
+ }
@@ -0,0 +1,5 @@
1
+ /** The recording cannot support this submission because its allotted time has elapsed. */
2
+ export declare class ReplayLimitExceededError extends Error {
3
+ readonly code = "replay_limit_exceeded";
4
+ constructor();
5
+ }
@@ -0,0 +1,8 @@
1
+ /** The recording cannot support this submission because its allotted time has elapsed. */
2
+ export class ReplayLimitExceededError extends Error {
3
+ code = 'replay_limit_exceeded';
4
+ constructor() {
5
+ super("The recording time limit was reached. This submission wasn't captured. Start a new recording and submit again.");
6
+ this.name = 'ReplayLimitExceededError';
7
+ }
8
+ }
@@ -1,4 +1,4 @@
1
- /** Serial, bounded retry transport. A resolved request means durable server acknowledgment. */
1
+ /** Bounded retry transport. A resolved request means durable server acknowledgment. */
2
2
  export declare class CaptureTransport {
3
3
  private readonly baseUrl;
4
4
  private readonly token;
@@ -1,5 +1,6 @@
1
1
  import { CaptureRejectedError } from './capture-rejected-error.js';
2
- /** Serial, bounded retry transport. A resolved request means durable server acknowledgment. */
2
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
3
+ /** Bounded retry transport. A resolved request means durable server acknowledgment. */
3
4
  export class CaptureTransport {
4
5
  baseUrl;
5
6
  token;
@@ -26,11 +27,13 @@ export class CaptureTransport {
26
27
  });
27
28
  if (response.ok)
28
29
  return;
30
+ if (response.status === 410)
31
+ throw new ReplayLimitExceededError();
29
32
  if (response.status < 500 && response.status !== 408 && response.status !== 429)
30
33
  throw new CaptureRejectedError(`Capture rejected (${response.status}).`);
31
34
  }
32
35
  catch (error) {
33
- if (error instanceof CaptureRejectedError || this.abort.signal.aborted)
36
+ if (error instanceof CaptureRejectedError || error instanceof ReplayLimitExceededError || this.abort.signal.aborted)
34
37
  throw error;
35
38
  }
36
39
  if (attempt === 4)
@@ -12,6 +12,8 @@ export interface CaptureSession {
12
12
  sessionId: string;
13
13
  uploadToken: string;
14
14
  expiresAt: string;
15
+ /** Maximum elapsed recording time including idle time, supplied by the service. */
16
+ maxReplaySeconds: number;
15
17
  form: ConsentForm;
16
18
  }
17
19
  export interface Recipient {
@@ -32,8 +34,12 @@ export interface CaptureOptions {
32
34
  form: HTMLFormElement;
33
35
  disclosure: HTMLElement;
34
36
  checkbox: HTMLInputElement;
37
+ /** For public domain sessions, durably request a certificate with the final submission. */
38
+ issueCertificate?: boolean;
35
39
  /** Called on terminal capture failures; never includes captured values. */
36
40
  onError?: (error: Error) => void;
41
+ /** Submission stages; no captured values or upload credentials are included. */
42
+ onProgress?: (stage: 'uploading' | 'submitting') => void;
37
43
  }
38
44
  export interface SealedBatch {
39
45
  number: number;
@@ -22,6 +22,7 @@ var LeadpingConsent = (() => {
22
22
  var embed_exports = {};
23
23
  __export(embed_exports, {
24
24
  ConsentCapture: () => ConsentCapture,
25
+ ReplayLimitExceededError: () => ReplayLimitExceededError,
25
26
  attach: () => attach
26
27
  });
27
28
 
@@ -12763,7 +12764,7 @@ var LeadpingConsent = (() => {
12763
12764
  }
12764
12765
  seal() {
12765
12766
  if (this.events.length === 0) return;
12766
- if (this.nextBatch >= 256) throw new Error("Recording exceeded the session batch limit.");
12767
+ if (this.nextBatch >= 1024) throw new Error("Recording exceeded the session batch limit.");
12767
12768
  const first = this.nextEvent - this.events.length;
12768
12769
  const body = `{"firstEventNumber":${first},"events":[${this.events.join(",")}]}`;
12769
12770
  this.events.length = 0;
@@ -12772,6 +12773,15 @@ var LeadpingConsent = (() => {
12772
12773
  }
12773
12774
  };
12774
12775
 
12776
+ // src/replay-limit-exceeded-error.ts
12777
+ var ReplayLimitExceededError = class extends Error {
12778
+ code = "replay_limit_exceeded";
12779
+ constructor() {
12780
+ super("The recording time limit was reached. This submission wasn't captured. Start a new recording and submit again.");
12781
+ this.name = "ReplayLimitExceededError";
12782
+ }
12783
+ };
12784
+
12775
12785
  // src/transport.ts
12776
12786
  var CaptureTransport = class {
12777
12787
  constructor(baseUrl, token) {
@@ -12802,10 +12812,11 @@ var LeadpingConsent = (() => {
12802
12812
  signal: AbortSignal.any([this.abort.signal, AbortSignal.timeout(15e3)])
12803
12813
  });
12804
12814
  if (response.ok) return;
12815
+ if (response.status === 410) throw new ReplayLimitExceededError();
12805
12816
  if (response.status < 500 && response.status !== 408 && response.status !== 429)
12806
12817
  throw new CaptureRejectedError(`Capture rejected (${response.status}).`);
12807
12818
  } catch (error) {
12808
- if (error instanceof CaptureRejectedError || this.abort.signal.aborted) throw error;
12819
+ if (error instanceof CaptureRejectedError || error instanceof ReplayLimitExceededError || this.abort.signal.aborted) throw error;
12809
12820
  }
12810
12821
  if (attempt === 4) break;
12811
12822
  await this.delay(Math.min(8e3, 500 * 2 ** attempt) + Math.random() * 250);
@@ -12831,6 +12842,32 @@ var LeadpingConsent = (() => {
12831
12842
  }
12832
12843
  };
12833
12844
 
12845
+ // src/replay-deadline.ts
12846
+ var ReplayDeadline = class {
12847
+ startedAt = Date.now();
12848
+ startedTick = performance.now();
12849
+ expiresAt = Infinity;
12850
+ maximumMs = 18e5;
12851
+ configure(expiresAt, maxReplaySeconds) {
12852
+ const deadline = Date.parse(expiresAt);
12853
+ if (!Number.isFinite(deadline) || !Number.isInteger(maxReplaySeconds) || maxReplaySeconds < 1 || maxReplaySeconds > 1800)
12854
+ throw new Error("Invalid recording time limit.");
12855
+ this.expiresAt = deadline;
12856
+ this.maximumMs = maxReplaySeconds * 1e3;
12857
+ this.assertWithinLimit();
12858
+ }
12859
+ get remainingMilliseconds() {
12860
+ return Math.min(
12861
+ this.expiresAt - Date.now(),
12862
+ this.maximumMs - (Date.now() - this.startedAt),
12863
+ this.maximumMs - (performance.now() - this.startedTick)
12864
+ );
12865
+ }
12866
+ assertWithinLimit() {
12867
+ if (this.remainingMilliseconds <= 0) throw new ReplayLimitExceededError();
12868
+ }
12869
+ };
12870
+
12834
12871
  // src/capture.ts
12835
12872
  var ConsentCapture = class _ConsentCapture {
12836
12873
  constructor(options) {
@@ -12846,12 +12883,10 @@ var LeadpingConsent = (() => {
12846
12883
  if (this.disposed || this.failure) throw this.failure ?? new Error("Recorder is disposed.");
12847
12884
  if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin || session.form.disclosure !== this.observedDisclosure)
12848
12885
  throw new Error("Form does not match the approved disclosure.");
12849
- const remaining = Date.parse(session.expiresAt) - Date.now();
12850
- if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1e3)
12851
- throw new Error("Invalid or expired capture session.");
12886
+ this.deadline.configure(session.expiresAt, session.maxReplaySeconds);
12852
12887
  this.session = session;
12853
12888
  this.transport = new CaptureTransport(url.href.replace(/\/$/, "") + "/consent/sessions/" + session.sessionId, session.uploadToken);
12854
- if (!this.stopped) this.expiry = setTimeout(() => this.fail(new Error("Capture session expired.")), remaining);
12889
+ if (!this.stopped) this.expiry = setTimeout(() => this.fail(new ReplayLimitExceededError()), this.deadline.remainingMilliseconds);
12855
12890
  });
12856
12891
  void this.ready.then(this.backgroundFlush).catch((error) => this.fail(error));
12857
12892
  _ConsentCapture.active = this;
@@ -12901,9 +12936,18 @@ var LeadpingConsent = (() => {
12901
12936
  maxTouchPoints: navigator.maxTouchPoints
12902
12937
  };
12903
12938
  buffer = new EventBuffer();
12939
+ deadline = new ReplayDeadline();
12904
12940
  transport;
12905
12941
  /** Resolves when the service session is available and validated. Recording starts before this. */
12906
12942
  ready;
12943
+ /** Allocated as soon as the session is ready; this does not assert that submission is saved. */
12944
+ get sessionId() {
12945
+ return this.session?.sessionId;
12946
+ }
12947
+ /** The server-selected duration limit, available after ready resolves. */
12948
+ get maxReplaySeconds() {
12949
+ return this.session?.maxReplaySeconds;
12950
+ }
12907
12951
  session;
12908
12952
  observedDisclosure;
12909
12953
  disposed = false;
@@ -12940,14 +12984,23 @@ var LeadpingConsent = (() => {
12940
12984
  }
12941
12985
  async finishCore(recipient) {
12942
12986
  if (this.stopped || this.failure) throw this.failure ?? new Error("Recorder is stopped.");
12987
+ try {
12988
+ this.deadline.assertWithinLimit();
12989
+ } catch (error) {
12990
+ this.fail(error);
12991
+ throw error;
12992
+ }
12943
12993
  const observedDisclosure = this.options.disclosure.textContent ?? "";
12944
12994
  if (observedDisclosure !== this.observedDisclosure)
12945
12995
  throw new Error("The disclosure changed during capture. Start a new approved form session.");
12946
12996
  const accepted = this.options.checkbox.checked;
12947
12997
  record.addCustomEvent("leadping.submit", { accepted });
12948
12998
  this.stop();
12999
+ this.options.onProgress?.("uploading");
12949
13000
  await this.flush();
12950
- await this.transport.put("/submission", JSON.stringify({
13001
+ this.deadline.assertWithinLimit();
13002
+ this.options.onProgress?.("submitting");
13003
+ await this.transport.put(this.options.issueCertificate ? "/submission/certificate" : "/submission", JSON.stringify({
12951
13004
  lastBatchNumber: this.buffer.lastBatchNumber,
12952
13005
  lastEventNumber: this.buffer.lastEventNumber,
12953
13006
  accepted,
@@ -12979,25 +13032,31 @@ var LeadpingConsent = (() => {
12979
13032
  async send() {
12980
13033
  await Promise.resolve();
12981
13034
  try {
12982
- while (this.head < this.pending.length) {
12983
- const batch = this.pending[this.head];
12984
- await this.transport.put("/batches/" + batch.number, batch.body);
12985
- this.queuedBytes -= batch.body.length * 2;
12986
- this.pending[this.head] = void 0;
12987
- this.head++;
12988
- if (this.head === this.pending.length) {
12989
- this.pending.length = 0;
12990
- this.head = 0;
12991
- }
13035
+ await Promise.all(Array.from({ length: 3 }, async () => {
13036
+ while (!this.failure && !this.disposed && this.head < this.pending.length) {
13037
+ const index2 = this.head++;
13038
+ const batch = this.pending[index2];
13039
+ await this.transport.put("/batches/" + batch.number, batch.body);
13040
+ this.queuedBytes -= batch.body.length * 2;
13041
+ this.pending[index2] = void 0;
13042
+ }
13043
+ }));
13044
+ if (this.head === this.pending.length) {
13045
+ this.pending.length = 0;
13046
+ this.head = 0;
12992
13047
  }
12993
13048
  } catch (error) {
13049
+ this.transport?.dispose();
12994
13050
  this.fail(error instanceof Error ? error : new Error("Capture upload failed."));
12995
13051
  throw this.failure;
12996
13052
  } finally {
12997
13053
  this.sending = void 0;
12998
13054
  }
12999
13055
  }
13000
- consentChanged = () => record.addCustomEvent("leadping.consent", { accepted: this.options.checkbox.checked });
13056
+ consentChanged = () => {
13057
+ record.addCustomEvent("leadping.consent", { accepted: this.options.checkbox.checked });
13058
+ if (this.options.checkbox.checked) this.backgroundFlush();
13059
+ };
13001
13060
  visibilityChanged = () => {
13002
13061
  if (document.visibilityState === "hidden") this.backgroundFlush();
13003
13062
  };
@@ -13038,7 +13097,7 @@ var LeadpingConsent = (() => {
13038
13097
  let capture;
13039
13098
  let submitted = false;
13040
13099
  const fail = (error) => {
13041
- status.textContent = "Unable to complete consent capture. Please reload to start again.";
13100
+ status.textContent = error instanceof ReplayLimitExceededError ? error.message : "Unable to complete consent capture. Please reload to start again.";
13042
13101
  form.dispatchEvent(new CustomEvent("leadping:error", { detail: error }));
13043
13102
  };
13044
13103
  const origin = new URL(script2.dataset.apiUrl ?? "https://consent.leadping.ai");
@@ -13048,6 +13107,9 @@ var LeadpingConsent = (() => {
13048
13107
  if (portal.protocol !== "https:" || portal.username || portal.password || portal.search || portal.hash || portal.pathname !== "/")
13049
13108
  throw new Error("Leadping requires an HTTPS portal origin.");
13050
13109
  const endpoint = `${origin.origin}/api/consent`;
13110
+ const progress = (stage) => {
13111
+ form.dispatchEvent(new CustomEvent("leadping:progress", { detail: { stage } }));
13112
+ };
13051
13113
  const post = async (path, body) => {
13052
13114
  const attempts = path.endsWith("/sessions") ? 5 : 1;
13053
13115
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -13086,14 +13148,11 @@ var LeadpingConsent = (() => {
13086
13148
  const value = values.get(key);
13087
13149
  if (typeof value === "string" && value) recipient[key] = value;
13088
13150
  }
13089
- let issuingCertificate = false;
13090
13151
  try {
13091
13152
  const consentCapture = await capture.finish(recipient);
13092
- issuingCertificate = true;
13093
- const certificate = await (await post("/certificates", consentCapture)).json();
13094
- if (!/^[0-9a-f-]{36}$/i.test(certificate.certificateId)) throw new Error("Invalid certificate response.");
13153
+ const certificate = { certificateId: consentCapture.sessionId };
13095
13154
  const certificateUrl = `${portal.origin}/${certificate.certificateId}`;
13096
- status.textContent = `Certificate received: ${certificate.certificateId}. `;
13155
+ status.textContent = `Submission saved. Certificate: ${certificate.certificateId}. `;
13097
13156
  const link = document.createElement("a");
13098
13157
  link.href = certificateUrl;
13099
13158
  link.textContent = "View certificate and replay";
@@ -13103,7 +13162,8 @@ var LeadpingConsent = (() => {
13103
13162
  form.dispatchEvent(new CustomEvent("leadping:success", { detail: { certificateId: certificate.certificateId, certificateUrl } }));
13104
13163
  } catch (error) {
13105
13164
  fail(error);
13106
- if (issuingCertificate) status.textContent = "Certificate issuance could not be confirmed. Please check before starting another recording.";
13165
+ if (!(error instanceof ReplayLimitExceededError))
13166
+ status.textContent = "Submission could not be confirmed. Keep the certificate ID and check before starting another recording.";
13107
13167
  } finally {
13108
13168
  capture.dispose();
13109
13169
  }
@@ -13120,14 +13180,32 @@ var LeadpingConsent = (() => {
13120
13180
  form,
13121
13181
  disclosure,
13122
13182
  checkbox,
13123
- onError: fail
13183
+ issueCertificate: true,
13184
+ onError: fail,
13185
+ onProgress: (stage) => {
13186
+ status.textContent = stage === "uploading" ? "Saving recording\u2026" : "Saving submission and requesting certificate\u2026";
13187
+ progress(stage);
13188
+ }
13124
13189
  });
13125
13190
  window.addEventListener("pagehide", () => {
13126
13191
  capture?.dispose();
13127
13192
  }, { once: true });
13128
13193
  await capture.ready;
13194
+ const certificateId = capture.sessionId;
13195
+ const certificateUrl = `${portal.origin}/${certificateId}`;
13196
+ for (const [name, value] of Object.entries({ leadping_certificate_id: certificateId, leadping_certificate_url: certificateUrl })) {
13197
+ const input2 = document.createElement("input");
13198
+ input2.type = "hidden";
13199
+ input2.name = name;
13200
+ input2.value = value;
13201
+ form.append(input2);
13202
+ }
13129
13203
  if (!submitted) status.textContent = "";
13130
- form.dispatchEvent(new CustomEvent("leadping:ready"));
13204
+ form.dispatchEvent(new CustomEvent("leadping:ready", { detail: {
13205
+ certificateId,
13206
+ certificateUrl,
13207
+ maxReplaySeconds: capture.maxReplaySeconds
13208
+ } }));
13131
13209
  } catch (error) {
13132
13210
  capture?.dispose();
13133
13211
  fail(error);