@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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # @genlook/storefront
2
+
3
+ Headless virtual try-on for your storefront. Upload a shopper's photo, run the try-on, get the image back. You own every pixel of the interface.
4
+
5
+ This is the same engine that powers the [Genlook](https://genlook.app) try-on widget, live on 900+ stores. With it, a custom storefront, a native app, or a server runs the exact flow the widget runs, without the widget.
6
+
7
+ ```javascript
8
+ import { createTryOnClient } from "@genlook/storefront";
9
+
10
+ const genlook = createTryOnClient({
11
+ publishableKey: "pk_your_key_here",
12
+ storeId: "your-store.myshopify.com",
13
+ });
14
+
15
+ const { fileId } = await genlook.uploadPhoto(file, {
16
+ fileSize: file.size,
17
+ mimeType: file.type,
18
+ uploadSource: "gallery",
19
+ skipClientDimensionCheck: false,
20
+ });
21
+
22
+ const { imageUrl } = await genlook.generate({
23
+ userImageId: fileId,
24
+ productId: "gid://shopify/Product/1234567890",
25
+ });
26
+ ```
27
+
28
+ Most try-ons finish in 10 to 20 seconds. `generate` resolves when the image is ready; progress is observable through state if you want a richer loading screen.
29
+
30
+ ## What it does, and what it deliberately does not
31
+
32
+ The client handles everything that has a wrong way to do it: the photo upload, the polling, the shopper's identity and quota, try-on history, sharing, ratings, and the analytics that feed the merchant dashboard.
33
+
34
+ It renders nothing. No modal, no button, no styles. State in, actions out; the interface is yours.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ npm install @genlook/storefront
40
+ ```
41
+
42
+ You need a **publishable key**. In your Shopify admin, open the Genlook Try-On app, go to Settings, and create one under **Publishable key**. It is safe to embed in your code: it can only do what a shopper on your storefront could already do.
43
+
44
+ Works today for stores on Shopify and SHOPLINE. WooCommerce and PrestaShop stores route through their site's Genlook plugin instead; see the [integration docs](https://genlook.app/docs/virtual-tryon/direct-api-access).
45
+
46
+ ## React Native
47
+
48
+ The client keeps state in a synchronous store. Hand it an async store once at startup and everything else is identical to the web:
49
+
50
+ ```javascript
51
+ import AsyncStorage from "@react-native-async-storage/async-storage";
52
+ import { createHydratedStorage, createTryOnClient } from "@genlook/storefront";
53
+
54
+ const storage = await createHydratedStorage(AsyncStorage);
55
+ const genlook = createTryOnClient({ publishableKey, storeId, storage });
56
+ ```
57
+
58
+ Await the hydration once before the first try-on. On app background, call `genlook.flushEvents({ beacon: true })` so the last analytics batch ships.
59
+
60
+ ## State and events
61
+
62
+ Two complementary ways to drive your UI:
63
+
64
+ ```javascript
65
+ // Re-render from the current state after every change:
66
+ const unsubscribe = genlook.subscribe(() => {
67
+ render(genlook.getState());
68
+ });
69
+
70
+ // React to the moment something happens:
71
+ genlook.on("tryon:generation_succeeded", (e) => {
72
+ showResult(e);
73
+ });
74
+ genlook.on("tryon:photo_upload_failed", (e) => {
75
+ showRetry(e);
76
+ });
77
+ ```
78
+
79
+ `subscribe` is for rendering, `on` is for reacting. Events are not replayed: a listener attached after the fact does not see the past.
80
+
81
+ ## Ask before you act
82
+
83
+ `can(action)` evaluates whether an action would be allowed right now, without performing it:
84
+
85
+ ```javascript
86
+ const verdict = genlook.can("generate");
87
+ if (!verdict.ok) {
88
+ // verdict.blocked is one of:
89
+ // "quota-exceeded" | "login-required" | "consent-required"
90
+ // | "email-required" | "credits-expired" | "concurrency"
91
+ showBlockedState(verdict.blocked);
92
+ }
93
+ ```
94
+
95
+ Use it to disable your button before the shopper taps it, rather than surfacing an error after.
96
+
97
+ ## Quotas and the email prompt
98
+
99
+ The merchant sets a per-shopper try-on quota in their Genlook app settings. The server enforces it, and the client stays in sync with those settings automatically, so `can("generate")` reflects them without any work on your side. You never enforce the quota yourself.
100
+
101
+ To override a setting for your integration, pass it in `limits`; anything you leave out follows the store:
102
+
103
+ ```javascript
104
+ createTryOnClient({
105
+ publishableKey,
106
+ storeId,
107
+ limits: { emailCollectionStep: 99 }, // optional overrides
108
+ });
109
+ ```
110
+
111
+ About that email step: the widget asks shoppers for an email after a set number of try-ons, and the client mirrors this. When `can("generate")` returns `email-required`, show your own email form and call `collectEmail({ email })`, or override `emailCollectionStep` as above if your app collects emails elsewhere. The API itself never refuses a try-on for a missing email.
112
+
113
+ ## Consent, and the optimistic upload
114
+
115
+ By default the client enforces no consent gate: you own your user experience, including any consent step your situation requires. If you show one, let the client enforce it so it cannot be bypassed:
116
+
117
+ ```javascript
118
+ const genlook = createTryOnClient({
119
+ publishableKey,
120
+ storeId,
121
+ requireLegalConsent: true,
122
+ });
123
+
124
+ // The photo is HELD IN MEMORY. Not one byte leaves the device yet.
125
+ genlook.stagePhoto(file, meta);
126
+
127
+ // The moment consent is recorded, the held photo uploads on its own.
128
+ genlook.acceptLegalConsent("2026-01");
129
+ ```
130
+
131
+ `stagePhoto` is the pattern worth copying even without a gate: the photo is ready to go the instant the shopper confirms, instead of the upload starting after the tap, at the exact moment they are waiting.
132
+
133
+ ## Error handling
134
+
135
+ Every rejecting method throws one of three errors. Match on `err.name`, which survives bundling; `instanceof` may not:
136
+
137
+ ```javascript
138
+ try {
139
+ await genlook.generate({ userImage: "latest", productId });
140
+ } catch (err) {
141
+ if (err.name === "PolicyBlockedError") {
142
+ // err.reason: the same values can("generate") returns
143
+ } else if (err.name === "GenerationFailedError") {
144
+ // err.code: the wire code (QUOTA_EXCEEDED, PRODUCT_BLOCKED, ...)
145
+ // err.kind: a routing category (quota, rate_limited, ...) for your UI
146
+ } else if (err.name === "UploadRejectedError") {
147
+ // err.reason: "invalid_image_type" | "file_too_large" | ...
148
+ }
149
+ }
150
+ ```
151
+
152
+ ## Analytics and privacy
153
+
154
+ The client reports the try-on funnel to the merchant dashboard. No personal data is collected: pseudonymous ids only, and outside a browser no URL, no user agent, no device details.
155
+
156
+ ```javascript
157
+ // On by default. One option turns it off entirely:
158
+ createTryOnClient({ publishableKey, storeId, tracking: "denied" });
159
+ ```
160
+
161
+ Shoppers can erase their data: `deleteMyData()` clears the server side for this device and wipes local history.
162
+
163
+ ## API overview
164
+
165
+ | | |
166
+ | --- | --- |
167
+ | Try-on | `uploadPhoto`, `stagePhoto`, `generate`, `can` |
168
+ | State | `getState`, `subscribe`, `on`, `onAny` |
169
+ | History | `getHistory`, `recentResults`, `rateResult`, `getShareUrl`, `clearHistory` |
170
+ | Store status | `checkCredits`, `getCurrentPlan` |
171
+ | Shopper | `collectEmail`, `acceptLegalConsent`, `deleteMyData` |
172
+ | Lifecycle | `flushEvents`, `dispose` |
173
+
174
+ Every default is replaceable: bring your own `Transport`, `KVStorage`, or `TrackSink` when the environment calls for it. `memoryStorage()` ships for tests.
175
+
176
+ ## Documentation
177
+
178
+ Full guides, the HTTP API the client speaks, and platform specifics: [genlook.app/docs](https://genlook.app/docs/virtual-tryon/direct-api-access).
179
+
180
+ Questions or a platform we should support next: [support@genlook.app](mailto:support@genlook.app).
@@ -0,0 +1,26 @@
1
+ import type { CoreEvents } from "./events";
2
+ /** Funnel-event sink. The boot passes the core Tracker directly (`sdk.tracking`),
3
+ * whose `capture` method satisfies this shape — no host closure needed. */
4
+ export interface TrackSink {
5
+ capture(event: string, props?: Record<string, unknown>): void;
6
+ /** Optional widget-scope consent grant (the core Tracker implements it; the
7
+ * no-op test sink does not). Called when the shopper accepts the widget's
8
+ * legal-consent gate so widget:/tryon:/sheet:/api: events keep flowing even
9
+ * when site analytics consent is denied. */
10
+ grantWidgetScopeConsent?(): void;
11
+ /** Optional queue drain to `/events` (the core Tracker implements it; the
12
+ * no-op test sink does not). `beacon` requests unload semantics. Surfaced on
13
+ * the client as `flushEvents`, mirroring how a browser host drives the
14
+ * tracker's flush from its own page-hide listeners. */
15
+ flush?(opts?: {
16
+ beacon?: boolean;
17
+ }): Promise<void>;
18
+ }
19
+ /**
20
+ * Subscribe the analytics sink to the core event bus. Returns an unsubscribe
21
+ * function. Called once, by the client constructor.
22
+ *
23
+ * `CoreEvents.emit` guards listeners, so a tracking failure can never break a
24
+ * try-on.
25
+ */
26
+ export declare function attachAnalytics(events: CoreEvents, sink: TrackSink): () => void;
@@ -0,0 +1,7 @@
1
+ export function attachAnalytics(events, sink) {
2
+ const capture = sink.capture.bind(sink);
3
+ return events.onAny((e) => {
4
+ const { type, ...props } = e;
5
+ capture(type, props);
6
+ });
7
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The widget-issued anonymous device id, owned by the core.
3
+ *
4
+ * It lives behind the {@link KVStorage} port, so a browser host, a native host
5
+ * (AsyncStorage) and a test all get the same id semantics.
6
+ *
7
+ * WIRE + STORAGE CONTRACT — do not change: deployed widgets already hold ids
8
+ * under this exact key in this exact format. A different key or prefix silently
9
+ * re-anonymises every returning shopper (lost photo history, reset quota) and
10
+ * breaks the backend's `anon_` guard in `readAnonymousId`.
11
+ */
12
+ import type { KVStorage } from "./ports";
13
+ /** Storage key for the anonymous device id. NOT namespaced (`genlook-…`) — the
14
+ * pre-core widget wrote it raw, and the key has to stay byte-identical. Also
15
+ * exported so a host can purge exactly this key on a consent deny. */
16
+ export declare const GENLOOK_ANONYMOUS_ID_STORAGE_KEY = "_genlook_anonymous_id";
17
+ /** Id prefix. The backend rejects any anonymous id that lacks it. */
18
+ export declare const GENLOOK_ANONYMOUS_ID_PREFIX = "anon_";
19
+ /**
20
+ * RFC 4122 v4 UUID. Own implementation because the core carries zero
21
+ * dependencies. `crypto.randomUUID` first, `getRandomValues` second, never
22
+ * `Math.random()` — its weak entropy produced real collisions in the event
23
+ * stream (the same id across devices).
24
+ */
25
+ export declare function uuid(): string;
26
+ /**
27
+ * Read the persisted anonymous id, minting + persisting one on first call.
28
+ * Stable for the lifetime of the storage it is given.
29
+ */
30
+ export declare function getAnonymousId(storage: KVStorage): string;
@@ -0,0 +1,33 @@
1
+ export const GENLOOK_ANONYMOUS_ID_STORAGE_KEY = "_genlook_anonymous_id";
2
+ export const GENLOOK_ANONYMOUS_ID_PREFIX = "anon_";
3
+ function getCrypto() {
4
+ return globalThis.crypto;
5
+ }
6
+ export function uuid() {
7
+ const c = getCrypto();
8
+ if (c && typeof c.randomUUID === "function")
9
+ return c.randomUUID();
10
+ if (c && typeof c.getRandomValues === "function") {
11
+ const bytes = new Uint8Array(16);
12
+ c.getRandomValues(bytes);
13
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
14
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
15
+ const hex = [];
16
+ for (let i = 0; i < 16; i++)
17
+ hex.push((bytes[i] + 0x100).toString(16).slice(1));
18
+ return (hex[0] + hex[1] + hex[2] + hex[3] +
19
+ "-" + hex[4] + hex[5] +
20
+ "-" + hex[6] + hex[7] +
21
+ "-" + hex[8] + hex[9] +
22
+ "-" + hex[10] + hex[11] + hex[12] + hex[13] + hex[14] + hex[15]);
23
+ }
24
+ throw new Error("[Genlook] No crypto source available to mint an anonymous id.");
25
+ }
26
+ export function getAnonymousId(storage) {
27
+ let id = storage.getItem(GENLOOK_ANONYMOUS_ID_STORAGE_KEY);
28
+ if (!id) {
29
+ id = `${GENLOOK_ANONYMOUS_ID_PREFIX}${uuid()}`;
30
+ storage.setItem(GENLOOK_ANONYMOUS_ID_STORAGE_KEY, id);
31
+ }
32
+ return id;
33
+ }
@@ -0,0 +1,289 @@
1
+ import type { Transport, KVStorage, MediaInput, NetworkInfo } from "./ports";
2
+ import type { CurrentPlanResponse, CheckCreditsResponse, CollectEmailRequest, CollectEmailResponse, ShareGenerationResponse, UploadResult, PrepareUploadRequest, RatingReasonCode } from "./types";
3
+ import type { CoreState, GenerationJob, UploadMeta } from "./entities";
4
+ import type { Listener } from "./store";
5
+ import type { PolicyAction, PolicyResult } from "./policy";
6
+ import { PolicyBlockedError } from "./policy";
7
+ import type { TrackSink } from "./analytics";
8
+ import type { CoreEvent, CoreEventListener, CoreEventType } from "./events";
9
+ import type { GetShareUrlOptions } from "./sharing";
10
+ import type { StoreSettings } from "./settings";
11
+ import type { TryOnResult, GenerationContext } from "./history";
12
+ import type { GenlookTryOn } from "./public-api";
13
+ /** Parsed widget config surfaced to the core. Limits/identity feed the policy layer. */
14
+ export interface TryOnClientConfig {
15
+ storeId?: string;
16
+ locale?: string;
17
+ loggedInCustomersOnly?: boolean;
18
+ loggedInCustomerId?: string | null;
19
+ /** Block upload + generate with `consent-required` until `acceptLegalConsent`
20
+ * is recorded. Default false — only a host that presents a consent gate
21
+ * should ask the core to enforce one. */
22
+ requireLegalConsent?: boolean;
23
+ limits?: {
24
+ maxGenerations?: number | null;
25
+ emailCollectionStep?: number | null;
26
+ period?: "daily" | "weekly" | null;
27
+ maxConcurrent?: number;
28
+ };
29
+ }
30
+ export interface TryOnClientDeps {
31
+ config: TryOnClientConfig;
32
+ transport: Transport;
33
+ storage: KVStorage;
34
+ /** Core tracker the funnel events fire through. Defaults to a no-op sink. */
35
+ tracker?: TrackSink;
36
+ /** Injectable clock (tests). Defaults to Date.now. */
37
+ now?: () => number;
38
+ /** Host connectivity probe, sampled when a failure is recorded. Optional — a
39
+ * host that cannot answer simply omits the fields. */
40
+ netInfo?: () => NetworkInfo;
41
+ /** Teardown hook run by {@link TryOnClient.dispose}. The composition root
42
+ * registers whatever it started (the periodic flush timer) so nothing keeps
43
+ * ticking after the client is dropped — a leak in tests and in any host that
44
+ * builds more than one client. */
45
+ onDispose?: () => void;
46
+ }
47
+ export interface GenerateInput {
48
+ /** Explicit uploaded-photo file id. */
49
+ userImageId?: string;
50
+ /** "latest": await the client's in-flight upload (if any) and use its fileId;
51
+ * falls back to `userImageId` when no upload is pending. */
52
+ userImage?: "latest";
53
+ productId: string;
54
+ variantId?: string;
55
+ /** When provided, the core assembles the full TryOnResult on success and
56
+ * appends it to history (returned as `result`). */
57
+ context?: GenerationContext;
58
+ }
59
+ export interface GenerateOutput {
60
+ imageUrl: string;
61
+ generationId: string;
62
+ /** Present when `context` was provided: the history entry the core appended. */
63
+ result?: TryOnResult;
64
+ }
65
+ /**
66
+ * Headless try-on domain client. Owns the entity stores (photos / generations /
67
+ * limits / identity), the policy layer, persistence, and funnel-event emission.
68
+ * Constructed on the boot side and reachable at window.Genlook.tryon; the UI
69
+ * bundle subscribes to the store and calls the ops.
70
+ */
71
+ export declare class TryOnClient implements GenlookTryOn {
72
+ private readonly config;
73
+ private readonly transport;
74
+ private readonly storage;
75
+ private readonly track;
76
+ private readonly now;
77
+ private readonly netInfo?;
78
+ private readonly onDispose?;
79
+ private disposed;
80
+ private readonly store;
81
+ private creditsCheckCache;
82
+ private readonly inFlight;
83
+ /** The client's LATEST in-flight upload (supersede-latest). generate({ userImage:
84
+ * "latest" }) awaits the newest registered upload; a superseded upload still
85
+ * settles for its own awaiter but is no longer current. The store analog is
86
+ * `latestPhotoId` / selectCurrentUpload. Kept after settle so a click racing
87
+ * upload completion still resolves the freshest fileId; cleared when the host
88
+ * discards the photo. */
89
+ private readonly pending;
90
+ /** A photo picked while legal consent was still owed. Held in memory ONLY — the
91
+ * bytes never touch the network from here; `acceptLegalConsent` is the sole
92
+ * release. See {@link stagePhoto}. */
93
+ private staged;
94
+ /** Lifecycle bus. Distinct from `subscribe()` (argless, level-triggered) and
95
+ * from the analytics sink (whose event names are an analytics contract) —
96
+ * see events.ts. */
97
+ private readonly events;
98
+ constructor(deps: TryOnClientDeps);
99
+ getState(): CoreState;
100
+ subscribe(listener: Listener): () => void;
101
+ /**
102
+ * Drain the tracker's queued events to `/events` now. The periodic timer does
103
+ * this on its own; call it when the host knows better — app backgrounded,
104
+ * screen closed, process about to exit — with `{ beacon: true }` for unload
105
+ * semantics. Same shape a browser host's page-hide listeners use.
106
+ *
107
+ * Never rejects; resolves immediately when the sink cannot flush (a host-owned
108
+ * tracker, or the no-op default).
109
+ */
110
+ flushEvents(opts?: {
111
+ beacon?: boolean;
112
+ }): Promise<void>;
113
+ /**
114
+ * Release what the composition root started for this client — today, the
115
+ * periodic flush timer. Idempotent. Queued events are flushed first (best
116
+ * effort) so a disposed client doesn't silently drop its last batch.
117
+ */
118
+ dispose(): void;
119
+ private buildInitialState;
120
+ /**
121
+ * Merge merchant-owned settings into live state. The SDK factory calls this
122
+ * when the store's `GET /settings` response lands, having already dropped the
123
+ * fields the integrator pinned. The client itself never fetches: the widget
124
+ * gets the same values inline in its config, and this is the only way in.
125
+ *
126
+ * Identity-stable when nothing moved, so a refresh that changes nothing
127
+ * notifies no subscriber. The policy layer is a pure function of state, so
128
+ * `can()` follows on its own.
129
+ */
130
+ applySettings(settings: Partial<StoreSettings>): void;
131
+ /** Set shopper identity. Private — the only remaining caller is collectEmail
132
+ * (the UI no longer writes identity through; the core owns it). The email
133
+ * transition is persisted by the usage store subscriber. */
134
+ private setIdentity;
135
+ can(action: PolicyAction): PolicyResult;
136
+ /** In-memory usage as the persisted shape. */
137
+ private usageSnapshot;
138
+ /**
139
+ * Pull in usage another core instance (second tab, bfcache-restored page) has
140
+ * persisted since this one last looked, merging it into state. Called right
141
+ * before the quota check in generate() — the enforcement point that must see
142
+ * everyone's consumption, not just this instance's.
143
+ */
144
+ private syncUsageFromStorage;
145
+ /** Record a generation against the rolling quota window + lifetime counter.
146
+ * The usage store subscriber persists it. Push-then-prune matches the legacy
147
+ * recordGeneration()+save() byte-for-byte. Returns the recorded window timestamp
148
+ * so the caller can stamp it on the generation and refund exactly this entry on
149
+ * a terminal error. The timestamp is bumped past any equal entry already in the
150
+ * window OR the tombstones: it doubles as the refund identity, the merge dedupes
151
+ * equal values, and an old tombstone must not kill a fresh entry. */
152
+ private recordUsage;
153
+ /** Record shopper acceptance of the legal-consent gate at `version`: persist the
154
+ * storage-compatible `{ version, acceptedAt }` record, reflect it into
155
+ * state.legalConsent (subscribers notified), and emit the V2-only
156
+ * `tryon:legal_consent_accepted`. Re-accepting the SAME version is a no-op (no
157
+ * duplicate event, no rewrite); a NEW version overwrites and emits again.
158
+ * Under `config.requireLegalConsent` this is what unblocks the policy layer:
159
+ * until it runs, `can("upload")` and `can("generate")` return
160
+ * `consent-required`. Hosts that did not opt in are unaffected — for them
161
+ * whether to show a consent screen stays a UI decision. */
162
+ acceptLegalConsent(version: string): void;
163
+ unseenResults(): GenerationJob[];
164
+ acknowledge(generationId: string): void;
165
+ /** Connectivity fields for a failure row, or nothing when the host supplies no
166
+ * probe. Never throws — a broken host probe must not break the upload's own
167
+ * error path. */
168
+ private netFields;
169
+ private uploadDeps;
170
+ private generationDeps;
171
+ /** Upload media (photo entity uploading→ready/failed + upload funnel events).
172
+ * Registers the promise as the client's LATEST in-flight upload (supersede-
173
+ * latest) so generate({ userImage: "latest" }) awaits the newest one. Upload
174
+ * progress is observable from state (selectCurrentUpload) — the entity
175
+ * transitions through the store, mirroring generation. PendingUpload.register
176
+ * swallows the unhandled rejection of a superseded/failed upload; the caller
177
+ * still receives the rejected promise.
178
+ *
179
+ * Also the enforcement point for the upload side of the policy layer (the login
180
+ * gate and, where the host opted in, the legal-consent gate): a blocked upload
181
+ * is refused before any network call. Rejects instead of throwing synchronously
182
+ * so a fire-and-forget caller can't blow up, and pre-catches so a voided call is
183
+ * never an unhandled rejection — the awaiting caller still gets it from the
184
+ * promise it was handed. */
185
+ uploadPhoto(file: MediaInput, meta: UploadMeta, productContext?: PrepareUploadRequest): Promise<UploadResult>;
186
+ /**
187
+ * Subscribe to a core lifecycle event. Returns an unsubscribe function.
188
+ *
189
+ * Use this instead of diffing `getState()` inside a `subscribe()` callback when
190
+ * what you care about is a transition ("the upload just failed") rather than a
191
+ * value ("the upload is failed"). Events are edges and are not replayed; state
192
+ * is the level and is always readable from `getState()`.
193
+ */
194
+ on<T extends CoreEventType>(type: T, listener: CoreEventListener<T>): () => void;
195
+ /** Subscribe to every core event, current and future. Returns an unsubscribe
196
+ * function. For consumers generic over the event set (logging, bridging, dev
197
+ * tooling) — see `CoreEvents.onAny`. */
198
+ onAny(listener: (event: CoreEvent) => void): () => void;
199
+ /**
200
+ * Stage a freshly picked photo: upload it now if legal consent is already on
201
+ * record, otherwise hold the bytes in memory until `acceptLegalConsent` releases
202
+ * them. That makes "nothing leaves the device before consent" an invariant of
203
+ * the core rather than a discipline each UI keeps.
204
+ *
205
+ * Uploading at pick time hides most of the wait: shoppers spend a median 2.5s on
206
+ * the confirm step against a median 4.5s upload, and a server-side rejection
207
+ * surfaces while the photo is still on screen.
208
+ *
209
+ * Returns nothing — read progress from `selectCurrentUpload(getState())`, and
210
+ * let `generate({ userImage: "latest" })` await the result. Supersede-latest
211
+ * applies to held photos too.
212
+ *
213
+ * Never throws, in either mode: while consent is outstanding the photo is held
214
+ * rather than refused, so the optimistic path stays available — the bytes are
215
+ * ready to leave the instant the shopper accepts instead of the upload starting
216
+ * only then. `flushStagedPhoto` is the single release and runs after the consent
217
+ * record exists, so the released upload passes `can("upload")`.
218
+ *
219
+ * Quota is NOT pre-checked: `can("generate")` is still evaluated by generate(),
220
+ * so a shopper who turns out to be over quota uploads for nothing. Cheap next to
221
+ * a generation, and pre-checking would skip the early upload for the
222
+ * `email-required` case — where it pays off most.
223
+ */
224
+ stagePhoto(file: MediaInput, meta: UploadMeta, productContext?: PrepareUploadRequest): void;
225
+ /** Start the upload for a photo staged before consent. No-op when nothing is
226
+ * held. Called from `acceptLegalConsent` — the single release point. Stamps
227
+ * `heldDurationMs` (→ held_duration_ms) so a consent-released upload is
228
+ * distinguishable from an immediate one, and the gate's latency cost is a
229
+ * field rather than a session join. */
230
+ private flushStagedPhoto;
231
+ /** Drop the tracked upload (host discarded the selected photo): clears the
232
+ * pending-upload pointer AND the current-upload state pointer (latestPhotoId),
233
+ * so selectCurrentUpload reads null. Photo entities are left in the store. */
234
+ clearPendingUpload(): void;
235
+ /** Resolve the user image for generate(): prefer the in-flight upload over the
236
+ * (possibly stale) host-provided fileId. All failure paths are typed
237
+ * upload_failed so the host routes back to preview. */
238
+ private resolveUserImageId;
239
+ /**
240
+ * Create + drive a generation to completion (generation entity + generation_start
241
+ * event). Awaits the in-flight upload when asked for the "latest" image, is
242
+ * deduped within a TTL and guarded by the policy layer. Rejects with
243
+ * GenerationFailedError (name+kind/code readable across the bundle boundary)
244
+ * or PolicyBlockedError when blocked. With `context`, appends the assembled
245
+ * TryOnResult to history on success.
246
+ */
247
+ generate(input: GenerateInput): Promise<GenerateOutput>;
248
+ getHistory(): TryOnResult[];
249
+ appendResult(entry: TryOnResult): void;
250
+ updateResult(id: string, patch: Partial<TryOnResult>): void;
251
+ /** Wholesale replace of the history slice (the store subscriber persists it,
252
+ * subscribers are notified). Used by the staged preview's history seeding. */
253
+ replaceHistory(entries: TryOnResult[]): void;
254
+ clearHistory(): void;
255
+ /**
256
+ * Shopper "delete my data": erase the shopper's data server-side, then wipe the
257
+ * local shopper state (history + the tracked pending upload). Fires
258
+ * `tryon:data_erased` ONLY on a confirmed backend erasure; on failure the local
259
+ * state is still cleared (the screen empties either way) and no event fires.
260
+ * Never rejects — mirrors rateResult's best-effort contract, so the UI can
261
+ * `await` it and navigate away unconditionally.
262
+ */
263
+ deleteMyData(): Promise<void>;
264
+ /** Read-side retention view — stored history is never pruned. */
265
+ recentResults(retentionDays?: number | null): TryOnResult[];
266
+ /** Share URL for a history entry: short-circuits on `entry.shareUrl`, else
267
+ * creates a link and writes it back into the entry (write-through cache).
268
+ * DOM-derived inputs (effective domain, product URL) are passed in by the UI. */
269
+ getShareUrl(entryId: string, opts?: GetShareUrlOptions): Promise<string>;
270
+ /** Rate a history entry: rating/feedbackReason write-through, the
271
+ * widget:result_rated / widget:result_rating_reason events (BQ contract),
272
+ * and the backend call. Never rejects. */
273
+ rateResult(entryId: string, rating: 1 | -1 | 0, reason?: RatingReasonCode | null): Promise<void>;
274
+ getCurrentPlan(): Promise<CurrentPlanResponse>;
275
+ checkCredits(): Promise<CheckCreditsResponse>;
276
+ rateGeneration(generationId: string, rating: 1 | -1 | 0, reason?: RatingReasonCode | null): Promise<{
277
+ rating: number | null;
278
+ ratedAt: string | null;
279
+ ratingReason: string | null;
280
+ }>;
281
+ /**
282
+ * Collect email: fire the email_collected funnel event from the identity
283
+ * transition (unconditional), set identity, and send the backend
284
+ * shopper/email call.
285
+ */
286
+ collectEmail(request: CollectEmailRequest): Promise<CollectEmailResponse>;
287
+ createShareLink(generationId: string, effectiveDomain?: string, productUrl?: string): Promise<ShareGenerationResponse>;
288
+ }
289
+ export { PolicyBlockedError };