@genlook/storefront 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +180 -0
  2. package/dist/analytics.d.ts +26 -0
  3. package/dist/analytics.js +7 -0
  4. package/dist/anonymous-id.d.ts +30 -0
  5. package/dist/anonymous-id.js +33 -0
  6. package/dist/client.d.ts +289 -0
  7. package/dist/client.js +465 -0
  8. package/dist/consent.d.ts +17 -0
  9. package/dist/consent.js +34 -0
  10. package/dist/create-client.d.ts +84 -0
  11. package/dist/create-client.js +131 -0
  12. package/dist/default-tracking.d.ts +64 -0
  13. package/dist/default-tracking.js +101 -0
  14. package/dist/email.d.ts +5 -0
  15. package/dist/email.js +24 -0
  16. package/dist/entities.d.ts +186 -0
  17. package/dist/entities.js +47 -0
  18. package/dist/erasure.d.ts +36 -0
  19. package/dist/erasure.js +23 -0
  20. package/dist/events.d.ts +226 -0
  21. package/dist/events.js +41 -0
  22. package/dist/fetch-transport.d.ts +65 -0
  23. package/dist/fetch-transport.js +112 -0
  24. package/dist/generation.d.ts +132 -0
  25. package/dist/generation.js +382 -0
  26. package/dist/history.d.ts +76 -0
  27. package/dist/history.js +68 -0
  28. package/dist/memory-storage.d.ts +10 -0
  29. package/dist/memory-storage.js +12 -0
  30. package/dist/pending-upload.d.ts +32 -0
  31. package/dist/pending-upload.js +13 -0
  32. package/dist/persistence.d.ts +12 -0
  33. package/dist/persistence.js +101 -0
  34. package/dist/policy.d.ts +24 -0
  35. package/dist/policy.js +45 -0
  36. package/dist/ports.d.ts +187 -0
  37. package/dist/ports.js +1 -0
  38. package/dist/public-api.d.ts +235 -0
  39. package/dist/public-api.js +1 -0
  40. package/dist/public.d.ts +33 -0
  41. package/dist/public.js +12 -0
  42. package/dist/settings.d.ts +38 -0
  43. package/dist/settings.js +70 -0
  44. package/dist/sharing.d.ts +26 -0
  45. package/dist/sharing.js +42 -0
  46. package/dist/storage-adapters.d.ts +69 -0
  47. package/dist/storage-adapters.js +82 -0
  48. package/dist/store.d.ts +9 -0
  49. package/dist/store.js +23 -0
  50. package/dist/tracker.d.ts +124 -0
  51. package/dist/tracker.js +229 -0
  52. package/dist/types.d.ts +91 -0
  53. package/dist/types.js +1 -0
  54. package/dist/upload.d.ts +123 -0
  55. package/dist/upload.js +176 -0
  56. package/dist/usage.d.ts +45 -0
  57. package/dist/usage.js +110 -0
  58. package/dist/version.d.ts +10 -0
  59. package/dist/version.js +1 -0
  60. package/package.json +30 -0
@@ -0,0 +1,229 @@
1
+ export const FLUSH_INTERVAL_MS = 5000;
2
+ function isFetchErrorResponse(v) {
3
+ return (!!v &&
4
+ typeof v.status === "number" &&
5
+ typeof v.text === "function");
6
+ }
7
+ export class Tracker {
8
+ queue = [];
9
+ pageviewId;
10
+ lastPathname;
11
+ isFlushing = false;
12
+ isBot;
13
+ consentSnapshot = null;
14
+ consentSubscribed = false;
15
+ uiVariant = null;
16
+ uiBuild = null;
17
+ widgetScopeConsent = false;
18
+ fallbackAnonymousId = null;
19
+ fallbackSessionId = null;
20
+ transport;
21
+ consent;
22
+ pageContext;
23
+ uuid;
24
+ now;
25
+ static MAX_QUEUE_SIZE = 100;
26
+ static EARLY_FLUSH_SIZE = 20;
27
+ constructor(deps) {
28
+ this.transport = deps.transport;
29
+ this.consent = deps.consent;
30
+ this.pageContext = deps.pageContext;
31
+ this.uuid = deps.uuid;
32
+ this.now = deps.now ?? Date.now;
33
+ this.pageviewId = this.uuid();
34
+ this.lastPathname = this.pageContext.getPathname();
35
+ this.isBot = this.pageContext.isBot();
36
+ }
37
+ setUiVariant(variant) {
38
+ this.uiVariant = variant;
39
+ }
40
+ setUiBuild(build) {
41
+ this.uiBuild = build;
42
+ }
43
+ static WIDGET_SCOPE_PREFIXES = ["widget:", "tryon:", "sheet:", "api:"];
44
+ isTrackingAllowed() {
45
+ this.ensureConsentSubscribed();
46
+ return this.consentSnapshot?.analytics_allowed !== false;
47
+ }
48
+ grantWidgetScopeConsent() {
49
+ this.widgetScopeConsent = true;
50
+ }
51
+ isEventAllowed(event) {
52
+ if (this.isTrackingAllowed())
53
+ return true;
54
+ if (!this.widgetScopeConsent)
55
+ return false;
56
+ return Tracker.WIDGET_SCOPE_PREFIXES.some((p) => event.startsWith(p));
57
+ }
58
+ capture(event, properties) {
59
+ if (this.isBot)
60
+ return;
61
+ if (!this.isEventAllowed(event))
62
+ return;
63
+ try {
64
+ this.enqueue({
65
+ event,
66
+ properties: properties || {},
67
+ timestamp: new Date(this.now()).toISOString(),
68
+ $insert_id: this.uuid(),
69
+ });
70
+ }
71
+ catch (error) {
72
+ console.error("[Genlook] Failed to capture event:", error);
73
+ }
74
+ }
75
+ async captureFetchError(url, method, error) {
76
+ try {
77
+ if (url.includes("/tracking/event"))
78
+ return;
79
+ const props = {
80
+ error_url: url,
81
+ error_method: method,
82
+ };
83
+ if (isFetchErrorResponse(error)) {
84
+ props.error_status = error.status;
85
+ props.error_message = `${method} ${url} failed with status ${error.status}`;
86
+ props.error_response_body = (await error.text()).slice(0, 10000);
87
+ const headers = {};
88
+ error.headers.forEach((v, k) => { headers[k] = v; });
89
+ props.error_response_headers = headers;
90
+ }
91
+ else if (error instanceof Error) {
92
+ props.error_message = error.message;
93
+ }
94
+ this.capture("api:error", props);
95
+ }
96
+ catch (err) {
97
+ console.error("[Genlook] Failed to capture fetch error:", err);
98
+ }
99
+ }
100
+ async flush(opts) {
101
+ if (this.isBot) {
102
+ this.queue = [];
103
+ return;
104
+ }
105
+ if (this.queue.length === 0)
106
+ return;
107
+ const beacon = opts?.beacon === true;
108
+ if (!beacon && this.isFlushing)
109
+ return;
110
+ if (!beacon)
111
+ this.isFlushing = true;
112
+ const context = this.buildContext();
113
+ const events = [...this.queue];
114
+ this.queue = [];
115
+ try {
116
+ const body = JSON.stringify({ events, context });
117
+ await this.transport.fetch("/events", {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body,
121
+ beacon,
122
+ });
123
+ }
124
+ catch (error) {
125
+ this.queue = events.concat(this.queue).slice(-Tracker.MAX_QUEUE_SIZE);
126
+ console.error("[Genlook] Flush failed:", error);
127
+ }
128
+ finally {
129
+ if (!beacon)
130
+ this.isFlushing = false;
131
+ }
132
+ }
133
+ enqueue(event) {
134
+ if (this.queue.length >= Tracker.MAX_QUEUE_SIZE)
135
+ this.queue.shift();
136
+ this.queue.push(event);
137
+ if (this.queue.length >= Tracker.EARLY_FLUSH_SIZE)
138
+ this.flush().catch(() => { });
139
+ }
140
+ ensureConsentSubscribed() {
141
+ if (this.consentSubscribed)
142
+ return;
143
+ this.consentSubscribed = true;
144
+ try {
145
+ this.consentSnapshot = this.consent.getSnapshot();
146
+ this.consent.onChange((snapshot) => {
147
+ this.consentSnapshot = snapshot;
148
+ this.handleConsentChange(snapshot);
149
+ });
150
+ }
151
+ catch { }
152
+ }
153
+ handleConsentChange(snapshot) {
154
+ if (snapshot?.analytics !== "no")
155
+ return;
156
+ this.pageContext.purgeTrackingStorage();
157
+ this.pageContext.resetSessionId();
158
+ }
159
+ buildContext() {
160
+ const base = this.pageContext.getBaseContext();
161
+ if (base.$pathname !== this.lastPathname) {
162
+ this.pageviewId = this.uuid();
163
+ this.lastPathname = base.$pathname;
164
+ }
165
+ const context = {
166
+ $current_url: base.$current_url,
167
+ $pathname: base.$pathname,
168
+ $host: base.$host,
169
+ $screen_width: base.$screen_width,
170
+ $screen_height: base.$screen_height,
171
+ $viewport_width: base.$viewport_width,
172
+ $viewport_height: base.$viewport_height,
173
+ $raw_user_agent: base.$raw_user_agent,
174
+ $browser_language: base.$browser_language,
175
+ $timezone: base.$timezone,
176
+ $pageview_id: this.pageviewId,
177
+ widget_enabled: base.widget_enabled,
178
+ platform: base.platform,
179
+ store_id: base.store_id,
180
+ product_id: base.product_id,
181
+ variant_id: base.variant_id,
182
+ widget_version: base.widget_version,
183
+ app_version: base.app_version ?? null,
184
+ page_type: base.page_type,
185
+ collection_id: base.collection_id,
186
+ integration: base.integration ?? null,
187
+ integration_version: base.integration_version ?? null,
188
+ consent: this.consentSnapshot,
189
+ ui_variant: this.uiVariant,
190
+ ui_version: this.uiBuild,
191
+ };
192
+ const id = this.readIdentityContext();
193
+ context.$referrer = id.$referrer;
194
+ context.$referring_domain = id.$referring_domain;
195
+ context.session_id = id.session_id;
196
+ context.anonymous_id = id.anonymous_id;
197
+ context.first_touch = id.first_touch;
198
+ return context;
199
+ }
200
+ readIdentityContext() {
201
+ try {
202
+ const id = this.pageContext.getIdentityContext();
203
+ return {
204
+ ...id,
205
+ anonymous_id: id.anonymous_id || this.getFallbackAnonymousId(),
206
+ session_id: id.session_id || this.getFallbackSessionId(),
207
+ };
208
+ }
209
+ catch {
210
+ return {
211
+ $referrer: null,
212
+ $referring_domain: null,
213
+ session_id: this.getFallbackSessionId(),
214
+ anonymous_id: this.getFallbackAnonymousId(),
215
+ first_touch: null,
216
+ };
217
+ }
218
+ }
219
+ getFallbackAnonymousId() {
220
+ if (this.fallbackAnonymousId === null)
221
+ this.fallbackAnonymousId = `anon_${this.uuid()}`;
222
+ return this.fallbackAnonymousId;
223
+ }
224
+ getFallbackSessionId() {
225
+ if (this.fallbackSessionId === null)
226
+ this.fallbackSessionId = `sess_${this.uuid()}`;
227
+ return this.fallbackSessionId;
228
+ }
229
+ }
@@ -0,0 +1,91 @@
1
+ export interface FittingRoomRequest {
2
+ userImageId: string;
3
+ /** Shopify Admin GraphQL product GID (normalised from storefront numeric id at send time). */
4
+ productId: string;
5
+ /** Shopify Admin GraphQL variant GID (optional; normalised at send time). */
6
+ variantId?: string;
7
+ }
8
+ export interface FittingRoomResponse {
9
+ jobId: string;
10
+ message: string;
11
+ code?: "BILLING_NOT_ALLOWED" | "QUOTA_EXCEEDED" | "RATE_LIMIT_EXCEEDED" | "CREATION_FAILED";
12
+ }
13
+ export interface GenerationStatusResponse {
14
+ generationId: string;
15
+ status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED";
16
+ resultImageKey?: string;
17
+ resultImageUrl?: string;
18
+ errorMessage?: string;
19
+ errorCode?: string;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ }
23
+ export interface CurrentPlanResponse {
24
+ plan: "free" | "starter" | "growth" | "pro" | "enterprise";
25
+ }
26
+ export interface CheckCreditsResponse {
27
+ allowed: boolean;
28
+ }
29
+ export interface CollectEmailRequest {
30
+ email: string;
31
+ emailCollectionStep: number;
32
+ marketingConsent?: boolean;
33
+ checkboxDisplayed?: boolean;
34
+ /** Version tag of the consent copy the shopper agreed to (GDPR evidence). */
35
+ marketingConsentWording?: string;
36
+ }
37
+ export interface CollectEmailResponse {
38
+ success: boolean;
39
+ message: string;
40
+ }
41
+ export interface ShareGenerationResponse {
42
+ shareToken: string;
43
+ shareUrl: string;
44
+ expiresAt: string;
45
+ }
46
+ export interface UploadResult {
47
+ fileId: string;
48
+ fileUrl: string;
49
+ imageWidth?: number;
50
+ imageHeight?: number;
51
+ }
52
+ export interface PrepareUploadRequest {
53
+ productId?: string;
54
+ variantId?: string;
55
+ }
56
+ export interface PrepareUploadResponse {
57
+ uploadUrl: string;
58
+ uploadKey: string;
59
+ }
60
+ export interface UploadCompleteResponse {
61
+ fileId: string;
62
+ fileUrl: string;
63
+ message: string;
64
+ }
65
+ /** Closed set of reason codes paired with a thumbs-down rating. Mirrors the
66
+ * backend's reason-code enum so downstream analytics stay clean. */
67
+ export type RatingReasonCode = "distorted_body" | "wrong_fit" | "face_changed" | "wrong_colors" | "something_else";
68
+ /**
69
+ * Standardized consent-state snapshot attached to every tracking batch, and the
70
+ * source of the tracker's binary consent gate. Platform-neutral by design: `source`
71
+ * names the origin ("platform-default", "shopify", ...), `available` says
72
+ * whether a real CMP answered, the raw `analytics`/`marketing`/`preferences`/
73
+ * `sale_of_data` strings carry the CMP's own vocabulary ('yes'/'no'/''), and the
74
+ * `_allowed` booleans carry a resolved verdict. Any field a platform can't
75
+ * resolve is null. Canonical home is here (headless core); a host runtime may
76
+ * re-export it so platform adapters can import it from there.
77
+ */
78
+ export interface ConsentSnapshot {
79
+ source: string;
80
+ available: boolean;
81
+ analytics: string | null;
82
+ marketing: string | null;
83
+ preferences: string | null;
84
+ sale_of_data: string | null;
85
+ analytics_allowed: boolean | null;
86
+ marketing_allowed: boolean | null;
87
+ preferences_allowed: boolean | null;
88
+ sale_of_data_allowed: boolean | null;
89
+ should_show_banner: boolean | null;
90
+ region: string | null;
91
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,123 @@
1
+ import type { Transport, MediaInput } from "./ports";
2
+ import type { PrepareUploadRequest, UploadResult } from "./types";
3
+ import type { Store } from "./store";
4
+ import type { CoreState, UploadMeta, UploadDimensions } from "./entities";
5
+ /** Max accepted upload size (bytes). */
6
+ export declare const MAX_UPLOAD_BYTES: number;
7
+ /** Accepted image MIME types (HEIC-by-extension is accepted separately). */
8
+ export declare const ALLOWED_UPLOAD_MIME_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
9
+ /** Minimum accepted client-read dimensions (below → invalid_dimensions). */
10
+ export declare const MIN_UPLOAD_WIDTH = 500;
11
+ export declare const MIN_UPLOAD_HEIGHT = 625;
12
+ /** True for a HEIC/HEIF file: recognised MIME set OR a .heic/.heif extension.
13
+ * Call it with `mimeType ← file.type` and `fileName ← file.name`; fileName is
14
+ * optional, and when absent only the MIME set is consulted. */
15
+ export declare function isHeicLike(mimeType: string, fileName?: string): boolean;
16
+ /** Closed set of rejection reasons. Pre-upload verdicts + the post-crop verdict
17
+ * classified from the backend's structured code. The UI switches on this to pick
18
+ * its localized copy; the rejection event carries it as `rejectionReason`. */
19
+ export type UploadRejectionReason = "invalid_image_type" | "file_too_large" | "invalid_dimensions" | "invalid_dimensions_post_crop";
20
+ /** Pure-validation input. `fileName`/`dimensions` are optional — an older host
21
+ * that predates core-side validation omits them and the core skips the checks it
22
+ * cannot perform (backward tolerant) rather than throwing. */
23
+ export interface UploadValidationInput {
24
+ fileSize: number;
25
+ mimeType: string;
26
+ fileName?: string;
27
+ dimensions?: UploadDimensions;
28
+ }
29
+ /** Pre-upload verdict. `extra` carries the per-reason event metadata the host used
30
+ * to attach inline (allowedTypes / maxFileSize / dimension fields), preserved
31
+ * byte-for-byte so the rejection event payload is unchanged. */
32
+ export type UploadVerdict = {
33
+ ok: true;
34
+ } | {
35
+ ok: false;
36
+ reason: UploadRejectionReason;
37
+ extra: Record<string, unknown>;
38
+ };
39
+ /**
40
+ * Pre-upload validation. Decision order and reasons:
41
+ * 1. invalid_image_type — non-allowed MIME AND not HEIC-like.
42
+ * 2. file_too_large — over MAX_UPLOAD_BYTES.
43
+ * 3. invalid_dimensions — "unreadable", or width/height below the minimums.
44
+ * The dimension check is skipped when `dimensions` is "skipped" (HEIC-like) or
45
+ * absent (older host). Pure — never throws; the caller emits + throws.
46
+ */
47
+ export declare function validateUpload(input: UploadValidationInput): UploadVerdict;
48
+ /**
49
+ * CROSS-BUNDLE CONTRACT: recognised by `error.name === "UploadRejectedError"` +
50
+ * the `reason` field read off the object — never `instanceof` (which would not
51
+ * match a separately bundled copy of this class), same pattern as
52
+ * GenerationFailedError. Carries the per-reason event `extra` so the host never
53
+ * recomputes it. Colocated here (the module that throws it) so this file keeps NO
54
+ * runtime sibling value import and loads under the strip-only `node --test`.
55
+ */
56
+ export declare class UploadRejectedError extends Error {
57
+ readonly reason: UploadRejectionReason;
58
+ readonly extra: Record<string, unknown>;
59
+ constructor(reason: UploadRejectionReason, message: string, extra?: Record<string, unknown>);
60
+ }
61
+ /**
62
+ * Transport-level HTTP failure from a backend endpoint (/uploads,
63
+ * /uploads/:key/complete) that answers with the structured `{ code, message, status }`
64
+ * ApiErrorBody. Carries the parsed `code` next to the flattened `message`.
65
+ * CROSS-BUNDLE CONTRACT: consumers (classifyUploadFailure via runUpload) MUST
66
+ * recognise the structured code by the DUCK-TYPED `code` field read off the
67
+ * object — never `instanceof` (a separately bundled transport may throw its own
68
+ * object carrying a `code` field, which would not match this class). `code` is
69
+ * undefined when the error body did not JSON-parse to an object with a string
70
+ * `code`. NOT exported from src/index.ts — the duck-typed field is the contract.
71
+ */
72
+ export declare class UploadHttpError extends Error {
73
+ readonly code?: string;
74
+ readonly status: number;
75
+ constructor(message: string, status: number, code?: string);
76
+ }
77
+ /**
78
+ * Post-crop rejection classifier. The backend rejects a low-resolution-after-crop
79
+ * upload from /uploads/:key/complete with a structured `code: "INVALID_IMAGE"` in the
80
+ * response body (ApiException → HttpException, whose object body is passed through
81
+ * unchanged by the global BaseExceptionFilter). `uploadPhoto` parses that JSON
82
+ * body and surfaces the `code` on the thrown UploadHttpError, so the structured
83
+ * `code` is now the PRIMARY signal: `code === "INVALID_IMAGE"` → post-crop
84
+ * rejection. A non-matching (foreign) code falls THROUGH to the message check
85
+ * rather than returning null — an older host may pass garbage in `code`. The
86
+ * message-token match ("INVALID_IMAGE" token OR the legacy `resolution is too
87
+ * low` prose) survives ONLY as a fallback for errors produced by older bundled
88
+ * transports / cached boot bundles that still flatten the structured body into
89
+ * the message without surfacing `code`.
90
+ */
91
+ export declare function classifyUploadFailure(message: string, code?: string): UploadRejectionReason | null;
92
+ /**
93
+ * Upload user media via the 3-step signed-URL flow:
94
+ * 1. POST /uploads → GCS signed write URL + uploadKey
95
+ * 2. PUT raw bytes to GCS → direct upload, NO Genlook identity headers
96
+ * 3. POST /uploads/:key/complete → backend crops and returns the final fileId + fileUrl
97
+ *
98
+ * The chain is synchronous and step 3 returns the cropped image — the widget
99
+ * chains generation immediately, so this must NOT be offloaded to a queue.
100
+ */
101
+ export declare function uploadPhoto(transport: Transport, file: MediaInput, productContext?: PrepareUploadRequest): Promise<UploadResult>;
102
+ export interface UploadEngineDeps {
103
+ transport: Transport;
104
+ store: Store<CoreState>;
105
+ now: () => number;
106
+ emitImageUpload: (meta: UploadMeta) => void;
107
+ emitImageUploadSuccess: (meta: UploadMeta, fileId: string, uploadDurationMs: number) => void;
108
+ emitImageUploadError: (meta: UploadMeta, error: string) => void;
109
+ /** Emits the image_upload_rejected pair (bound to analytics by the client). One
110
+ * emission point for pre-upload verdicts and the post-crop classification. */
111
+ emitImageUploadRejected: (meta: UploadMeta, reason: UploadRejectionReason, extra: Record<string, unknown>) => void;
112
+ }
113
+ /**
114
+ * Drive a photo through uploading→ready/failed while firing the upload funnel
115
+ * events from the transitions. Runs pre-upload validation from the meta BEFORE any
116
+ * network call: on a verdict it emits the image_upload_rejected pair and throws a
117
+ * typed UploadRejectedError (no photo entity, no upload_started event). The
118
+ * prepare→PUT→complete chain stays synchronous and `ready` carries the cropped
119
+ * result. On failure it classifies a post-crop rejection (structured
120
+ * INVALID_IMAGE) → image_upload_rejected + typed error; any other failure fires
121
+ * image_upload_error and rethrows, so the host's error routing is unchanged.
122
+ */
123
+ export declare function runUpload(deps: UploadEngineDeps, file: MediaInput, meta: UploadMeta, productContext?: PrepareUploadRequest): Promise<UploadResult>;
package/dist/upload.js ADDED
@@ -0,0 +1,176 @@
1
+ export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
2
+ export const ALLOWED_UPLOAD_MIME_TYPES = [
3
+ "image/jpeg",
4
+ "image/png",
5
+ "image/webp",
6
+ "image/heic",
7
+ "image/heif",
8
+ ];
9
+ export const MIN_UPLOAD_WIDTH = 500;
10
+ export const MIN_UPLOAD_HEIGHT = 625;
11
+ const HEIC_MIME_TYPES = new Set(["image/heic", "image/heif"]);
12
+ const HEIC_EXTENSION_RE = /\.(heic|heif)$/i;
13
+ export function isHeicLike(mimeType, fileName) {
14
+ if (HEIC_MIME_TYPES.has(mimeType))
15
+ return true;
16
+ return fileName ? HEIC_EXTENSION_RE.test(fileName) : false;
17
+ }
18
+ export function validateUpload(input) {
19
+ const { fileSize, mimeType, fileName, dimensions } = input;
20
+ const allowed = ALLOWED_UPLOAD_MIME_TYPES.includes(mimeType);
21
+ if (!allowed && !isHeicLike(mimeType, fileName)) {
22
+ return {
23
+ ok: false,
24
+ reason: "invalid_image_type",
25
+ extra: { allowedTypes: [...ALLOWED_UPLOAD_MIME_TYPES] },
26
+ };
27
+ }
28
+ if (fileSize > MAX_UPLOAD_BYTES) {
29
+ return { ok: false, reason: "file_too_large", extra: { maxFileSize: MAX_UPLOAD_BYTES } };
30
+ }
31
+ if (dimensions && dimensions !== "skipped") {
32
+ if (dimensions === "unreadable") {
33
+ return {
34
+ ok: false,
35
+ reason: "invalid_dimensions",
36
+ extra: {
37
+ reason: "dimension_read_failed",
38
+ minWidth: MIN_UPLOAD_WIDTH,
39
+ minHeight: MIN_UPLOAD_HEIGHT,
40
+ },
41
+ };
42
+ }
43
+ if (dimensions.width < MIN_UPLOAD_WIDTH || dimensions.height < MIN_UPLOAD_HEIGHT) {
44
+ return {
45
+ ok: false,
46
+ reason: "invalid_dimensions",
47
+ extra: {
48
+ width: dimensions.width,
49
+ height: dimensions.height,
50
+ minWidth: MIN_UPLOAD_WIDTH,
51
+ minHeight: MIN_UPLOAD_HEIGHT,
52
+ },
53
+ };
54
+ }
55
+ }
56
+ return { ok: true };
57
+ }
58
+ export class UploadRejectedError extends Error {
59
+ reason;
60
+ extra;
61
+ constructor(reason, message, extra = {}) {
62
+ super(message);
63
+ this.name = "UploadRejectedError";
64
+ this.reason = reason;
65
+ this.extra = extra;
66
+ }
67
+ }
68
+ export class UploadHttpError extends Error {
69
+ code;
70
+ status;
71
+ constructor(message, status, code) {
72
+ super(message);
73
+ this.name = "UploadHttpError";
74
+ this.status = status;
75
+ this.code = code;
76
+ }
77
+ }
78
+ function uploadHttpError(label, status, statusText, text) {
79
+ let code;
80
+ try {
81
+ const parsed = JSON.parse(text);
82
+ if (parsed && typeof parsed === "object" && typeof parsed.code === "string") {
83
+ code = parsed.code;
84
+ }
85
+ }
86
+ catch {
87
+ }
88
+ return new UploadHttpError(`${label}: ${status} ${statusText}. ${text}`, status, code);
89
+ }
90
+ export function classifyUploadFailure(message, code) {
91
+ if (code === "INVALID_IMAGE") {
92
+ return "invalid_dimensions_post_crop";
93
+ }
94
+ if (message.includes("INVALID_IMAGE") || message.includes("resolution is too low")) {
95
+ return "invalid_dimensions_post_crop";
96
+ }
97
+ return null;
98
+ }
99
+ export async function uploadPhoto(transport, file, productContext) {
100
+ const prepareRes = await transport.fetch("/uploads", {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify(productContext ?? {}),
104
+ });
105
+ if (!prepareRes.ok) {
106
+ const text = await prepareRes.text();
107
+ throw uploadHttpError("Prepare upload failed", prepareRes.status, prepareRes.statusText, text);
108
+ }
109
+ const { uploadUrl, uploadKey } = (await prepareRes.json());
110
+ console.log("Prepared upload, key:", uploadKey);
111
+ const putRes = await transport.putRaw(uploadUrl, file, { "Content-Type": "application/octet-stream" });
112
+ if (!putRes.ok) {
113
+ const text = await putRes.text();
114
+ throw new Error(`Direct upload to storage failed: ${putRes.status} ${putRes.statusText}. ${text}`);
115
+ }
116
+ console.log("Direct upload complete for key:", uploadKey);
117
+ const completeRes = await transport.fetch(`/uploads/${encodeURIComponent(uploadKey)}/complete`, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: "{}",
121
+ includeFingerprint: true,
122
+ });
123
+ if (!completeRes.ok) {
124
+ const text = await completeRes.text();
125
+ throw uploadHttpError("Upload complete failed", completeRes.status, completeRes.statusText, text);
126
+ }
127
+ const result = (await completeRes.json());
128
+ console.log("Upload processed:", result);
129
+ return { fileId: result.fileId, fileUrl: result.fileUrl };
130
+ }
131
+ function setPhoto(store, photo) {
132
+ store.setState((s) => ({ ...s, photos: { ...s.photos, [photo.id]: photo } }));
133
+ }
134
+ function beginUpload(store, photo) {
135
+ store.setState((s) => ({
136
+ ...s,
137
+ photos: { ...s.photos, [photo.id]: photo },
138
+ latestPhotoId: photo.id,
139
+ }));
140
+ }
141
+ export async function runUpload(deps, file, meta, productContext) {
142
+ const verdict = validateUpload({
143
+ fileSize: meta.fileSize,
144
+ mimeType: meta.mimeType,
145
+ fileName: meta.fileName,
146
+ dimensions: meta.dimensions,
147
+ });
148
+ if (!verdict.ok) {
149
+ deps.emitImageUploadRejected(meta, verdict.reason, verdict.extra);
150
+ throw new UploadRejectedError(verdict.reason, `Upload rejected: ${verdict.reason}`, verdict.extra);
151
+ }
152
+ const id = `p${deps.now()}-${Math.floor(Math.random() * 1e9)}`;
153
+ const createdAt = deps.now();
154
+ beginUpload(deps.store, { id, status: "uploading", progress: 0, createdAt });
155
+ deps.emitImageUpload(meta);
156
+ const start = deps.now();
157
+ try {
158
+ const result = await uploadPhoto(deps.transport, file, productContext);
159
+ setPhoto(deps.store, { id, status: "ready", result, createdAt });
160
+ deps.emitImageUploadSuccess(meta, result.fileId, deps.now() - start);
161
+ return result;
162
+ }
163
+ catch (err) {
164
+ const message = err instanceof Error ? err.message : "Unknown error";
165
+ setPhoto(deps.store, { id, status: "failed", error: message, createdAt });
166
+ const code = err && typeof err.code === "string" ? err.code : undefined;
167
+ const postCropReason = classifyUploadFailure(message, code);
168
+ if (postCropReason) {
169
+ const extra = { error: message.substring(0, 500) };
170
+ deps.emitImageUploadRejected(meta, postCropReason, extra);
171
+ throw new UploadRejectedError(postCropReason, message, extra);
172
+ }
173
+ deps.emitImageUploadError(meta, message);
174
+ throw err;
175
+ }
176
+ }
@@ -0,0 +1,45 @@
1
+ import type { KVStorage } from "./ports";
2
+ /** Persisted usage snapshot. `tryOns` are recent try-on timestamps (rolling-
3
+ * window rate limit); `refunded` are tombstones for entries rolled back on a
4
+ * terminal generation failure; `totalTryOns` is the lifetime counter (email-
5
+ * collection step); `email` is the collected shopper email (or null). */
6
+ export interface StoredUsage {
7
+ tryOns: number[];
8
+ refunded: number[];
9
+ totalTryOns: number;
10
+ email: string | null;
11
+ }
12
+ /**
13
+ * Legacy StoredLimits key earlier widget versions wrote through
14
+ * `window.Genlook.storage` (getJSON/setJSON). The KVStorage port applies the
15
+ * same `genlook-` prefix getJSON did, so passing this logical key to the port
16
+ * reaches the exact same localStorage slot those versions used.
17
+ */
18
+ export declare const LEGACY_LIMITS_KEY = "genlook-widget-user-limits";
19
+ /** Drop try-on timestamps older than the 7-day retention (strict `>`), matching
20
+ * the legacy prune. */
21
+ export declare function pruneTryOns(tryOns: number[], now: number): number[];
22
+ export declare function saveUsage(storage: KVStorage, storeId: string, usage: StoredUsage, now: number): void;
23
+ /**
24
+ * Merge a persisted usage blob with the in-memory snapshot of a live core
25
+ * instance. Two instances of the core (a second tab, a bfcache-restored page)
26
+ * each hold their own snapshot; a blind write from the stale one rolls the
27
+ * counter back and hands the shopper free try-ons. The merge is monotonic:
28
+ * counted timestamps union, refund tombstones union and win over `tryOns` (so a
29
+ * stale copy that still holds a refunded entry can't resurrect it), and the
30
+ * lifetime counter takes the max of the two GROSS counts (stored total + own
31
+ * tombstones), minus the merged tombstones — a plain max on the stored total
32
+ * would make a refund's decrement unmergeable, the pre-refund copy always
33
+ * winning and the failed generation still counting toward the email step.
34
+ * `persisted === null` = nothing to merge.
35
+ */
36
+ export declare function mergeUsage(persisted: StoredUsage | null, current: StoredUsage, now: number): StoredUsage;
37
+ /** Load persisted usage from the new key. null when absent/malformed. */
38
+ export declare function loadUsage(storage: KVStorage, storeId: string, now: number): StoredUsage | null;
39
+ /**
40
+ * One-time migration source: read the legacy StoredLimits blob and normalize it
41
+ * to StoredUsage. Handles the array format and the old generationCount format,
42
+ * exactly as the legacy normalizeStored did. Returns null only when the legacy
43
+ * key is absent/malformed. Never deletes the legacy key.
44
+ */
45
+ export declare function migrateLegacyUsage(storage: KVStorage, now: number): StoredUsage | null;