@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
|
@@ -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
|
+
}
|
package/dist/email.d.ts
ADDED
|
@@ -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 {};
|
package/dist/entities.js
ADDED
|
@@ -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>;
|
package/dist/erasure.js
ADDED
|
@@ -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
|
+
}
|