@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.
- package/README.md +180 -0
- package/dist/analytics.d.ts +26 -0
- package/dist/analytics.js +7 -0
- package/dist/anonymous-id.d.ts +30 -0
- package/dist/anonymous-id.js +33 -0
- package/dist/client.d.ts +289 -0
- package/dist/client.js +465 -0
- package/dist/consent.d.ts +17 -0
- package/dist/consent.js +34 -0
- package/dist/create-client.d.ts +84 -0
- package/dist/create-client.js +131 -0
- package/dist/default-tracking.d.ts +64 -0
- package/dist/default-tracking.js +101 -0
- package/dist/email.d.ts +5 -0
- package/dist/email.js +24 -0
- package/dist/entities.d.ts +186 -0
- package/dist/entities.js +47 -0
- package/dist/erasure.d.ts +36 -0
- package/dist/erasure.js +23 -0
- package/dist/events.d.ts +226 -0
- package/dist/events.js +41 -0
- package/dist/fetch-transport.d.ts +65 -0
- package/dist/fetch-transport.js +112 -0
- package/dist/generation.d.ts +132 -0
- package/dist/generation.js +382 -0
- package/dist/history.d.ts +76 -0
- package/dist/history.js +68 -0
- package/dist/memory-storage.d.ts +10 -0
- package/dist/memory-storage.js +12 -0
- package/dist/pending-upload.d.ts +32 -0
- package/dist/pending-upload.js +13 -0
- package/dist/persistence.d.ts +12 -0
- package/dist/persistence.js +101 -0
- package/dist/policy.d.ts +24 -0
- package/dist/policy.js +45 -0
- package/dist/ports.d.ts +187 -0
- package/dist/ports.js +1 -0
- package/dist/public-api.d.ts +235 -0
- package/dist/public-api.js +1 -0
- package/dist/public.d.ts +33 -0
- package/dist/public.js +12 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +70 -0
- package/dist/sharing.d.ts +26 -0
- package/dist/sharing.js +42 -0
- package/dist/storage-adapters.d.ts +69 -0
- package/dist/storage-adapters.js +82 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +23 -0
- package/dist/tracker.d.ts +124 -0
- package/dist/tracker.js +229 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/upload.d.ts +123 -0
- package/dist/upload.js +176 -0
- package/dist/usage.d.ts +45 -0
- package/dist/usage.js +110 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +1 -0
- 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,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
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -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 };
|