@genlook/storefront 0.1.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.
Files changed (60) hide show
  1. package/README.md +180 -0
  2. package/dist/analytics.d.ts +26 -0
  3. package/dist/analytics.js +7 -0
  4. package/dist/anonymous-id.d.ts +30 -0
  5. package/dist/anonymous-id.js +33 -0
  6. package/dist/client.d.ts +289 -0
  7. package/dist/client.js +465 -0
  8. package/dist/consent.d.ts +17 -0
  9. package/dist/consent.js +34 -0
  10. package/dist/create-client.d.ts +84 -0
  11. package/dist/create-client.js +131 -0
  12. package/dist/default-tracking.d.ts +64 -0
  13. package/dist/default-tracking.js +101 -0
  14. package/dist/email.d.ts +5 -0
  15. package/dist/email.js +24 -0
  16. package/dist/entities.d.ts +186 -0
  17. package/dist/entities.js +47 -0
  18. package/dist/erasure.d.ts +36 -0
  19. package/dist/erasure.js +23 -0
  20. package/dist/events.d.ts +226 -0
  21. package/dist/events.js +41 -0
  22. package/dist/fetch-transport.d.ts +65 -0
  23. package/dist/fetch-transport.js +112 -0
  24. package/dist/generation.d.ts +132 -0
  25. package/dist/generation.js +382 -0
  26. package/dist/history.d.ts +76 -0
  27. package/dist/history.js +68 -0
  28. package/dist/memory-storage.d.ts +10 -0
  29. package/dist/memory-storage.js +12 -0
  30. package/dist/pending-upload.d.ts +32 -0
  31. package/dist/pending-upload.js +13 -0
  32. package/dist/persistence.d.ts +12 -0
  33. package/dist/persistence.js +101 -0
  34. package/dist/policy.d.ts +24 -0
  35. package/dist/policy.js +45 -0
  36. package/dist/ports.d.ts +187 -0
  37. package/dist/ports.js +1 -0
  38. package/dist/public-api.d.ts +235 -0
  39. package/dist/public-api.js +1 -0
  40. package/dist/public.d.ts +33 -0
  41. package/dist/public.js +12 -0
  42. package/dist/settings.d.ts +38 -0
  43. package/dist/settings.js +70 -0
  44. package/dist/sharing.d.ts +26 -0
  45. package/dist/sharing.js +42 -0
  46. package/dist/storage-adapters.d.ts +69 -0
  47. package/dist/storage-adapters.js +82 -0
  48. package/dist/store.d.ts +9 -0
  49. package/dist/store.js +23 -0
  50. package/dist/tracker.d.ts +124 -0
  51. package/dist/tracker.js +229 -0
  52. package/dist/types.d.ts +91 -0
  53. package/dist/types.js +1 -0
  54. package/dist/upload.d.ts +123 -0
  55. package/dist/upload.js +176 -0
  56. package/dist/usage.d.ts +45 -0
  57. package/dist/usage.js +110 -0
  58. package/dist/version.d.ts +10 -0
  59. package/dist/version.js +1 -0
  60. package/package.json +30 -0
package/dist/client.js ADDED
@@ -0,0 +1,465 @@
1
+ import { initialCoreState, selectUnseenResults } from "./entities";
2
+ import { createStore } from "./store";
3
+ import { can as evaluatePolicy, PolicyBlockedError } from "./policy";
4
+ import { attachAnalytics } from "./analytics";
5
+ import { runUpload } from "./upload";
6
+ import { PendingUpload } from "./pending-upload";
7
+ import { CoreEvents } from "./events";
8
+ function photoMetaFields(meta) {
9
+ return {
10
+ fileSize: meta.fileSize,
11
+ mimeType: meta.mimeType,
12
+ uploadSource: meta.uploadSource,
13
+ skipClientDimensionCheck: meta.skipClientDimensionCheck,
14
+ ...(meta.photoAgeSeconds != null ? { photo_age_s: meta.photoAgeSeconds } : {}),
15
+ ...(meta.capturedInPicker != null ? { captured_in_picker: meta.capturedInPicker } : {}),
16
+ ...(meta.pickerDurationMs != null ? { picker_duration_ms: meta.pickerDurationMs } : {}),
17
+ ...(meta.pickerBackgrounded != null ? { picker_backgrounded: meta.pickerBackgrounded } : {}),
18
+ ...(meta.heldDurationMs != null ? { held_duration_ms: meta.heldDurationMs } : {}),
19
+ ...(typeof meta.dimensions === "object"
20
+ ? { width: meta.dimensions.width, height: meta.dimensions.height }
21
+ : {}),
22
+ };
23
+ }
24
+ import { getCurrentPlan, checkCredits, rateGeneration, rateResult, runGeneration, acknowledgeGeneration, scheduleInFlightResumes, GenerationFailedError, } from "./generation";
25
+ import { collectEmail } from "./email";
26
+ import { runDataErasure } from "./erasure";
27
+ import { createShareLink, getShareUrl } from "./sharing";
28
+ import { loadJobs, saveJobs } from "./persistence";
29
+ import { loadConsent, saveConsent } from "./consent";
30
+ import { loadUsage, saveUsage, mergeUsage, migrateLegacyUsage, pruneTryOns } from "./usage";
31
+ import { loadHistory, saveHistory, selectRecentResults, buildTryOnResult, appendEntry, updateEntry, } from "./history";
32
+ const CREDITS_CHECK_CACHE_TTL = 2 * 60 * 1000;
33
+ const GENERATE_DEDUP_TTL = 60 * 1000;
34
+ const RATING_EVENT_SOURCE = "history-page-test-2";
35
+ export class TryOnClient {
36
+ config;
37
+ transport;
38
+ storage;
39
+ track;
40
+ now;
41
+ netInfo;
42
+ onDispose;
43
+ disposed = false;
44
+ store;
45
+ creditsCheckCache = null;
46
+ inFlight = new Map();
47
+ pending = new PendingUpload();
48
+ staged = null;
49
+ events = new CoreEvents();
50
+ constructor(deps) {
51
+ this.config = deps.config;
52
+ this.transport = deps.transport;
53
+ this.storage = deps.storage;
54
+ this.track = deps.tracker ?? { capture: () => { } };
55
+ attachAnalytics(this.events, this.track);
56
+ this.now = deps.now ?? Date.now;
57
+ this.netInfo = deps.netInfo;
58
+ this.onDispose = deps.onDispose;
59
+ this.store = createStore(this.buildInitialState());
60
+ const storeId = this.config.storeId ?? "";
61
+ const now = this.now();
62
+ const usage = loadUsage(this.storage, storeId, now) ?? migrateLegacyUsage(this.storage, now);
63
+ const jobs = loadJobs(this.storage, storeId, now);
64
+ const history = loadHistory(this.storage);
65
+ const consent = loadConsent(this.storage, this.config.storeId);
66
+ this.store.subscribe(() => {
67
+ saveJobs(this.storage, storeId, Object.values(this.store.getState().generations));
68
+ });
69
+ this.store.subscribe(() => {
70
+ const now = this.now();
71
+ const merged = mergeUsage(loadUsage(this.storage, storeId, now), this.usageSnapshot(), now);
72
+ saveUsage(this.storage, storeId, merged, now);
73
+ });
74
+ this.store.subscribe(() => {
75
+ saveHistory(this.storage, this.store.getState().history);
76
+ });
77
+ if (usage || jobs.length > 0 || history.length > 0 || consent) {
78
+ this.store.setState((s) => {
79
+ const next = { ...s };
80
+ if (usage) {
81
+ next.limits = {
82
+ ...s.limits,
83
+ tryOns: usage.tryOns, refunded: usage.refunded, totalTryOns: usage.totalTryOns,
84
+ };
85
+ next.identity = { ...s.identity, email: usage.email ?? s.identity.email };
86
+ }
87
+ if (jobs.length > 0) {
88
+ const generations = { ...s.generations };
89
+ for (const job of jobs)
90
+ generations[job.id] = job;
91
+ next.generations = generations;
92
+ }
93
+ if (history.length > 0)
94
+ next.history = history;
95
+ if (consent)
96
+ next.legalConsent = consent;
97
+ return next;
98
+ });
99
+ }
100
+ if (consent)
101
+ this.track.grantWidgetScopeConsent?.();
102
+ scheduleInFlightResumes(this.generationDeps(), jobs);
103
+ }
104
+ getState() {
105
+ return this.store.getState();
106
+ }
107
+ subscribe(listener) {
108
+ return this.store.subscribe(listener);
109
+ }
110
+ flushEvents(opts) {
111
+ return Promise.resolve(this.track.flush?.(opts)).then(() => undefined, () => undefined);
112
+ }
113
+ dispose() {
114
+ if (this.disposed)
115
+ return;
116
+ this.disposed = true;
117
+ this.flushEvents().catch(() => { });
118
+ try {
119
+ this.onDispose?.();
120
+ }
121
+ catch { }
122
+ }
123
+ buildInitialState() {
124
+ const state = initialCoreState();
125
+ const l = this.config.limits ?? {};
126
+ state.limits.maxGenerations = l.maxGenerations ?? state.limits.maxGenerations;
127
+ state.limits.emailCollectionStep = l.emailCollectionStep ?? state.limits.emailCollectionStep;
128
+ state.limits.period = l.period === "daily" ? "daily" : "weekly";
129
+ if (l.maxConcurrent !== undefined)
130
+ state.limits.maxConcurrent = l.maxConcurrent;
131
+ state.limits.loggedInCustomersOnly = this.config.loggedInCustomersOnly ?? false;
132
+ state.limits.requireLegalConsent = this.config.requireLegalConsent ?? false;
133
+ state.identity.loggedInCustomerId = this.config.loggedInCustomerId ?? null;
134
+ return state;
135
+ }
136
+ applySettings(settings) {
137
+ this.store.setState((s) => {
138
+ const limits = { ...s.limits };
139
+ let changed = false;
140
+ if (settings.maxGenerations !== undefined && settings.maxGenerations !== limits.maxGenerations) {
141
+ limits.maxGenerations = settings.maxGenerations;
142
+ changed = true;
143
+ }
144
+ if (settings.emailCollectionStep !== undefined &&
145
+ settings.emailCollectionStep !== limits.emailCollectionStep) {
146
+ limits.emailCollectionStep = settings.emailCollectionStep;
147
+ changed = true;
148
+ }
149
+ if (settings.period !== undefined && settings.period !== limits.period) {
150
+ limits.period = settings.period;
151
+ changed = true;
152
+ }
153
+ if (settings.loggedInCustomersOnly !== undefined &&
154
+ settings.loggedInCustomersOnly !== limits.loggedInCustomersOnly) {
155
+ limits.loggedInCustomersOnly = settings.loggedInCustomersOnly;
156
+ changed = true;
157
+ }
158
+ return changed ? { ...s, limits } : s;
159
+ });
160
+ }
161
+ setIdentity(input) {
162
+ this.store.setState((s) => ({
163
+ ...s,
164
+ identity: {
165
+ email: input.email !== undefined ? input.email : s.identity.email,
166
+ loggedInCustomerId: input.loggedInCustomerId !== undefined ? input.loggedInCustomerId : s.identity.loggedInCustomerId,
167
+ },
168
+ }));
169
+ }
170
+ can(action) {
171
+ return evaluatePolicy(action, this.store.getState(), this.now());
172
+ }
173
+ usageSnapshot() {
174
+ const s = this.store.getState();
175
+ return {
176
+ tryOns: s.limits.tryOns,
177
+ refunded: s.limits.refunded,
178
+ totalTryOns: s.limits.totalTryOns,
179
+ email: s.identity.email,
180
+ };
181
+ }
182
+ syncUsageFromStorage() {
183
+ const now = this.now();
184
+ const persisted = loadUsage(this.storage, this.config.storeId ?? "", now);
185
+ if (!persisted)
186
+ return;
187
+ const current = this.usageSnapshot();
188
+ const merged = mergeUsage(persisted, current, now);
189
+ const unchanged = merged.tryOns.length === current.tryOns.length &&
190
+ merged.refunded.length === current.refunded.length &&
191
+ merged.totalTryOns === current.totalTryOns &&
192
+ merged.email === current.email;
193
+ if (unchanged)
194
+ return;
195
+ this.store.setState((s) => ({
196
+ ...s,
197
+ limits: {
198
+ ...s.limits,
199
+ tryOns: merged.tryOns, refunded: merged.refunded, totalTryOns: merged.totalTryOns,
200
+ },
201
+ identity: s.identity.email === null && merged.email
202
+ ? { ...s.identity, email: merged.email }
203
+ : s.identity,
204
+ }));
205
+ }
206
+ recordUsage() {
207
+ let now = this.now();
208
+ const { tryOns: existing, refunded } = this.store.getState().limits;
209
+ while (existing.includes(now) || refunded.includes(now))
210
+ now += 1;
211
+ const at = now;
212
+ this.store.setState((s) => ({
213
+ ...s,
214
+ limits: {
215
+ ...s.limits,
216
+ tryOns: pruneTryOns([...s.limits.tryOns, at], at),
217
+ totalTryOns: s.limits.totalTryOns + 1,
218
+ },
219
+ }));
220
+ return at;
221
+ }
222
+ acceptLegalConsent(version) {
223
+ const current = this.store.getState().legalConsent;
224
+ if (current && current.version === version)
225
+ return;
226
+ const consent = { version, acceptedAt: new Date(this.now()).toISOString() };
227
+ saveConsent(this.storage, this.config.storeId, consent);
228
+ this.store.setState((s) => ({ ...s, legalConsent: consent }));
229
+ this.events.emit({ type: "tryon:legal_consent_accepted", consent_version: version });
230
+ this.flushStagedPhoto();
231
+ this.track.grantWidgetScopeConsent?.();
232
+ }
233
+ unseenResults() {
234
+ return selectUnseenResults(this.store.getState());
235
+ }
236
+ acknowledge(generationId) {
237
+ acknowledgeGeneration(this.store, generationId);
238
+ }
239
+ netFields() {
240
+ if (!this.netInfo)
241
+ return {};
242
+ try {
243
+ const n = this.netInfo();
244
+ return {
245
+ net_online: n.online,
246
+ ...(n.effectiveType ? { net_effective_type: n.effectiveType } : {}),
247
+ ...(n.visibility ? { net_visibility: n.visibility } : {}),
248
+ };
249
+ }
250
+ catch {
251
+ return {};
252
+ }
253
+ }
254
+ uploadDeps() {
255
+ return {
256
+ transport: this.transport,
257
+ store: this.store,
258
+ now: this.now,
259
+ emitImageUpload: (meta) => this.events.emit({ type: "tryon:photo_upload_started", ...photoMetaFields(meta) }),
260
+ emitImageUploadSuccess: (meta, fileId, ms) => this.events.emit({
261
+ type: "tryon:photo_upload_succeeded",
262
+ fileId,
263
+ fileSize: meta.fileSize, mimeType: meta.mimeType,
264
+ uploadSource: meta.uploadSource, skipClientDimensionCheck: meta.skipClientDimensionCheck,
265
+ duration_ms: ms,
266
+ }),
267
+ emitImageUploadError: (meta, error) => this.events.emit({
268
+ type: "tryon:photo_upload_failed",
269
+ fileSize: meta.fileSize, mimeType: meta.mimeType, uploadSource: meta.uploadSource,
270
+ error: error.substring(0, 500),
271
+ kind: "upload_failed", code: "UPLOAD_FAILED",
272
+ ...this.netFields(),
273
+ }),
274
+ emitImageUploadRejected: (meta, reason, extra) => this.events.emit({
275
+ type: "tryon:photo_upload_rejected",
276
+ fileSize: meta.fileSize, mimeType: meta.mimeType, uploadSource: meta.uploadSource,
277
+ rejectionReason: reason,
278
+ kind: "upload_rejected",
279
+ ...(extra ?? {}),
280
+ }),
281
+ };
282
+ }
283
+ generationDeps() {
284
+ return {
285
+ transport: this.transport,
286
+ store: this.store,
287
+ now: this.now,
288
+ emitGenerationStart: (id) => this.events.emit({ type: "tryon:generation_started", generationId: id }),
289
+ emitGenerationSucceeded: (i) => this.events.emit({
290
+ type: "tryon:generation_succeeded",
291
+ generationId: i.generationId,
292
+ duration_ms: i.durationMs,
293
+ productId: i.productId,
294
+ ...(i.variantId !== undefined ? { variantId: i.variantId } : {}),
295
+ }),
296
+ emitGenerationFailed: (i) => this.events.emit({ type: "tryon:generation_failed", generationId: i.generationId, kind: i.kind, code: i.code }),
297
+ emitGenerationBlocked: (reason) => this.events.emit({ type: "tryon:generation_blocked", reason }),
298
+ };
299
+ }
300
+ uploadPhoto(file, meta, productContext) {
301
+ const verdict = this.can("upload");
302
+ if (!verdict.ok) {
303
+ const rejected = Promise.reject(new PolicyBlockedError(verdict.blocked));
304
+ rejected.catch(() => { });
305
+ return rejected;
306
+ }
307
+ const promise = runUpload(this.uploadDeps(), file, meta, productContext);
308
+ this.pending.register(promise);
309
+ return promise;
310
+ }
311
+ on(type, listener) {
312
+ return this.events.on(type, listener);
313
+ }
314
+ onAny(listener) {
315
+ return this.events.onAny(listener);
316
+ }
317
+ stagePhoto(file, meta, productContext) {
318
+ if (this.store.getState().legalConsent) {
319
+ this.staged = null;
320
+ this.events.emit({ type: "tryon:photo_submitted", held: false, ...photoMetaFields(meta) });
321
+ void this.uploadPhoto(file, meta, productContext);
322
+ return;
323
+ }
324
+ this.staged = { file, meta, productContext, heldAt: this.now() };
325
+ this.events.emit({ type: "tryon:photo_submitted", held: true, ...photoMetaFields(meta) });
326
+ }
327
+ flushStagedPhoto() {
328
+ const held = this.staged;
329
+ if (!held)
330
+ return;
331
+ this.staged = null;
332
+ const meta = { ...held.meta, heldDurationMs: Math.max(0, this.now() - held.heldAt) };
333
+ void this.uploadPhoto(held.file, meta, held.productContext);
334
+ }
335
+ clearPendingUpload() {
336
+ this.pending.clear();
337
+ this.staged = null;
338
+ this.events.emit({ type: "tryon:photo_discarded" });
339
+ this.store.setState((s) => (s.latestPhotoId === null ? s : { ...s, latestPhotoId: null }));
340
+ }
341
+ async resolveUserImageId(input) {
342
+ let userImageId = input.userImageId;
343
+ const pending = this.pending.current();
344
+ if (input.userImage === "latest" && pending) {
345
+ try {
346
+ userImageId = (await pending).fileId;
347
+ }
348
+ catch (err) {
349
+ throw new GenerationFailedError("UPLOAD_FAILED", err instanceof Error ? err.message : String(err));
350
+ }
351
+ }
352
+ if (!userImageId) {
353
+ throw new GenerationFailedError("UPLOAD_FAILED", "No uploaded photo available");
354
+ }
355
+ return userImageId;
356
+ }
357
+ async generate(input) {
358
+ const userImageId = await this.resolveUserImageId(input);
359
+ const key = `${userImageId}:${input.productId}:${input.variantId ?? ""}`;
360
+ const now = this.now();
361
+ const cached = this.inFlight.get(key);
362
+ if (cached && now - cached.ts < GENERATE_DEDUP_TTL)
363
+ return cached.promise;
364
+ this.syncUsageFromStorage();
365
+ const verdict = this.can("generate");
366
+ if (!verdict.ok) {
367
+ this.events.emit({ type: "tryon:generation_blocked", reason: verdict.blocked });
368
+ throw new PolicyBlockedError(verdict.blocked);
369
+ }
370
+ const usageAt = this.recordUsage();
371
+ const promise = runGeneration(this.generationDeps(), {
372
+ userImageId, productId: input.productId, variantId: input.variantId, usageAt,
373
+ }).then((res) => {
374
+ if (!input.context)
375
+ return res;
376
+ const result = buildTryOnResult({
377
+ generationId: res.generationId,
378
+ resultImage: res.imageUrl,
379
+ context: input.context,
380
+ now: this.now(),
381
+ });
382
+ this.appendResult(result);
383
+ return { ...res, result };
384
+ });
385
+ this.inFlight.set(key, { promise, ts: now });
386
+ promise.catch(() => {
387
+ const c = this.inFlight.get(key);
388
+ if (c && c.promise === promise)
389
+ this.inFlight.delete(key);
390
+ });
391
+ return promise;
392
+ }
393
+ getHistory() {
394
+ return this.store.getState().history;
395
+ }
396
+ appendResult(entry) {
397
+ this.store.setState((s) => ({ ...s, history: appendEntry(s.history, entry) }));
398
+ }
399
+ updateResult(id, patch) {
400
+ this.store.setState((s) => ({ ...s, history: updateEntry(s.history, id, patch) }));
401
+ }
402
+ replaceHistory(entries) {
403
+ this.store.setState((s) => ({ ...s, history: [...entries] }));
404
+ }
405
+ clearHistory() {
406
+ this.store.setState((s) => ({ ...s, history: [] }));
407
+ }
408
+ deleteMyData() {
409
+ return runDataErasure({
410
+ transport: this.transport,
411
+ clearHistory: () => this.clearHistory(),
412
+ clearPendingUpload: () => this.clearPendingUpload(),
413
+ emitDataErased: () => this.events.emit({ type: "tryon:data_erased" }),
414
+ });
415
+ }
416
+ recentResults(retentionDays) {
417
+ return selectRecentResults(this.store.getState().history, retentionDays, this.now());
418
+ }
419
+ getShareUrl(entryId, opts) {
420
+ return getShareUrl({
421
+ transport: this.transport,
422
+ store: this.store,
423
+ emitShareLinkCreated: (i) => this.events.emit({ type: "tryon:share_link_created", generationId: i.generationId, entryId: i.entryId }),
424
+ }, entryId, opts);
425
+ }
426
+ rateResult(entryId, rating, reason) {
427
+ return rateResult({
428
+ transport: this.transport,
429
+ store: this.store,
430
+ emitResultRated: (generationId, r) => this.events.emit({ type: "tryon:result_rated", generationId, rating: r, source: RATING_EVENT_SOURCE }),
431
+ emitResultRatingReason: (generationId, rc) => this.events.emit({ type: "tryon:result_feedback_given", generationId, reason: rc, source: RATING_EVENT_SOURCE }),
432
+ }, entryId, rating, reason);
433
+ }
434
+ getCurrentPlan() {
435
+ return getCurrentPlan(this.transport);
436
+ }
437
+ async checkCredits() {
438
+ const now = this.now();
439
+ if (this.creditsCheckCache && now - this.creditsCheckCache.timestamp < CREDITS_CHECK_CACHE_TTL) {
440
+ console.log("Using cached credits check result");
441
+ return this.creditsCheckCache.data;
442
+ }
443
+ const result = await checkCredits(this.transport);
444
+ this.creditsCheckCache = { data: result, timestamp: now };
445
+ this.store.setState((s) => ({ ...s, limits: { ...s.limits, creditsAllowed: result.allowed } }));
446
+ return result;
447
+ }
448
+ rateGeneration(generationId, rating, reason) {
449
+ return rateGeneration(this.transport, generationId, rating, reason);
450
+ }
451
+ collectEmail(request) {
452
+ this.setIdentity({ email: request.email });
453
+ this.events.emit({
454
+ type: "tryon:email_collected",
455
+ emailCollectionStep: request.emailCollectionStep,
456
+ checkboxDisplayed: true,
457
+ ...(request.marketingConsent !== undefined ? { marketingConsent: request.marketingConsent } : {}),
458
+ });
459
+ return collectEmail(this.transport, request);
460
+ }
461
+ createShareLink(generationId, effectiveDomain, productUrl) {
462
+ return createShareLink(this.transport, generationId, effectiveDomain, productUrl);
463
+ }
464
+ }
465
+ export { PolicyBlockedError };
@@ -0,0 +1,17 @@
1
+ import type { KVStorage } from "./ports";
2
+ import type { LegalConsent } from "./entities";
3
+ /** Same key the pre-headless sheet wrote: `tryon-legal-consent:${storeId ?? "default"}`.
4
+ * `storeId` is optional and threaded through un-coerced (the client passes
5
+ * `config.storeId`) so it matches the sheet's `host.storeId` — both descend from
6
+ * the one boot-computed storeId. */
7
+ export declare function consentStorageKey(storeId?: string): string;
8
+ /**
9
+ * Load the persisted consent record, or null when absent/malformed. Accepts the
10
+ * legacy `{ version, acceptedAt }` shape byte-for-byte — the pre-headless sheet's
11
+ * `hasStoredConsent` only checked truthiness, so any record it wrote (always with
12
+ * both string fields) loads here.
13
+ */
14
+ export declare function loadConsent(storage: KVStorage, storeId?: string): LegalConsent | null;
15
+ /** Persist the consent record (best-effort). Shape byte-identical to the legacy
16
+ * sheet write: `{ version, acceptedAt }`. */
17
+ export declare function saveConsent(storage: KVStorage, storeId: string | undefined, consent: LegalConsent): void;
@@ -0,0 +1,34 @@
1
+ export function consentStorageKey(storeId) {
2
+ return `tryon-legal-consent:${storeId ?? "default"}`;
3
+ }
4
+ export function loadConsent(storage, storeId) {
5
+ let raw;
6
+ try {
7
+ raw = storage.getItem(consentStorageKey(storeId));
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ if (!raw)
13
+ return null;
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ if (!parsed || typeof parsed !== "object")
22
+ return null;
23
+ const rec = parsed;
24
+ if (typeof rec.version !== "string" || typeof rec.acceptedAt !== "string")
25
+ return null;
26
+ return { version: rec.version, acceptedAt: rec.acceptedAt };
27
+ }
28
+ export function saveConsent(storage, storeId, consent) {
29
+ try {
30
+ storage.setItem(consentStorageKey(storeId), JSON.stringify(consent));
31
+ }
32
+ catch {
33
+ }
34
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * One-call composition root for an integrator holding a publishable key.
3
+ *
4
+ * `new TryOnClient(...)` requires a Transport and a KVStorage, which is right for
5
+ * the storefront plugins (they have an app proxy and their own storage) but is
6
+ * two adapters of busywork for everyone else. This wires the defaults shipped
7
+ * with the core — fetch transport, device storage, auto-minted anonymous id,
8
+ * and the core Tracker over host-free ports — while leaving every port
9
+ * individually overridable for React Native, tests, or a self-hosted API.
10
+ */
11
+ import { TryOnClient } from "./client";
12
+ import type { TryOnClientConfig } from "./client";
13
+ import type { ConsentSource, IntegrationName, KVStorage, NetworkInfo, Transport } from "./ports";
14
+ import type { TrackSink } from "./analytics";
15
+ import type { FetchLike } from "./fetch-transport";
16
+ import type { ScreenSize, TrackingConsentMode } from "./default-tracking";
17
+ export interface CreateTryOnClientOptions extends TryOnClientConfig {
18
+ /** Publishable key (`pk_…`) issued for the store. Required unless a
19
+ * ready-made `transport` is supplied. */
20
+ publishableKey?: string;
21
+ /** API origin. Defaults to the Genlook production API. */
22
+ baseUrl?: string;
23
+ /** Path the public endpoints are mounted under. Defaults to the store API. */
24
+ apiPath?: string;
25
+ /** Known shopper id → `x-genlook-customer-id`. Defaults to
26
+ * `loggedInCustomerId` when that is set. */
27
+ customerId?: string | null;
28
+ /** Known shopper email → `x-genlook-customer-email`. */
29
+ customerEmail?: string | null;
30
+ /** Client build id → `x-genlook-widget-version`. */
31
+ widgetVersion?: string;
32
+ /** Device fingerprint source; read only on the requests that opt in. */
33
+ getFingerprint?: () => string | null | undefined;
34
+ /** Override the host `fetch` (tests, custom agents). */
35
+ fetchImpl?: FetchLike;
36
+ /** Full transport override. Skips the fetch transport entirely (app-proxy
37
+ * hosts, mocks). */
38
+ transport?: Transport;
39
+ /** Storage override. REQUIRED on React Native — pass the resolved
40
+ * `createHydratedStorage(AsyncStorage)`. Defaults to localStorage, falling
41
+ * back to in-memory. */
42
+ storage?: KVStorage;
43
+ /** Pin the anonymous id instead of reading/minting it from storage. */
44
+ anonymousId?: string;
45
+ /** Full tracker override. When set it wins outright and NO default tracker,
46
+ * ports or flush timer are built — the storefront widget keeps its own. */
47
+ tracker?: TrackSink;
48
+ now?: () => number;
49
+ netInfo?: () => NetworkInfo;
50
+ /**
51
+ * Consent posture of the default tracker. `"granted"` (the default) sends the
52
+ * funnel events; `"denied"` cuts the channel entirely — nothing is captured,
53
+ * nothing is sent, and the widget-scope escape hatch stays shut. Pass a live
54
+ * {@link ConsentSource} instead when the host has a real CMP to read.
55
+ */
56
+ tracking?: TrackingConsentMode | ConsentSource;
57
+ /** Which surface the tracking batches name themselves as. Defaults to
58
+ * "tryon_core". */
59
+ integration?: IntegrationName | null;
60
+ /** Version reported alongside {@link integration}. Defaults to this
61
+ * package's semver. */
62
+ integrationVersion?: string | null;
63
+ /** Screen size for the tracking context, when the host knows one. */
64
+ screen?: ScreenSize | (() => ScreenSize | null | undefined);
65
+ /** Periodic flush cadence (ms). Defaults to the core's own interval (5s). */
66
+ flushIntervalMs?: number;
67
+ }
68
+ /**
69
+ * Build a ready-to-use {@link TryOnClient}.
70
+ *
71
+ * ```ts
72
+ * const client = createTryOnClient({ publishableKey: "pk_live_…", storeId });
73
+ * ```
74
+ *
75
+ * React Native — hydrate storage first, since {@link KVStorage} is synchronous:
76
+ * ```ts
77
+ * const storage = await createHydratedStorage(AsyncStorage);
78
+ * const client = createTryOnClient({ publishableKey, storeId, storage });
79
+ * ```
80
+ *
81
+ * Funnel events are tracked by default; pass `tracking: "denied"` to send none.
82
+ * Call `client.dispose()` when the client goes away — it stops the flush timer.
83
+ */
84
+ export declare function createTryOnClient(options: CreateTryOnClientOptions): TryOnClient;