@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,382 @@
|
|
|
1
|
+
export function classifyErrorKind(code, message) {
|
|
2
|
+
switch (code) {
|
|
3
|
+
case "QUOTA_EXCEEDED":
|
|
4
|
+
case "BILLING_NOT_ALLOWED":
|
|
5
|
+
return "quota";
|
|
6
|
+
case "FITTING_ROOM_WEEKLY_LIMIT_EXCEEDED":
|
|
7
|
+
return "weekly_limit";
|
|
8
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
9
|
+
return "rate_limited";
|
|
10
|
+
case "CREATION_FAILED":
|
|
11
|
+
return "creation_failed";
|
|
12
|
+
case "UPLOAD_FAILED":
|
|
13
|
+
return "upload_failed";
|
|
14
|
+
}
|
|
15
|
+
const quotaRelated = message.includes("quota exceeded") || message.includes("exceeded your current quota");
|
|
16
|
+
if (!quotaRelated &&
|
|
17
|
+
(message.includes("out of capacity") ||
|
|
18
|
+
(message.includes("RESOURCE_EXHAUSTED") && message.includes("429")))) {
|
|
19
|
+
return "overloaded";
|
|
20
|
+
}
|
|
21
|
+
return "failed";
|
|
22
|
+
}
|
|
23
|
+
export class GenerationFailedError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
kind;
|
|
26
|
+
constructor(code, message, kind) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "GenerationFailedError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.kind = kind ?? classifyErrorKind(code, message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function createFittingRoomJob(transport, request) {
|
|
34
|
+
try {
|
|
35
|
+
const endpoint = "/try-ons";
|
|
36
|
+
console.log("Creating generation job at:", endpoint);
|
|
37
|
+
const response = await transport.fetch(endpoint, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify(request),
|
|
41
|
+
includeFingerprint: true,
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
if (response.status === 429) {
|
|
45
|
+
let errorCode = "RATE_LIMIT_EXCEEDED";
|
|
46
|
+
try {
|
|
47
|
+
const body = (await response.json());
|
|
48
|
+
if (body?.code === "FITTING_ROOM_WEEKLY_LIMIT_EXCEEDED") {
|
|
49
|
+
errorCode = "FITTING_ROOM_WEEKLY_LIMIT_EXCEEDED";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
}
|
|
54
|
+
throw new GenerationFailedError(errorCode, "Too many requests");
|
|
55
|
+
}
|
|
56
|
+
const errorText = await response.text();
|
|
57
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText}. ${errorText}`);
|
|
58
|
+
}
|
|
59
|
+
const result = (await response.json());
|
|
60
|
+
console.log("Generation job created:", result.jobId);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof Error)
|
|
65
|
+
throw error;
|
|
66
|
+
throw new Error(`Unexpected error: ${error}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function getGenerationStatus(transport, jobId) {
|
|
70
|
+
try {
|
|
71
|
+
const endpoint = `/try-ons/${encodeURIComponent(jobId)}`;
|
|
72
|
+
console.log("Checking generation status at:", endpoint);
|
|
73
|
+
const response = await transport.fetch(endpoint, { method: "GET" });
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorText = await response.text();
|
|
76
|
+
throw new Error(`Status check failed: ${response.status} ${response.statusText}. ${errorText}`);
|
|
77
|
+
}
|
|
78
|
+
const result = (await response.json());
|
|
79
|
+
console.log("Generation status:", result.status);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof Error)
|
|
84
|
+
throw error;
|
|
85
|
+
throw new Error(`Unexpected error: ${error}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function pollGenerationStatus(transport, jobId, onStatusChange, pollInterval = 1000, maxAttempts = 60) {
|
|
89
|
+
let attempts = 0;
|
|
90
|
+
while (attempts < maxAttempts) {
|
|
91
|
+
const status = await getGenerationStatus(transport, jobId);
|
|
92
|
+
if (onStatusChange)
|
|
93
|
+
onStatusChange(status);
|
|
94
|
+
if (status.status === "COMPLETED")
|
|
95
|
+
return status;
|
|
96
|
+
if (status.status === "FAILED") {
|
|
97
|
+
throw new GenerationFailedError(status.errorCode || "GENERATION_FAILED", status.errorMessage || "Generation failed");
|
|
98
|
+
}
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
100
|
+
attempts++;
|
|
101
|
+
}
|
|
102
|
+
throw new GenerationFailedError("GENERATION_FAILED", "Generation timeout - please try again");
|
|
103
|
+
}
|
|
104
|
+
export async function getCurrentPlan(transport) {
|
|
105
|
+
try {
|
|
106
|
+
const endpoint = "/plan";
|
|
107
|
+
console.log("Fetching current plan at:", endpoint);
|
|
108
|
+
const response = await transport.fetch(endpoint, { method: "GET" });
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const errorText = await response.text();
|
|
111
|
+
throw new Error(`Failed to get current plan: ${response.status} ${response.statusText}. ${errorText}`);
|
|
112
|
+
}
|
|
113
|
+
const result = (await response.json());
|
|
114
|
+
console.log("Current plan:", result.plan);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof Error)
|
|
119
|
+
throw error;
|
|
120
|
+
throw new Error(`Unexpected error: ${error}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function checkCredits(transport) {
|
|
124
|
+
const endpoint = "/availability";
|
|
125
|
+
console.log("Checking credits at:", endpoint);
|
|
126
|
+
const response = await transport.fetch(endpoint, { method: "GET" });
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
const errorText = await response.text();
|
|
129
|
+
throw new Error(`Failed to check credits: ${response.status} ${response.statusText}. ${errorText}`);
|
|
130
|
+
}
|
|
131
|
+
const result = (await response.json());
|
|
132
|
+
console.log("Credits check result:", result.allowed ? "allowed" : "not allowed");
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
export async function rateGeneration(transport, generationId, rating, reason) {
|
|
136
|
+
const response = await transport.fetch(`/try-ons/${encodeURIComponent(generationId)}/rating`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify({ rating, reason: reason ?? null }),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const errorText = await response.text();
|
|
143
|
+
throw new Error(`Failed to rate generation: ${response.status} ${response.statusText}. ${errorText}`);
|
|
144
|
+
}
|
|
145
|
+
return (await response.json());
|
|
146
|
+
}
|
|
147
|
+
function patchHistoryEntry(store, id, patch) {
|
|
148
|
+
store.setState((s) => ({
|
|
149
|
+
...s,
|
|
150
|
+
history: s.history.map((r) => (r.id === id ? { ...r, ...patch } : r)),
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
export async function rateResult(deps, entryId, rating, reason) {
|
|
154
|
+
const entry = deps.store.getState().history.find((r) => r.id === entryId);
|
|
155
|
+
if (!entry)
|
|
156
|
+
return;
|
|
157
|
+
const generationId = entry.generationId || entry.id;
|
|
158
|
+
if (!generationId)
|
|
159
|
+
return;
|
|
160
|
+
if (reason != null) {
|
|
161
|
+
patchHistoryEntry(deps.store, entryId, { feedbackReason: reason });
|
|
162
|
+
deps.emitResultRatingReason(generationId, reason);
|
|
163
|
+
try {
|
|
164
|
+
await rateGeneration(deps.transport, generationId, rating, reason);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.error("Failed to save feedback reason:", error);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const previousRating = entry.rating ?? null;
|
|
172
|
+
patchHistoryEntry(deps.store, entryId, { rating, feedbackReason: undefined });
|
|
173
|
+
deps.emitResultRated(generationId, rating);
|
|
174
|
+
try {
|
|
175
|
+
await rateGeneration(deps.transport, generationId, rating);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
console.error("Failed to rate generation:", error);
|
|
179
|
+
patchHistoryEntry(deps.store, entryId, { rating: previousRating });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function failureKindCode(err) {
|
|
183
|
+
const typed = err instanceof Error && err.name === "GenerationFailedError"
|
|
184
|
+
? err
|
|
185
|
+
: null;
|
|
186
|
+
return { kind: typed?.kind ?? "failed", code: typed?.code ?? "GENERATION_FAILED" };
|
|
187
|
+
}
|
|
188
|
+
function limitsWithUsageRefunded(limits, usageAt) {
|
|
189
|
+
if (usageAt === undefined)
|
|
190
|
+
return limits;
|
|
191
|
+
const idx = limits.tryOns.indexOf(usageAt);
|
|
192
|
+
const tryOns = idx >= 0
|
|
193
|
+
? [...limits.tryOns.slice(0, idx), ...limits.tryOns.slice(idx + 1)]
|
|
194
|
+
: limits.tryOns;
|
|
195
|
+
const refunded = limits.refunded.includes(usageAt) ? limits.refunded : [...limits.refunded, usageAt];
|
|
196
|
+
return { ...limits, tryOns, refunded, totalTryOns: Math.max(0, limits.totalTryOns - 1) };
|
|
197
|
+
}
|
|
198
|
+
function refundPreEntityUsage(deps, usageAt) {
|
|
199
|
+
if (usageAt === undefined)
|
|
200
|
+
return;
|
|
201
|
+
deps.store.setState((s) => ({ ...s, limits: limitsWithUsageRefunded(s.limits, usageAt) }));
|
|
202
|
+
}
|
|
203
|
+
function refundJobUsage(deps, generationId) {
|
|
204
|
+
deps.store.setState((s) => {
|
|
205
|
+
const job = s.generations[generationId];
|
|
206
|
+
if (!job || job.usageAt === undefined || job.usageRefunded)
|
|
207
|
+
return s;
|
|
208
|
+
return {
|
|
209
|
+
...s,
|
|
210
|
+
limits: limitsWithUsageRefunded(s.limits, job.usageAt),
|
|
211
|
+
generations: { ...s.generations, [generationId]: { ...job, usageRefunded: true } },
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function upsertGeneration(store, job) {
|
|
216
|
+
store.setState((s) => ({ ...s, generations: { ...s.generations, [job.id]: job } }));
|
|
217
|
+
}
|
|
218
|
+
function patchGeneration(store, id, next) {
|
|
219
|
+
store.setState((s) => {
|
|
220
|
+
const current = s.generations[id];
|
|
221
|
+
if (!current)
|
|
222
|
+
return s;
|
|
223
|
+
return { ...s, generations: { ...s.generations, [id]: next(current) } };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function ensureReported(deps, id) {
|
|
227
|
+
const job = deps.store.getState().generations[id];
|
|
228
|
+
if (job && !job.reported) {
|
|
229
|
+
deps.emitGenerationStart(id);
|
|
230
|
+
patchGeneration(deps.store, id, (j) => ({ ...j, reported: true }));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export function classifyGenerationError(err) {
|
|
234
|
+
const typed = err instanceof Error && err.name === "GenerationFailedError"
|
|
235
|
+
? err
|
|
236
|
+
: null;
|
|
237
|
+
const kind = typed?.kind ?? "failed";
|
|
238
|
+
const backendCode = typed?.code ?? "GENERATION_FAILED";
|
|
239
|
+
switch (kind) {
|
|
240
|
+
case "quota":
|
|
241
|
+
return { code: "credits-expired", retryable: false, backendCode };
|
|
242
|
+
case "weekly_limit":
|
|
243
|
+
case "rate_limited":
|
|
244
|
+
return { code: "rate-limited", retryable: true, backendCode };
|
|
245
|
+
case "upload_failed":
|
|
246
|
+
return { code: "upload-failed", retryable: true, backendCode };
|
|
247
|
+
default:
|
|
248
|
+
return { code: "server-overloaded", retryable: true, backendCode };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function blockedReasonFor(err) {
|
|
252
|
+
if (!(err instanceof Error) || err.name !== "GenerationFailedError")
|
|
253
|
+
return null;
|
|
254
|
+
const { kind, code } = err;
|
|
255
|
+
return kind === "quota" || kind === "weekly_limit" || kind === "rate_limited" ? code : null;
|
|
256
|
+
}
|
|
257
|
+
export async function runGeneration(deps, input) {
|
|
258
|
+
let jobResponse;
|
|
259
|
+
try {
|
|
260
|
+
jobResponse = await createFittingRoomJob(deps.transport, {
|
|
261
|
+
userImageId: input.userImageId,
|
|
262
|
+
productId: input.productId,
|
|
263
|
+
variantId: input.variantId,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
refundPreEntityUsage(deps, input.usageAt);
|
|
268
|
+
const blocked = blockedReasonFor(err);
|
|
269
|
+
if (blocked)
|
|
270
|
+
deps.emitGenerationBlocked(blocked);
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
if (jobResponse.code) {
|
|
274
|
+
refundPreEntityUsage(deps, input.usageAt);
|
|
275
|
+
const err = new GenerationFailedError(jobResponse.code, jobResponse.message || "Job creation failed");
|
|
276
|
+
const blocked = blockedReasonFor(err);
|
|
277
|
+
if (blocked)
|
|
278
|
+
deps.emitGenerationBlocked(blocked);
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
const generationId = jobResponse.jobId;
|
|
282
|
+
upsertGeneration(deps.store, {
|
|
283
|
+
id: generationId,
|
|
284
|
+
photoId: input.userImageId,
|
|
285
|
+
productId: input.productId,
|
|
286
|
+
variantId: input.variantId,
|
|
287
|
+
createdAt: deps.now(),
|
|
288
|
+
seen: false,
|
|
289
|
+
reported: false,
|
|
290
|
+
usageAt: input.usageAt,
|
|
291
|
+
usageRefunded: false,
|
|
292
|
+
status: "requested",
|
|
293
|
+
});
|
|
294
|
+
ensureReported(deps, generationId);
|
|
295
|
+
patchGeneration(deps.store, generationId, (j) => ({ ...j, status: "generating" }));
|
|
296
|
+
try {
|
|
297
|
+
const status = await pollGenerationStatus(deps.transport, generationId, undefined, deps.pollInterval, deps.maxAttempts);
|
|
298
|
+
if (!status.resultImageUrl)
|
|
299
|
+
throw new Error("No image URL returned from generation");
|
|
300
|
+
patchGeneration(deps.store, generationId, (j) => ({
|
|
301
|
+
id: j.id, photoId: j.photoId, productId: j.productId, variantId: j.variantId,
|
|
302
|
+
createdAt: j.createdAt, seen: false, reported: j.reported,
|
|
303
|
+
usageAt: j.usageAt, usageRefunded: j.usageRefunded,
|
|
304
|
+
status: "done", resultImageUrl: status.resultImageUrl, resultImageKey: status.resultImageKey,
|
|
305
|
+
}));
|
|
306
|
+
emitSucceeded(deps, generationId);
|
|
307
|
+
return { imageUrl: status.resultImageUrl, generationId };
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
const error = classifyGenerationError(err);
|
|
311
|
+
patchGeneration(deps.store, generationId, (j) => ({
|
|
312
|
+
id: j.id, photoId: j.photoId, productId: j.productId, variantId: j.variantId,
|
|
313
|
+
createdAt: j.createdAt, seen: j.seen, reported: j.reported,
|
|
314
|
+
usageAt: j.usageAt, usageRefunded: j.usageRefunded, status: "error", error,
|
|
315
|
+
}));
|
|
316
|
+
deps.emitGenerationFailed({ generationId, ...failureKindCode(err) });
|
|
317
|
+
refundJobUsage(deps, generationId);
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function emitSucceeded(deps, generationId) {
|
|
322
|
+
const job = deps.store.getState().generations[generationId];
|
|
323
|
+
if (!job)
|
|
324
|
+
return;
|
|
325
|
+
deps.emitGenerationSucceeded({
|
|
326
|
+
generationId,
|
|
327
|
+
durationMs: deps.now() - job.createdAt,
|
|
328
|
+
productId: job.productId,
|
|
329
|
+
variantId: job.variantId,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
export async function resumeGeneration(deps, generationId) {
|
|
333
|
+
const existing = deps.store.getState().generations[generationId];
|
|
334
|
+
if (!existing)
|
|
335
|
+
return;
|
|
336
|
+
ensureReported(deps, generationId);
|
|
337
|
+
patchGeneration(deps.store, generationId, (j) => j.status === "requested" ? { ...j, status: "generating" } : j);
|
|
338
|
+
try {
|
|
339
|
+
const status = await pollGenerationStatus(deps.transport, generationId, undefined, deps.pollInterval, deps.maxAttempts);
|
|
340
|
+
if (status.resultImageUrl) {
|
|
341
|
+
patchGeneration(deps.store, generationId, (j) => ({
|
|
342
|
+
id: j.id, photoId: j.photoId, productId: j.productId, variantId: j.variantId,
|
|
343
|
+
createdAt: j.createdAt, seen: false, reported: j.reported,
|
|
344
|
+
usageAt: j.usageAt, usageRefunded: j.usageRefunded,
|
|
345
|
+
status: "done", resultImageUrl: status.resultImageUrl, resultImageKey: status.resultImageKey,
|
|
346
|
+
}));
|
|
347
|
+
emitSucceeded(deps, generationId);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
patchGeneration(deps.store, generationId, (j) => ({
|
|
351
|
+
id: j.id, photoId: j.photoId, productId: j.productId, variantId: j.variantId,
|
|
352
|
+
createdAt: j.createdAt, seen: j.seen, reported: j.reported,
|
|
353
|
+
usageAt: j.usageAt, usageRefunded: j.usageRefunded,
|
|
354
|
+
status: "error", error: { code: "server-overloaded", retryable: true, backendCode: "GENERATION_FAILED" },
|
|
355
|
+
}));
|
|
356
|
+
deps.emitGenerationFailed({ generationId, kind: "failed", code: "GENERATION_FAILED" });
|
|
357
|
+
refundJobUsage(deps, generationId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
patchGeneration(deps.store, generationId, (j) => ({
|
|
362
|
+
id: j.id, photoId: j.photoId, productId: j.productId, variantId: j.variantId,
|
|
363
|
+
createdAt: j.createdAt, seen: j.seen, reported: j.reported,
|
|
364
|
+
usageAt: j.usageAt, usageRefunded: j.usageRefunded,
|
|
365
|
+
status: "error", error: classifyGenerationError(err),
|
|
366
|
+
}));
|
|
367
|
+
deps.emitGenerationFailed({ generationId, ...failureKindCode(err) });
|
|
368
|
+
refundJobUsage(deps, generationId);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
export function acknowledgeGeneration(store, generationId) {
|
|
372
|
+
patchGeneration(store, generationId, (j) => ({ ...j, seen: true }));
|
|
373
|
+
}
|
|
374
|
+
export function scheduleInFlightResumes(deps, jobs) {
|
|
375
|
+
void Promise.resolve().then(() => {
|
|
376
|
+
for (const job of jobs) {
|
|
377
|
+
if (job.status === "requested" || job.status === "generating") {
|
|
378
|
+
void resumeGeneration(deps, job.id);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { KVStorage } from "./ports";
|
|
2
|
+
/** A history entry as rendered by the widget's history page. */
|
|
3
|
+
export interface TryOnResult {
|
|
4
|
+
id: string;
|
|
5
|
+
generationId: string;
|
|
6
|
+
shareUrl?: string;
|
|
7
|
+
productId: string;
|
|
8
|
+
productName?: string | null;
|
|
9
|
+
variantId?: string | null;
|
|
10
|
+
featured_image: string;
|
|
11
|
+
resultImage: string;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
location: string;
|
|
14
|
+
/** 1 = thumbs up, -1 = thumbs down, 0 or null = no rating */
|
|
15
|
+
rating?: 1 | -1 | 0 | null;
|
|
16
|
+
feedbackReason?: string;
|
|
17
|
+
/**
|
|
18
|
+
* True when this result came from a scripted/sample showcase path (no backend
|
|
19
|
+
* generation record exists). Such results hide share + rating (nothing to
|
|
20
|
+
* share or rate on the backend) but keep add-to-cart. Real generations leave
|
|
21
|
+
* this unset.
|
|
22
|
+
*/
|
|
23
|
+
scripted?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Structured product context, ADDITIVE over the legacy flat fields
|
|
26
|
+
* (`productName` / `featured_image` / `location`, which are still written
|
|
27
|
+
* verbatim — see buildTryOnResult). New readers prefer this object; classic +
|
|
28
|
+
* legacy readers ignore these extra keys and keep reading the flat fields, so
|
|
29
|
+
* the shared `tryOnHistory` byte format stays backward-compatible. Absent on
|
|
30
|
+
* entries written before this field existed.
|
|
31
|
+
*/
|
|
32
|
+
product?: {
|
|
33
|
+
title?: string | null;
|
|
34
|
+
image?: string;
|
|
35
|
+
url?: string;
|
|
36
|
+
price?: string | null;
|
|
37
|
+
currency?: string | null;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Display-side product context the host passes into generate() so the core can
|
|
41
|
+
* assemble the full TryOnResult on success. Distinct from the API product id
|
|
42
|
+
* (`productId` here is the raw storefront id the history page renders). */
|
|
43
|
+
export interface GenerationContext {
|
|
44
|
+
productId: string;
|
|
45
|
+
productName?: string | null;
|
|
46
|
+
variantId?: string | null;
|
|
47
|
+
featuredImage: string;
|
|
48
|
+
location: string;
|
|
49
|
+
/** Display-ready, already-formatted price string (e.g. "$48.00"); null/absent
|
|
50
|
+
* when the host couldn't resolve a price it's confident is correct. */
|
|
51
|
+
price?: string | null;
|
|
52
|
+
/** ISO currency code that `price` was formatted in, when known. */
|
|
53
|
+
currency?: string | null;
|
|
54
|
+
}
|
|
55
|
+
export declare const HISTORY_KEY = "tryOnHistory";
|
|
56
|
+
export declare const DEFAULT_RETENTION_DAYS = 7;
|
|
57
|
+
export declare function loadHistory(storage: KVStorage): TryOnResult[];
|
|
58
|
+
export declare function saveHistory(storage: KVStorage, history: TryOnResult[]): void;
|
|
59
|
+
/**
|
|
60
|
+
* Read-side retention window: entries younger than (retention - 1) days, so the
|
|
61
|
+
* UI doesn't show results about to be deleted server-side. Stored history is
|
|
62
|
+
* NEVER pruned — this is a derived view only.
|
|
63
|
+
*/
|
|
64
|
+
export declare function selectRecentResults(history: TryOnResult[], retentionDays: number | null | undefined, now: number): TryOnResult[];
|
|
65
|
+
/** Assemble a full TryOnResult (id minting included) from a completed — or
|
|
66
|
+
* scripted — generation plus the host-provided display context. */
|
|
67
|
+
export declare function buildTryOnResult(input: {
|
|
68
|
+
generationId: string;
|
|
69
|
+
resultImage: string;
|
|
70
|
+
context: GenerationContext;
|
|
71
|
+
now: number;
|
|
72
|
+
shareUrl?: string;
|
|
73
|
+
scripted?: boolean;
|
|
74
|
+
}): TryOnResult;
|
|
75
|
+
export declare function appendEntry(history: TryOnResult[], entry: TryOnResult): TryOnResult[];
|
|
76
|
+
export declare function updateEntry(history: TryOnResult[], id: string, patch: Partial<TryOnResult>): TryOnResult[];
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const HISTORY_KEY = "tryOnHistory";
|
|
2
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
3
|
+
export const DEFAULT_RETENTION_DAYS = 7;
|
|
4
|
+
export function loadHistory(storage) {
|
|
5
|
+
let raw;
|
|
6
|
+
try {
|
|
7
|
+
raw = storage.getItem(HISTORY_KEY);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
if (!raw)
|
|
13
|
+
return [];
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
22
|
+
}
|
|
23
|
+
export function saveHistory(storage, history) {
|
|
24
|
+
try {
|
|
25
|
+
storage.setItem(HISTORY_KEY, JSON.stringify(history));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function selectRecentResults(history, retentionDays, now) {
|
|
31
|
+
const days = retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
32
|
+
const thresholdMs = Math.max(1, days - 1) * MS_PER_DAY;
|
|
33
|
+
return history.filter((r) => now - r.timestamp < thresholdMs);
|
|
34
|
+
}
|
|
35
|
+
export function buildTryOnResult(input) {
|
|
36
|
+
const result = {
|
|
37
|
+
id: `${input.now}-${Math.random()}`,
|
|
38
|
+
generationId: input.generationId,
|
|
39
|
+
productId: input.context.productId,
|
|
40
|
+
productName: input.context.productName,
|
|
41
|
+
variantId: input.context.variantId,
|
|
42
|
+
featured_image: input.context.featuredImage,
|
|
43
|
+
resultImage: input.resultImage,
|
|
44
|
+
timestamp: input.now,
|
|
45
|
+
location: input.context.location,
|
|
46
|
+
};
|
|
47
|
+
if (input.shareUrl !== undefined)
|
|
48
|
+
result.shareUrl = input.shareUrl;
|
|
49
|
+
if (input.scripted)
|
|
50
|
+
result.scripted = true;
|
|
51
|
+
const product = {
|
|
52
|
+
title: input.context.productName,
|
|
53
|
+
image: input.context.featuredImage,
|
|
54
|
+
url: input.context.location,
|
|
55
|
+
};
|
|
56
|
+
if (input.context.price !== undefined)
|
|
57
|
+
product.price = input.context.price;
|
|
58
|
+
if (input.context.currency !== undefined)
|
|
59
|
+
product.currency = input.context.currency;
|
|
60
|
+
result.product = product;
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
export function appendEntry(history, entry) {
|
|
64
|
+
return [entry, ...history];
|
|
65
|
+
}
|
|
66
|
+
export function updateEntry(history, id, patch) {
|
|
67
|
+
return history.map((r) => (r.id === id ? { ...r, ...patch } : r));
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { KVStorage } from "./ports";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory {@link KVStorage} (Map-backed). A legitimate port helper: a host
|
|
4
|
+
* that wants ephemeral, isolated core persistence — the design-settings preview
|
|
5
|
+
* composition root, unit tests — injects this instead of a device-backed store.
|
|
6
|
+
* The core reads/writes its usage / jobs / history keys against a per-instance
|
|
7
|
+
* map that never touches `localStorage`, so nothing leaks across preview sessions
|
|
8
|
+
* and no merchant-browser state is polluted.
|
|
9
|
+
*/
|
|
10
|
+
export declare function memoryStorage(): KVStorage;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { UploadResult } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* The client's LATEST pending upload. Each `uploadPhoto` registers its promise;
|
|
4
|
+
* a newer registration supersedes the older. A superseded upload's promise STILL
|
|
5
|
+
* settles for its own awaiter (and its terminal funnel events still fire — the
|
|
6
|
+
* upload happened), it is just no longer "current":
|
|
7
|
+
*
|
|
8
|
+
* - it never overwrites the pointer — only `register` writes it, in call order,
|
|
9
|
+
* so the newest-registered upload wins and an older upload settling later never
|
|
10
|
+
* clobbers the newer one;
|
|
11
|
+
* - `generate({ userImage: "latest" })` awaits `current()` — the newest upload AT
|
|
12
|
+
* THE TIME generate is called.
|
|
13
|
+
*
|
|
14
|
+
* This is the promise counterpart to `selectCurrentUpload` (store state): both
|
|
15
|
+
* point at the same "latest" upload, one for awaiting, one for rendering.
|
|
16
|
+
*
|
|
17
|
+
* NO cancellation: a superseded upload runs to completion and its result is simply
|
|
18
|
+
* ignored. Aborting it would require threading an AbortSignal through the Transport
|
|
19
|
+
* port (a port change) — a possible future refinement, out of scope here.
|
|
20
|
+
*/
|
|
21
|
+
export declare class PendingUpload {
|
|
22
|
+
private latest;
|
|
23
|
+
/** Register a freshly started upload as the current pending upload (supersedes
|
|
24
|
+
* any previous one). Attaches a no-op catch so a superseded/failed upload never
|
|
25
|
+
* surfaces as an unhandled rejection — the real awaiter still receives the
|
|
26
|
+
* rejection from the promise it was handed. */
|
|
27
|
+
register(promise: Promise<UploadResult>): void;
|
|
28
|
+
/** The newest registered upload, or null when none is pending / it was cleared. */
|
|
29
|
+
current(): Promise<UploadResult> | null;
|
|
30
|
+
/** Drop the current pointer (the host discarded the selected photo). */
|
|
31
|
+
clear(): void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KVStorage } from "./ports";
|
|
2
|
+
import type { GenerationJob } from "./entities";
|
|
3
|
+
export declare const GENERATION_TIMEOUT_MS: number;
|
|
4
|
+
export declare const STALE_TTL_MS: number;
|
|
5
|
+
export declare const RETENTION_MS: number;
|
|
6
|
+
export declare function saveJobs(storage: KVStorage, storeId: string, jobs: GenerationJob[]): void;
|
|
7
|
+
/**
|
|
8
|
+
* Load persisted jobs. Prunes expired terminal jobs, converts stale non-terminal
|
|
9
|
+
* jobs to `error`, and returns the surviving live entities. Fresh non-terminal
|
|
10
|
+
* jobs come back with their status intact so the client can resume polling.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadJobs(storage: KVStorage, storeId: string, now: number): GenerationJob[];
|