@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
@@ -0,0 +1,131 @@
1
+ import { TryOnClient } from "./client";
2
+ import { createFetchTransport } from "./fetch-transport";
3
+ import { defaultStorage } from "./storage-adapters";
4
+ import { getAnonymousId, uuid } from "./anonymous-id";
5
+ import { FLUSH_INTERVAL_MS, Tracker } from "./tracker";
6
+ import { createDefaultPageContext, staticConsent } from "./default-tracking";
7
+ import { fetchSettings, loadSettings, saveSettings } from "./settings";
8
+ import { SDK_VERSION } from "./version";
9
+ export function createTryOnClient(options) {
10
+ const { publishableKey, baseUrl, apiPath, customerId, customerEmail, widgetVersion, getFingerprint, fetchImpl, transport, storage, anonymousId, tracker, now, netInfo, tracking = "granted", integration = "tryon_core", integrationVersion = SDK_VERSION, screen, flushIntervalMs, ...config } = options;
11
+ const resolvedStorage = storage ?? defaultStorage();
12
+ const resolvedAnonymousId = anonymousId ?? getAnonymousId(resolvedStorage);
13
+ let resolvedTransport = transport;
14
+ if (!resolvedTransport) {
15
+ if (!publishableKey) {
16
+ throw new Error("[Genlook] createTryOnClient requires either `publishableKey` or a `transport`.");
17
+ }
18
+ resolvedTransport = createFetchTransport({
19
+ publishableKey,
20
+ baseUrl,
21
+ apiPath,
22
+ anonymousId: resolvedAnonymousId,
23
+ customerId: customerId ?? config.loggedInCustomerId ?? null,
24
+ customerEmail,
25
+ widgetVersion,
26
+ getFingerprint,
27
+ fetchImpl,
28
+ });
29
+ }
30
+ const wiring = tracker
31
+ ? { sink: tracker, stop: undefined }
32
+ : wireDefaultTracker({
33
+ transport: resolvedTransport,
34
+ storage: resolvedStorage,
35
+ tracking,
36
+ integration,
37
+ integrationVersion,
38
+ screen,
39
+ widgetVersion,
40
+ pinnedAnonymousId: anonymousId,
41
+ storeId: config.storeId ?? null,
42
+ flushIntervalMs: flushIntervalMs ?? FLUSH_INTERVAL_MS,
43
+ });
44
+ const clock = now ?? Date.now;
45
+ const storeIdKey = config.storeId ?? "";
46
+ const pinned = pinnedFields(config);
47
+ const cached = loadSettings(resolvedStorage, storeIdKey, clock());
48
+ const client = new TryOnClient({
49
+ config: cached ? withSettings(config, unpinned(cached.settings, pinned)) : config,
50
+ transport: resolvedTransport,
51
+ storage: resolvedStorage,
52
+ tracker: wiring.sink,
53
+ ...(now ? { now } : {}),
54
+ ...(netInfo ? { netInfo } : {}),
55
+ ...(wiring.stop ? { onDispose: wiring.stop } : {}),
56
+ });
57
+ if (!cached?.fresh) {
58
+ fetchSettings(resolvedTransport)
59
+ .then((settings) => {
60
+ if (!settings)
61
+ return;
62
+ saveSettings(resolvedStorage, storeIdKey, settings, clock());
63
+ client.applySettings(unpinned(settings, pinned));
64
+ })
65
+ .catch((error) => {
66
+ console.warn("[Genlook] settings refresh failed:", error);
67
+ });
68
+ }
69
+ return client;
70
+ }
71
+ function pinnedFields(config) {
72
+ return {
73
+ maxGenerations: config.limits?.maxGenerations != null,
74
+ emailCollectionStep: config.limits?.emailCollectionStep != null,
75
+ period: config.limits?.period != null,
76
+ loggedInCustomersOnly: config.loggedInCustomersOnly != null,
77
+ };
78
+ }
79
+ function unpinned(settings, pins) {
80
+ const out = {};
81
+ if (!pins.maxGenerations && settings.maxGenerations !== undefined) {
82
+ out.maxGenerations = settings.maxGenerations;
83
+ }
84
+ if (!pins.emailCollectionStep && settings.emailCollectionStep !== undefined) {
85
+ out.emailCollectionStep = settings.emailCollectionStep;
86
+ }
87
+ if (!pins.period && settings.period !== undefined)
88
+ out.period = settings.period;
89
+ if (!pins.loggedInCustomersOnly && settings.loggedInCustomersOnly !== undefined) {
90
+ out.loggedInCustomersOnly = settings.loggedInCustomersOnly;
91
+ }
92
+ return out;
93
+ }
94
+ function withSettings(config, settings) {
95
+ const { loggedInCustomersOnly, ...limits } = settings;
96
+ if (loggedInCustomersOnly === undefined && Object.keys(limits).length === 0)
97
+ return config;
98
+ return {
99
+ ...config,
100
+ ...(loggedInCustomersOnly !== undefined ? { loggedInCustomersOnly } : {}),
101
+ limits: { ...config.limits, ...limits },
102
+ };
103
+ }
104
+ function wireDefaultTracker(args) {
105
+ const denied = args.tracking === "denied";
106
+ const consent = typeof args.tracking === "string" ? staticConsent(args.tracking) : args.tracking;
107
+ const instance = new Tracker({
108
+ transport: args.transport,
109
+ consent,
110
+ pageContext: createDefaultPageContext({
111
+ storage: args.storage,
112
+ storeId: args.storeId,
113
+ integration: args.integration,
114
+ integrationVersion: args.integrationVersion,
115
+ ...(args.pinnedAnonymousId ? { anonymousId: args.pinnedAnonymousId } : {}),
116
+ ...(args.widgetVersion?.startsWith("wv_") ? { widgetVersion: args.widgetVersion } : {}),
117
+ ...(args.screen ? { screen: args.screen } : {}),
118
+ }),
119
+ uuid,
120
+ });
121
+ const sink = {
122
+ capture: (event, props) => instance.capture(event, props),
123
+ flush: (opts) => instance.flush(opts),
124
+ ...(denied ? {} : { grantWidgetScopeConsent: () => instance.grantWidgetScopeConsent() }),
125
+ };
126
+ const handle = setInterval(() => {
127
+ instance.flush().catch(() => { });
128
+ }, args.flushIntervalMs);
129
+ handle?.unref?.();
130
+ return { sink, stop: () => clearInterval(handle) };
131
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Default tracking ports, so the core {@link Tracker} — the one real pipeline
3
+ * (queue, batching, consent gate, context assembly, `/events` wire format) — can
4
+ * be instantiated without a browser host.
5
+ *
6
+ * There is no second tracker here: this file only supplies the two host-shaped
7
+ * inputs the Tracker asks for on a runtime that has neither a CMP nor a DOM.
8
+ */
9
+ import type { ConsentSource, IntegrationName, KVStorage, PageContext } from "./ports";
10
+ /** Fixed consent posture for the SDK channel. */
11
+ export type TrackingConsentMode = "granted" | "denied";
12
+ /**
13
+ * A {@link ConsentSource} pinned to one state, never changing.
14
+ *
15
+ * Posture on this channel: the integrator embeds the SDK in their OWN product
16
+ * and owns the consent UX there, so the core cannot discover a verdict on its
17
+ * own — and the pipeline it gates is already PiiFree and pseudonymous (an
18
+ * `anon_` device id, no email, no address). `"granted"` is the default;
19
+ * `"denied"` cuts the channel entirely. An integrator with a real CMP passes a
20
+ * live ConsentSource instead.
21
+ */
22
+ export declare function staticConsent(state: TrackingConsentMode): ConsentSource;
23
+ /** Screen size, when the host can report one (React Native `Dimensions`, an
24
+ * Electron display). Read per batch so a rotation is reflected. */
25
+ export type ScreenSize = {
26
+ width: number;
27
+ height: number;
28
+ };
29
+ export interface DefaultPageContextOptions {
30
+ /** Device storage the anonymous id is read from (and purged from on deny). */
31
+ storage: KVStorage;
32
+ /** Pin the anonymous id instead of reading it from storage. Must match the id
33
+ * the transport sends as `x-genlook-anonymous-id`, or try-ons and events land
34
+ * on two different shoppers in the warehouse. */
35
+ anonymousId?: string;
36
+ /** Store the events belong to. */
37
+ storeId?: string | null;
38
+ /** Host platform label. Free-form; defaults to "sdk". */
39
+ platform?: string;
40
+ /** Client build id. BACKEND CONTRACT: must start with `wv_` or the batch is
41
+ * rejected. Defaults to "wv_sdk". */
42
+ widgetVersion?: string;
43
+ /** Which surface this host is. Defaults to "tryon_core"; null sends an
44
+ * unnamed batch. */
45
+ integration?: IntegrationName | null;
46
+ /** Version of that surface. Defaults to this package's semver — the core is
47
+ * what runs here whatever the host calls itself. */
48
+ integrationVersion?: string | null;
49
+ /** Screen size if the host knows one; omitted means "unknown". */
50
+ screen?: ScreenSize | (() => ScreenSize | null | undefined);
51
+ }
52
+ /**
53
+ * A neutral, honest {@link PageContext} for a host with no page.
54
+ *
55
+ * Every browser field reports null rather than a plausible-looking stand-in;
56
+ * only what the runtime genuinely knows is filled in (timezone, the configured
57
+ * platform / store / build id, and the screen when the host supplies one).
58
+ *
59
+ * SESSION: there is one session per client instance, lasting as long as the
60
+ * process. `getIdentityContext` returns an empty `session_id` on purpose and
61
+ * lets the Tracker mint its own `sess_…` fallback once — duplicating that here
62
+ * would be a second source of truth for the same id.
63
+ */
64
+ export declare function createDefaultPageContext(opts: DefaultPageContextOptions): PageContext;
@@ -0,0 +1,101 @@
1
+ import { getAnonymousId, GENLOOK_ANONYMOUS_ID_STORAGE_KEY } from "./anonymous-id";
2
+ import { SDK_VERSION } from "./version";
3
+ const SDK_CONSENT_SOURCE = "sdk-static";
4
+ const GRANTED_SNAPSHOT = {
5
+ source: SDK_CONSENT_SOURCE,
6
+ available: true,
7
+ analytics: "yes",
8
+ marketing: "yes",
9
+ preferences: "yes",
10
+ sale_of_data: "yes",
11
+ analytics_allowed: true,
12
+ marketing_allowed: true,
13
+ preferences_allowed: true,
14
+ sale_of_data_allowed: true,
15
+ should_show_banner: false,
16
+ region: null,
17
+ };
18
+ const DENIED_SNAPSHOT = {
19
+ ...GRANTED_SNAPSHOT,
20
+ analytics: "no",
21
+ marketing: "no",
22
+ preferences: "no",
23
+ sale_of_data: "no",
24
+ analytics_allowed: false,
25
+ marketing_allowed: false,
26
+ preferences_allowed: false,
27
+ sale_of_data_allowed: false,
28
+ };
29
+ export function staticConsent(state) {
30
+ const snapshot = state === "denied" ? DENIED_SNAPSHOT : GRANTED_SNAPSHOT;
31
+ return {
32
+ getSnapshot: () => snapshot,
33
+ onChange: () => { },
34
+ };
35
+ }
36
+ function resolveTimezone() {
37
+ try {
38
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
39
+ }
40
+ catch {
41
+ return "UTC";
42
+ }
43
+ }
44
+ export function createDefaultPageContext(opts) {
45
+ const { storage, anonymousId, storeId = null, platform = "sdk", widgetVersion = "wv_sdk", integration = "tryon_core", integrationVersion = SDK_VERSION, screen, } = opts;
46
+ const readScreen = () => {
47
+ try {
48
+ const value = typeof screen === "function" ? screen() : screen;
49
+ return value ?? null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ };
55
+ return {
56
+ isBot: () => false,
57
+ getPathname: () => null,
58
+ getBaseContext() {
59
+ const size = readScreen();
60
+ return {
61
+ $current_url: null,
62
+ $pathname: null,
63
+ $host: null,
64
+ $screen_width: size?.width ?? 0,
65
+ $screen_height: size?.height ?? 0,
66
+ $viewport_width: null,
67
+ $viewport_height: null,
68
+ $raw_user_agent: null,
69
+ $browser_language: null,
70
+ $timezone: resolveTimezone(),
71
+ widget_enabled: true,
72
+ platform,
73
+ store_id: storeId,
74
+ product_id: null,
75
+ variant_id: null,
76
+ widget_version: widgetVersion,
77
+ app_version: null,
78
+ page_type: null,
79
+ collection_id: null,
80
+ integration,
81
+ integration_version: integrationVersion,
82
+ };
83
+ },
84
+ getIdentityContext() {
85
+ return {
86
+ $referrer: null,
87
+ $referring_domain: null,
88
+ session_id: "",
89
+ anonymous_id: anonymousId ?? getAnonymousId(storage),
90
+ first_touch: null,
91
+ };
92
+ },
93
+ resetSessionId: () => { },
94
+ purgeTrackingStorage: () => {
95
+ try {
96
+ storage.removeItem(GENLOOK_ANONYMOUS_ID_STORAGE_KEY);
97
+ }
98
+ catch { }
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,5 @@
1
+ import type { Transport } from "./ports";
2
+ import type { CollectEmailRequest, CollectEmailResponse } from "./types";
3
+ /** Collect user email and create/update customer record. Errors are logged then
4
+ * rethrown (callers treat it as fire-and-forget). */
5
+ export declare function collectEmail(transport: Transport, request: CollectEmailRequest): Promise<CollectEmailResponse>;
package/dist/email.js ADDED
@@ -0,0 +1,24 @@
1
+ export async function collectEmail(transport, request) {
2
+ try {
3
+ const endpoint = "/shopper/email";
4
+ console.log("Collecting email at:", endpoint);
5
+ const response = await transport.fetch(endpoint, {
6
+ method: "POST",
7
+ headers: { "Content-Type": "application/json" },
8
+ body: JSON.stringify(request),
9
+ });
10
+ if (!response.ok) {
11
+ const errorText = await response.text();
12
+ throw new Error(`Failed to collect email: ${response.status} ${response.statusText}. ${errorText}`);
13
+ }
14
+ const result = (await response.json());
15
+ console.log("Email collected:", result.message);
16
+ return result;
17
+ }
18
+ catch (error) {
19
+ console.error("Failed to collect email:", error);
20
+ if (error instanceof Error)
21
+ throw error;
22
+ throw new Error(`Unexpected error: ${error}`);
23
+ }
24
+ }
@@ -0,0 +1,186 @@
1
+ import type { UploadResult } from "./types";
2
+ import type { TryOnResult } from "./history";
3
+ export type PhotoId = string;
4
+ export type GenId = string;
5
+ export type UploadSource = "gallery" | "mirror";
6
+ /** DOM-read image dimensions, or a sentinel the host passes when it did not read
7
+ * them: "skipped" (HEIC-like — most browsers can't decode it for a preview read)
8
+ * or "unreadable" (the browser image decode failed). The core validates against
9
+ * these; the sentinels short-circuit the dimension check to a skip / reject. */
10
+ export type UploadDimensions = {
11
+ width: number;
12
+ height: number;
13
+ } | "unreadable" | "skipped";
14
+ /** Upload metadata the headless core cannot derive from the opaque MediaInput —
15
+ * the host reads it off the browser File and passes it in for the funnel events
16
+ * and the pre-upload validation. */
17
+ export interface UploadMeta {
18
+ fileSize: number;
19
+ mimeType: string;
20
+ uploadSource: UploadSource;
21
+ skipClientDimensionCheck: boolean;
22
+ /** Whole seconds between the file's `lastModified` and pick time. ~0 means the
23
+ * photo was taken inside the chooser (camera path) — the only observable
24
+ * signal separating "took one just now" from "dug one out of the roll".
25
+ * Optional: hosts without a real File omit it. */
26
+ photoAgeSeconds?: number;
27
+ /** `lastModified` fell inside the OS chooser's open→resolve window — the photo
28
+ * was taken via the chooser's camera path. Host-observed; omit when unknown. */
29
+ capturedInPicker?: boolean;
30
+ /** OS chooser open → file returned, in whole ms. Host-observed; omit when
31
+ * unknown. */
32
+ pickerDurationMs?: number;
33
+ /** The page was backgrounded while the OS chooser was open — on Android the
34
+ * chooser's camera path backgrounds the page; app switches do too.
35
+ * Host-observed; omit when unknown. */
36
+ pickerBackgrounded?: boolean;
37
+ /** CORE-stamped (flushStagedPhoto), never host-set: how long the photo waited
38
+ * at the consent gate before release. Presence = this upload was held. */
39
+ heldDurationMs?: number;
40
+ /** Original file name — used for HEIC-by-extension detection in validation.
41
+ * Optional: an older host that predates core-side validation omits it, and the
42
+ * core degrades the type check to mime-only rather than throwing. */
43
+ fileName?: string;
44
+ /** Host-supplied dimensions (or a sentinel). Optional: absent for an older host,
45
+ * in which case the core skips the dimension check it cannot perform. */
46
+ dimensions?: UploadDimensions;
47
+ }
48
+ /** Photo upload lifecycle. The prepare→PUT→complete chain is synchronous; `ready`
49
+ * carries the cropped result the widget chains generation off. */
50
+ export type PhotoUpload = {
51
+ id: PhotoId;
52
+ status: "uploading";
53
+ progress: number;
54
+ createdAt: number;
55
+ } | {
56
+ id: PhotoId;
57
+ status: "ready";
58
+ result: UploadResult;
59
+ createdAt: number;
60
+ } | {
61
+ id: PhotoId;
62
+ status: "failed";
63
+ error: string;
64
+ createdAt: number;
65
+ };
66
+ export type GenErrorCode = "credits-expired" | "rate-limited" | "server-overloaded" | "upload-failed" | "network";
67
+ export interface GenError {
68
+ code: GenErrorCode;
69
+ retryable: boolean;
70
+ /** Raw backend code (e.g. GENERATION_FAILED, FITTING_ROOM_WEEKLY_LIMIT_EXCEEDED)
71
+ * so the host can preserve its exact error routing. Never rendered directly. */
72
+ backendCode?: string;
73
+ }
74
+ interface GenerationBase {
75
+ id: GenId;
76
+ photoId: PhotoId;
77
+ productId: string;
78
+ variantId?: string;
79
+ createdAt: number;
80
+ /** false until the host surfaces this result to the shopper (acknowledge()). */
81
+ seen: boolean;
82
+ /** true once this job's funnel event has been emitted; persisted so it fires
83
+ * exactly once across refreshes/rehydrations. */
84
+ reported: boolean;
85
+ /** The exact quota-window timestamp (limits.tryOns entry) counted for THIS job
86
+ * when it was accepted, so a terminal-error refund can remove precisely that
87
+ * entry (not merely "the latest"). Persisted so the refund survives a reload.
88
+ * undefined only for a legacy rehydrated job that predates usage tracking — such
89
+ * a job cannot be refunded through its entry. */
90
+ usageAt?: number;
91
+ /** true once this job's counted use has been refunded (it reached a terminal
92
+ * error). Persisted alongside the job so a resume-after-reload that re-drives
93
+ * the same job never double-refunds. */
94
+ usageRefunded?: boolean;
95
+ }
96
+ /** Generation job lifecycle. */
97
+ export type GenerationJob = (GenerationBase & {
98
+ status: "requested";
99
+ }) | (GenerationBase & {
100
+ status: "generating";
101
+ }) | (GenerationBase & {
102
+ status: "done";
103
+ resultImageUrl: string;
104
+ resultImageKey?: string;
105
+ }) | (GenerationBase & {
106
+ status: "error";
107
+ error: GenError;
108
+ });
109
+ export type LimitPeriod = "daily" | "weekly";
110
+ /** Limits config (from widget config) + runtime counters. Policy derives from this. */
111
+ export interface LimitsState {
112
+ maxGenerations: number;
113
+ emailCollectionStep: number;
114
+ period: LimitPeriod;
115
+ loggedInCustomersOnly: boolean;
116
+ /** Legal-consent gate, seeded from `TryOnClientConfig.requireLegalConsent`
117
+ * (default false). When true, `can("upload")` and `can("generate")` block with
118
+ * `consent-required` until `legalConsent` is on record. */
119
+ requireLegalConsent: boolean;
120
+ /** Opt-in concurrency guard: when set to a finite number, blocks a new
121
+ * generation while that many are already in-flight. Unset (the default)
122
+ * disables the gate entirely. */
123
+ maxConcurrent?: number;
124
+ /** Timestamps of recent try-ons (windowed rate limit). Mirrors persisted StoredLimits. */
125
+ tryOns: number[];
126
+ /** Refund tombstones: `tryOns` timestamps refunded on a terminal generation
127
+ * failure. Kept (and persisted) so a merge with another tab's copy can't
128
+ * resurrect a refunded entry. Pruned on the same 7-day window as `tryOns`. */
129
+ refunded: number[];
130
+ /** Lifetime counter (email-collection step). */
131
+ totalTryOns: number;
132
+ /** null = not yet checked. */
133
+ creditsAllowed: boolean | null;
134
+ }
135
+ export interface IdentityState {
136
+ email: string | null;
137
+ loggedInCustomerId: string | null;
138
+ }
139
+ /** Shopper acceptance of the legal-consent gate. `version` is the wording version
140
+ * the host presented (UI-owned constant); `acceptedAt` is an ISO-8601 timestamp.
141
+ * Shape is byte-identical to the pre-headless mobile-sheet persisted record so an
142
+ * already-consented shopper is recognized (see consent.ts STORAGE COMPAT). */
143
+ export interface LegalConsent {
144
+ version: string;
145
+ acceptedAt: string;
146
+ }
147
+ export interface CoreState {
148
+ photos: Record<PhotoId, PhotoUpload>;
149
+ /** The current upload entity id (supersede-latest). Set at entity creation to
150
+ * the newest upload; a superseded (older) upload's later transition never
151
+ * rewrites it. Read `selectCurrentUpload` to render the current upload — a
152
+ * superseded upload's ready/failed state is deliberately not surfaced there.
153
+ * null = no upload registered (or the host cleared the selected photo). */
154
+ latestPhotoId: PhotoId | null;
155
+ generations: Record<GenId, GenerationJob>;
156
+ /** Shopper-facing result list, newest-first. Scripted entries (no backing
157
+ * generations[id] job) are first-class members. */
158
+ history: TryOnResult[];
159
+ limits: LimitsState;
160
+ identity: IdentityState;
161
+ /** Shopper's legal-consent acceptance, loaded from storage at client boot;
162
+ * null = never accepted (a UI may show a consent screen). The policy layer
163
+ * reads it ONLY when `limits.requireLegalConsent` is on (host opt-in); every
164
+ * other host keeps consent as a purely UI-side decision. */
165
+ legalConsent: LegalConsent | null;
166
+ }
167
+ export declare const GENERATION_TERMINAL_STATUSES: readonly ["done", "error"];
168
+ export declare function isGenerationTerminal(job: GenerationJob): boolean;
169
+ /**
170
+ * The current (latest-registered) upload entity, or null. Supersede-latest: a
171
+ * newer upload becomes current; a superseded (older) upload's later transition
172
+ * never rewrites `latestPhotoId`, so its ready/failed state is NOT surfaced here.
173
+ * The promise analog is the client's PendingUpload (what `generate({ userImage:
174
+ * "latest" })` awaits). A white-label host renders upload progress from this +
175
+ * subscribe(), mirroring how it renders generation progress from `generations`.
176
+ */
177
+ export declare function selectCurrentUpload(state: CoreState): PhotoUpload | null;
178
+ /** Completed jobs not yet surfaced to the shopper (completed-while-away results). */
179
+ export declare function selectUnseenResults(state: CoreState): GenerationJob[];
180
+ export declare function isGenerationInFlight(job: GenerationJob): boolean;
181
+ export declare function windowMs(period: LimitPeriod): number;
182
+ /** Count try-on timestamps inside the rate-limit window ending at `now`. */
183
+ export declare function countInWindow(tryOns: number[], period: LimitPeriod, now: number): number;
184
+ export declare function defaultLimitsState(): LimitsState;
185
+ export declare function initialCoreState(): CoreState;
186
+ export {};
@@ -0,0 +1,47 @@
1
+ export const GENERATION_TERMINAL_STATUSES = ["done", "error"];
2
+ export function isGenerationTerminal(job) {
3
+ return job.status === "done" || job.status === "error";
4
+ }
5
+ export function selectCurrentUpload(state) {
6
+ return state.latestPhotoId ? (state.photos[state.latestPhotoId] ?? null) : null;
7
+ }
8
+ export function selectUnseenResults(state) {
9
+ return Object.values(state.generations)
10
+ .filter((j) => j.status === "done" && !j.seen)
11
+ .sort((a, b) => a.createdAt - b.createdAt);
12
+ }
13
+ export function isGenerationInFlight(job) {
14
+ return job.status === "requested" || job.status === "generating";
15
+ }
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ export function windowMs(period) {
18
+ return period === "daily" ? DAY_MS : 7 * DAY_MS;
19
+ }
20
+ export function countInWindow(tryOns, period, now) {
21
+ const cutoff = now - windowMs(period);
22
+ return tryOns.filter((ts) => ts > cutoff).length;
23
+ }
24
+ export function defaultLimitsState() {
25
+ return {
26
+ maxGenerations: 6,
27
+ emailCollectionStep: 1,
28
+ period: "weekly",
29
+ loggedInCustomersOnly: false,
30
+ requireLegalConsent: false,
31
+ tryOns: [],
32
+ refunded: [],
33
+ totalTryOns: 0,
34
+ creditsAllowed: null,
35
+ };
36
+ }
37
+ export function initialCoreState() {
38
+ return {
39
+ photos: {},
40
+ latestPhotoId: null,
41
+ generations: {},
42
+ history: [],
43
+ limits: defaultLimitsState(),
44
+ identity: { email: null, loggedInCustomerId: null },
45
+ legalConsent: null,
46
+ };
47
+ }
@@ -0,0 +1,36 @@
1
+ import type { Transport } from "./ports";
2
+ /**
3
+ * Send the shopper "delete my data" request to the Genlook proxy. It is a DELETE
4
+ * with no body — the proxy injects the shopper-identity headers, so the backend
5
+ * already knows whose data to erase. Throws on a non-2xx response (mirrors
6
+ * createShareLink's error style). Returns nothing: the backend replies
7
+ * `{ success: true }`, which the core never needs to read.
8
+ */
9
+ export declare function deleteMyData(transport: Transport): Promise<void>;
10
+ export interface ErasureEngineDeps {
11
+ transport: Transport;
12
+ /** Clear the shopper-facing history slice (bound to client.clearHistory). */
13
+ clearHistory: () => void;
14
+ /** Drop the tracked/pending upload (bound to client.clearPendingUpload). */
15
+ clearPendingUpload: () => void;
16
+ /** Emit tryon:data_erased — fired ONLY after a successful backend erasure. */
17
+ emitDataErased: () => void;
18
+ }
19
+ /**
20
+ * Drive a shopper "delete my data" request: erase server-side, then wipe the
21
+ * local shopper state.
22
+ *
23
+ * - SUCCESS: the backend erase resolves, `tryon:data_erased` fires, then the
24
+ * local history + pending upload are cleared.
25
+ * - FAILURE: the local history + pending upload are STILL cleared (preserving
26
+ * the pre-wiring UX — the shopper's screen empties either way), but the event
27
+ * does NOT fire and the error is swallowed after logging.
28
+ *
29
+ * Never rejects — same best-effort contract as rateResult. Rationale: erasure is
30
+ * best-effort from the widget's side (the backend also purges on its own
31
+ * schedule), and blocking the shopper on a transient proxy error would strand
32
+ * them on a screen still showing the photos they just asked to delete. The clear
33
+ * runs in `finally` so it happens on both paths; the emit runs only in the try,
34
+ * after a confirmed success.
35
+ */
36
+ export declare function runDataErasure(deps: ErasureEngineDeps): Promise<void>;
@@ -0,0 +1,23 @@
1
+ export async function deleteMyData(transport) {
2
+ const endpoint = "/shopper";
3
+ const response = await transport.fetch(endpoint, {
4
+ method: "DELETE",
5
+ });
6
+ if (!response.ok) {
7
+ const errorText = await response.text();
8
+ throw new Error(`Failed to delete data: ${response.status} ${response.statusText}. ${errorText}`);
9
+ }
10
+ }
11
+ export async function runDataErasure(deps) {
12
+ try {
13
+ await deleteMyData(deps.transport);
14
+ deps.emitDataErased();
15
+ }
16
+ catch (error) {
17
+ console.error("[Genlook] shopper erasure failed:", error);
18
+ }
19
+ finally {
20
+ deps.clearHistory();
21
+ deps.clearPendingUpload();
22
+ }
23
+ }