@primestyleai/tryon 1.0.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.
@@ -0,0 +1,1169 @@
1
+ const DEFAULT_API_URL = "https://api.primestyleai.com";
2
+ class ApiClient {
3
+ constructor(apiKey, apiUrl) {
4
+ this.apiKey = apiKey;
5
+ this.baseUrl = (apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
6
+ }
7
+ get headers() {
8
+ return {
9
+ "Content-Type": "application/json",
10
+ Authorization: `Bearer ${this.apiKey}`
11
+ };
12
+ }
13
+ async submitTryOn(modelImage, garmentImage) {
14
+ const res = await fetch(`${this.baseUrl}/api/v1/tryon`, {
15
+ method: "POST",
16
+ headers: this.headers,
17
+ body: JSON.stringify({ modelImage, garmentImage })
18
+ });
19
+ if (!res.ok) {
20
+ const data = await res.json().catch(() => ({}));
21
+ if (res.status === 402) {
22
+ throw new PrimeStyleError(
23
+ data.message || "Insufficient tokens",
24
+ "INSUFFICIENT_TOKENS"
25
+ );
26
+ }
27
+ throw new PrimeStyleError(
28
+ data.message || "Failed to submit try-on",
29
+ "API_ERROR"
30
+ );
31
+ }
32
+ return res.json();
33
+ }
34
+ async getStatus(jobId) {
35
+ const res = await fetch(`${this.baseUrl}/api/v1/tryon/status/${jobId}`, {
36
+ headers: this.headers
37
+ });
38
+ if (!res.ok) {
39
+ const data = await res.json().catch(() => ({}));
40
+ throw new PrimeStyleError(
41
+ data.message || "Failed to get status",
42
+ "API_ERROR"
43
+ );
44
+ }
45
+ return res.json();
46
+ }
47
+ getStreamUrl() {
48
+ return `${this.baseUrl}/api/v1/tryon/stream?key=${encodeURIComponent(this.apiKey)}`;
49
+ }
50
+ }
51
+ class PrimeStyleError extends Error {
52
+ constructor(message, code) {
53
+ super(message);
54
+ this.name = "PrimeStyleError";
55
+ this.code = code;
56
+ }
57
+ }
58
+ class SseClient {
59
+ constructor(streamUrl) {
60
+ this.eventSource = null;
61
+ this.listeners = /* @__PURE__ */ new Map();
62
+ this.reconnectTimer = null;
63
+ this.reconnectAttempts = 0;
64
+ this.maxReconnectAttempts = 5;
65
+ this.streamUrl = streamUrl;
66
+ }
67
+ connect() {
68
+ if (this.eventSource) return;
69
+ this.eventSource = new EventSource(this.streamUrl);
70
+ this.eventSource.addEventListener("vto-update", (event) => {
71
+ try {
72
+ const data = JSON.parse(event.data);
73
+ this.emit(data.galleryId, data);
74
+ } catch {
75
+ }
76
+ });
77
+ this.eventSource.onopen = () => {
78
+ this.reconnectAttempts = 0;
79
+ };
80
+ this.eventSource.onerror = () => {
81
+ this.eventSource?.close();
82
+ this.eventSource = null;
83
+ this.scheduleReconnect();
84
+ };
85
+ }
86
+ scheduleReconnect() {
87
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
88
+ if (this.listeners.size === 0) return;
89
+ const delay = Math.min(1e3 * 2 ** this.reconnectAttempts, 3e4);
90
+ this.reconnectAttempts++;
91
+ this.reconnectTimer = setTimeout(() => {
92
+ this.connect();
93
+ }, delay);
94
+ }
95
+ onJob(jobId, callback) {
96
+ if (!this.listeners.has(jobId)) {
97
+ this.listeners.set(jobId, /* @__PURE__ */ new Set());
98
+ }
99
+ this.listeners.get(jobId).add(callback);
100
+ if (!this.eventSource) {
101
+ this.connect();
102
+ }
103
+ return () => {
104
+ const jobListeners = this.listeners.get(jobId);
105
+ if (jobListeners) {
106
+ jobListeners.delete(callback);
107
+ if (jobListeners.size === 0) {
108
+ this.listeners.delete(jobId);
109
+ }
110
+ }
111
+ if (this.listeners.size === 0) {
112
+ this.disconnect();
113
+ }
114
+ };
115
+ }
116
+ emit(jobId, update) {
117
+ const callbacks = this.listeners.get(jobId);
118
+ if (callbacks) {
119
+ callbacks.forEach((cb) => cb(update));
120
+ }
121
+ }
122
+ disconnect() {
123
+ if (this.reconnectTimer) {
124
+ clearTimeout(this.reconnectTimer);
125
+ this.reconnectTimer = null;
126
+ }
127
+ if (this.eventSource) {
128
+ this.eventSource.close();
129
+ this.eventSource = null;
130
+ }
131
+ this.listeners.clear();
132
+ this.reconnectAttempts = 0;
133
+ }
134
+ }
135
+ function detectProductImage() {
136
+ const ogImage = document.querySelector(
137
+ 'meta[property="og:image"]'
138
+ );
139
+ if (ogImage?.content) return ogImage.content;
140
+ const jsonLdScripts = document.querySelectorAll(
141
+ 'script[type="application/ld+json"]'
142
+ );
143
+ for (const script of jsonLdScripts) {
144
+ try {
145
+ const data = JSON.parse(script.textContent || "");
146
+ const image = extractSchemaImage(data);
147
+ if (image) return image;
148
+ } catch {
149
+ }
150
+ }
151
+ const selectors = [
152
+ "[data-product-image] img",
153
+ "[data-product-image]",
154
+ ".product-image img",
155
+ ".product-gallery img",
156
+ "#product-image",
157
+ ".product__media img",
158
+ ".product-single__photo img",
159
+ ".woocommerce-product-gallery img",
160
+ ".product-media img"
161
+ ];
162
+ for (const selector of selectors) {
163
+ const el = document.querySelector(selector);
164
+ if (el) {
165
+ const src = el.src || el.dataset.src || el.dataset.zoom;
166
+ if (src) return src;
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ function extractSchemaImage(data) {
172
+ if (!data || typeof data !== "object") return null;
173
+ if (Array.isArray(data)) {
174
+ for (const item of data) {
175
+ const result = extractSchemaImage(item);
176
+ if (result) return result;
177
+ }
178
+ return null;
179
+ }
180
+ const obj = data;
181
+ if (obj["@type"] === "Product" || obj["@type"] === "IndividualProduct") {
182
+ const image = obj.image;
183
+ if (typeof image === "string") return image;
184
+ if (Array.isArray(image) && typeof image[0] === "string") return image[0];
185
+ if (image && typeof image === "object") {
186
+ const imgObj = image;
187
+ if (typeof imgObj.url === "string") return imgObj.url;
188
+ if (typeof imgObj.contentUrl === "string") return imgObj.contentUrl;
189
+ }
190
+ }
191
+ if (Array.isArray(obj["@graph"])) {
192
+ return extractSchemaImage(obj["@graph"]);
193
+ }
194
+ return null;
195
+ }
196
+ const MAX_DIMENSION = 1024;
197
+ const JPEG_QUALITY = 0.85;
198
+ function compressImage(file) {
199
+ return new Promise((resolve, reject) => {
200
+ const reader = new FileReader();
201
+ reader.onload = () => {
202
+ const img = new Image();
203
+ img.onload = () => {
204
+ try {
205
+ const canvas = document.createElement("canvas");
206
+ let { width, height } = img;
207
+ if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
208
+ if (width > height) {
209
+ height = Math.round(height * MAX_DIMENSION / width);
210
+ width = MAX_DIMENSION;
211
+ } else {
212
+ width = Math.round(width * MAX_DIMENSION / height);
213
+ height = MAX_DIMENSION;
214
+ }
215
+ }
216
+ canvas.width = width;
217
+ canvas.height = height;
218
+ const ctx = canvas.getContext("2d");
219
+ if (!ctx) {
220
+ reject(new Error("Canvas context not available"));
221
+ return;
222
+ }
223
+ ctx.drawImage(img, 0, 0, width, height);
224
+ const dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
225
+ resolve(dataUrl);
226
+ } catch (err) {
227
+ reject(err);
228
+ }
229
+ };
230
+ img.onerror = () => reject(new Error("Failed to load image"));
231
+ img.src = reader.result;
232
+ };
233
+ reader.onerror = () => reject(new Error("Failed to read file"));
234
+ reader.readAsDataURL(file);
235
+ });
236
+ }
237
+ function isValidImageFile(file) {
238
+ const accepted = ["image/jpeg", "image/png", "image/webp"];
239
+ return accepted.includes(file.type);
240
+ }
241
+ function getStyles() {
242
+ return `
243
+ :host {
244
+ display: inline-block;
245
+ --ps-primary: #bb945c;
246
+ --ps-primary-hover: #a07d4e;
247
+ --ps-text: #ffffff;
248
+ --ps-text-secondary: #999999;
249
+ --ps-bg: #111211;
250
+ --ps-bg-secondary: #1a1b1a;
251
+ --ps-border: #333333;
252
+ --ps-radius: 12px;
253
+ --ps-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
254
+ --ps-overlay: rgba(0, 0, 0, 0.6);
255
+ --ps-error: #ef4444;
256
+ --ps-success: #22c55e;
257
+ }
258
+
259
+ * {
260
+ box-sizing: border-box;
261
+ margin: 0;
262
+ padding: 0;
263
+ }
264
+
265
+ /* ── Button ─────────────────────────────── */
266
+ .ps-button {
267
+ display: inline-flex;
268
+ align-items: center;
269
+ gap: 8px;
270
+ padding: var(--ps-btn-padding, 12px 24px);
271
+ background: var(--ps-btn-bg, var(--ps-primary));
272
+ color: var(--ps-btn-color, #111211);
273
+ font-family: var(--ps-btn-font, var(--ps-font));
274
+ font-size: var(--ps-btn-font-size, 14px);
275
+ font-weight: var(--ps-btn-font-weight, 600);
276
+ border: var(--ps-btn-border, none);
277
+ border-radius: var(--ps-btn-radius, 8px);
278
+ cursor: pointer;
279
+ transition: all 0.2s ease;
280
+ width: var(--ps-btn-width, auto);
281
+ height: var(--ps-btn-height, auto);
282
+ box-shadow: var(--ps-btn-shadow, none);
283
+ line-height: 1;
284
+ white-space: nowrap;
285
+ }
286
+
287
+ .ps-button:hover {
288
+ background: var(--ps-btn-hover-bg, var(--ps-primary-hover));
289
+ color: var(--ps-btn-hover-color, var(--ps-btn-color, #111211));
290
+ transform: translateY(-1px);
291
+ }
292
+
293
+ .ps-button:active {
294
+ transform: translateY(0);
295
+ }
296
+
297
+ .ps-button svg {
298
+ width: var(--ps-btn-icon-size, 18px);
299
+ height: var(--ps-btn-icon-size, 18px);
300
+ fill: none;
301
+ stroke: var(--ps-btn-icon-color, currentColor);
302
+ stroke-width: 2;
303
+ stroke-linecap: round;
304
+ stroke-linejoin: round;
305
+ }
306
+
307
+ /* ── Modal Overlay ──────────────────────── */
308
+ .ps-overlay {
309
+ position: fixed;
310
+ inset: 0;
311
+ background: var(--ps-modal-overlay, var(--ps-overlay));
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ z-index: 999999;
316
+ opacity: 0;
317
+ visibility: hidden;
318
+ transition: opacity 0.25s ease, visibility 0.25s ease;
319
+ padding: 16px;
320
+ }
321
+
322
+ .ps-overlay.ps-open {
323
+ opacity: 1;
324
+ visibility: visible;
325
+ }
326
+
327
+ /* ── Modal ──────────────────────────────── */
328
+ .ps-modal {
329
+ background: var(--ps-modal-bg, var(--ps-bg));
330
+ color: var(--ps-modal-color, var(--ps-text));
331
+ border-radius: var(--ps-modal-radius, var(--ps-radius));
332
+ width: var(--ps-modal-width, 100%);
333
+ max-width: var(--ps-modal-max-width, 480px);
334
+ max-height: 90vh;
335
+ overflow-y: auto;
336
+ font-family: var(--ps-modal-font, var(--ps-font));
337
+ position: relative;
338
+ transform: translateY(20px) scale(0.97);
339
+ transition: transform 0.25s ease;
340
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
341
+ }
342
+
343
+ .ps-open .ps-modal {
344
+ transform: translateY(0) scale(1);
345
+ }
346
+
347
+ /* ── Modal Header ───────────────────────── */
348
+ .ps-header {
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: space-between;
352
+ padding: 20px 24px;
353
+ background: var(--ps-modal-header-bg, var(--ps-bg-secondary));
354
+ border-bottom: 1px solid var(--ps-border);
355
+ border-radius: var(--ps-modal-radius, var(--ps-radius)) var(--ps-modal-radius, var(--ps-radius)) 0 0;
356
+ }
357
+
358
+ .ps-header-title {
359
+ font-size: 16px;
360
+ font-weight: 600;
361
+ color: var(--ps-modal-header-color, var(--ps-text));
362
+ }
363
+
364
+ .ps-close {
365
+ width: 32px;
366
+ height: 32px;
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: center;
370
+ background: none;
371
+ border: none;
372
+ color: var(--ps-modal-close-color, var(--ps-text-secondary));
373
+ cursor: pointer;
374
+ border-radius: 6px;
375
+ transition: background 0.15s;
376
+ }
377
+
378
+ .ps-close:hover {
379
+ background: rgba(255, 255, 255, 0.1);
380
+ }
381
+
382
+ .ps-close svg {
383
+ width: 20px;
384
+ height: 20px;
385
+ stroke: currentColor;
386
+ stroke-width: 2;
387
+ fill: none;
388
+ }
389
+
390
+ /* ── Modal Body ─────────────────────────── */
391
+ .ps-body {
392
+ padding: 24px;
393
+ }
394
+
395
+ /* ── Upload Zone ─────────────────────────── */
396
+ .ps-upload-zone {
397
+ border: 2px dashed var(--ps-upload-border, var(--ps-border));
398
+ border-radius: var(--ps-radius);
399
+ padding: 40px 24px;
400
+ text-align: center;
401
+ cursor: pointer;
402
+ transition: all 0.2s ease;
403
+ background: var(--ps-upload-bg, transparent);
404
+ }
405
+
406
+ .ps-upload-zone:hover,
407
+ .ps-upload-zone.ps-drag-over {
408
+ border-color: var(--ps-primary);
409
+ background: rgba(187, 148, 92, 0.05);
410
+ }
411
+
412
+ .ps-upload-zone input {
413
+ display: none;
414
+ }
415
+
416
+ .ps-upload-icon {
417
+ width: 48px;
418
+ height: 48px;
419
+ margin: 0 auto 12px;
420
+ stroke: var(--ps-upload-icon-color, var(--ps-primary));
421
+ fill: none;
422
+ stroke-width: 1.5;
423
+ }
424
+
425
+ .ps-upload-text {
426
+ font-size: 14px;
427
+ color: var(--ps-upload-color, var(--ps-text));
428
+ margin-bottom: 4px;
429
+ }
430
+
431
+ .ps-upload-hint {
432
+ font-size: 12px;
433
+ color: var(--ps-text-secondary);
434
+ }
435
+
436
+ /* ── Preview ────────────────────────────── */
437
+ .ps-preview {
438
+ margin-top: 16px;
439
+ position: relative;
440
+ }
441
+
442
+ .ps-preview img {
443
+ width: 100%;
444
+ border-radius: var(--ps-radius);
445
+ display: block;
446
+ }
447
+
448
+ .ps-preview-remove {
449
+ position: absolute;
450
+ top: 8px;
451
+ right: 8px;
452
+ width: 28px;
453
+ height: 28px;
454
+ border-radius: 50%;
455
+ background: rgba(0, 0, 0, 0.6);
456
+ border: none;
457
+ color: white;
458
+ cursor: pointer;
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: center;
462
+ font-size: 16px;
463
+ transition: background 0.15s;
464
+ }
465
+
466
+ .ps-preview-remove:hover {
467
+ background: rgba(0, 0, 0, 0.8);
468
+ }
469
+
470
+ /* ── Submit Button ──────────────────────── */
471
+ .ps-submit {
472
+ width: 100%;
473
+ padding: 14px;
474
+ margin-top: 20px;
475
+ background: var(--ps-modal-primary-bg, var(--ps-primary));
476
+ color: var(--ps-modal-primary-color, #111211);
477
+ font-family: var(--ps-modal-font, var(--ps-font));
478
+ font-size: 14px;
479
+ font-weight: 600;
480
+ border: none;
481
+ border-radius: var(--ps-modal-primary-radius, 8px);
482
+ cursor: pointer;
483
+ transition: all 0.2s ease;
484
+ display: flex;
485
+ align-items: center;
486
+ justify-content: center;
487
+ gap: 8px;
488
+ }
489
+
490
+ .ps-submit:hover:not(:disabled) {
491
+ opacity: 0.9;
492
+ transform: translateY(-1px);
493
+ }
494
+
495
+ .ps-submit:disabled {
496
+ opacity: 0.5;
497
+ cursor: not-allowed;
498
+ }
499
+
500
+ /* ── Processing State ───────────────────── */
501
+ .ps-processing {
502
+ text-align: center;
503
+ padding: 40px 24px;
504
+ }
505
+
506
+ .ps-spinner {
507
+ width: 48px;
508
+ height: 48px;
509
+ border: 3px solid var(--ps-border);
510
+ border-top-color: var(--ps-loader, var(--ps-primary));
511
+ border-radius: 50%;
512
+ animation: ps-spin 0.8s linear infinite;
513
+ margin: 0 auto 16px;
514
+ }
515
+
516
+ @keyframes ps-spin {
517
+ to { transform: rotate(360deg); }
518
+ }
519
+
520
+ .ps-processing-text {
521
+ font-size: 14px;
522
+ color: var(--ps-text);
523
+ margin-bottom: 4px;
524
+ }
525
+
526
+ .ps-processing-sub {
527
+ font-size: 12px;
528
+ color: var(--ps-text-secondary);
529
+ }
530
+
531
+ /* ── Result ─────────────────────────────── */
532
+ .ps-result {
533
+ text-align: center;
534
+ }
535
+
536
+ .ps-result img {
537
+ width: 100%;
538
+ border-radius: var(--ps-result-radius, var(--ps-radius));
539
+ display: block;
540
+ margin-bottom: 16px;
541
+ }
542
+
543
+ .ps-result-actions {
544
+ display: flex;
545
+ gap: 8px;
546
+ }
547
+
548
+ .ps-result-actions button {
549
+ flex: 1;
550
+ padding: 12px;
551
+ font-family: var(--ps-modal-font, var(--ps-font));
552
+ font-size: 13px;
553
+ font-weight: 600;
554
+ border-radius: 8px;
555
+ cursor: pointer;
556
+ transition: all 0.2s;
557
+ border: none;
558
+ }
559
+
560
+ .ps-btn-download {
561
+ background: var(--ps-primary);
562
+ color: #111211;
563
+ }
564
+
565
+ .ps-btn-download:hover {
566
+ opacity: 0.9;
567
+ }
568
+
569
+ .ps-btn-retry {
570
+ background: rgba(255, 255, 255, 0.1);
571
+ color: var(--ps-text);
572
+ border: 1px solid var(--ps-border) !important;
573
+ }
574
+
575
+ .ps-btn-retry:hover {
576
+ background: rgba(255, 255, 255, 0.15);
577
+ }
578
+
579
+ /* ── Error State ────────────────────────── */
580
+ .ps-error {
581
+ text-align: center;
582
+ padding: 24px;
583
+ }
584
+
585
+ .ps-error-icon {
586
+ width: 48px;
587
+ height: 48px;
588
+ margin: 0 auto 12px;
589
+ stroke: var(--ps-error);
590
+ fill: none;
591
+ stroke-width: 1.5;
592
+ }
593
+
594
+ .ps-error-text {
595
+ font-size: 14px;
596
+ color: var(--ps-error);
597
+ margin-bottom: 16px;
598
+ }
599
+
600
+ /* ── Powered By ─────────────────────────── */
601
+ .ps-powered {
602
+ text-align: center;
603
+ padding: 12px 24px 16px;
604
+ font-size: 11px;
605
+ color: var(--ps-text-secondary);
606
+ }
607
+
608
+ .ps-powered a {
609
+ color: var(--ps-primary);
610
+ text-decoration: none;
611
+ }
612
+
613
+ .ps-powered a:hover {
614
+ text-decoration: underline;
615
+ }
616
+ `;
617
+ }
618
+ const ICONS = {
619
+ camera: `<svg viewBox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>`,
620
+ upload: `<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
621
+ x: `<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
622
+ alert: `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`
623
+ };
624
+ class PrimeStyleTryon extends HTMLElement {
625
+ constructor() {
626
+ super();
627
+ this.apiClient = null;
628
+ this.sseClient = null;
629
+ this.sseUnsubscribe = null;
630
+ this.state = "idle";
631
+ this.selectedFile = null;
632
+ this.previewUrl = null;
633
+ this.resultImageUrl = null;
634
+ this.errorMessage = null;
635
+ this.currentJobId = null;
636
+ this.productImageUrl = null;
637
+ this.buttonStyles = {};
638
+ this.modalStyles = {};
639
+ this.shadow = this.attachShadow({ mode: "open" });
640
+ }
641
+ static get observedAttributes() {
642
+ return [
643
+ "api-key",
644
+ "api-url",
645
+ "product-image",
646
+ "button-text",
647
+ "locale",
648
+ "show-powered-by",
649
+ "button-styles",
650
+ "modal-styles"
651
+ ];
652
+ }
653
+ connectedCallback() {
654
+ this.init();
655
+ this.render();
656
+ }
657
+ disconnectedCallback() {
658
+ this.cleanup();
659
+ }
660
+ attributeChangedCallback(name, _old, val) {
661
+ if (name === "api-key" || name === "api-url") {
662
+ this.initApi();
663
+ }
664
+ if (name === "product-image") {
665
+ this.productImageUrl = val;
666
+ }
667
+ if (name === "button-styles") {
668
+ try {
669
+ this.buttonStyles = JSON.parse(val);
670
+ } catch {
671
+ }
672
+ }
673
+ if (name === "modal-styles") {
674
+ try {
675
+ this.modalStyles = JSON.parse(val);
676
+ } catch {
677
+ }
678
+ }
679
+ if (this.isConnected) this.render();
680
+ }
681
+ // ── Public API ──────────────────────────────
682
+ /** Configure button appearance programmatically */
683
+ setButtonStyles(styles) {
684
+ this.buttonStyles = { ...this.buttonStyles, ...styles };
685
+ this.applyButtonStyles();
686
+ }
687
+ /** Configure modal appearance programmatically */
688
+ setModalStyles(styles) {
689
+ this.modalStyles = { ...this.modalStyles, ...styles };
690
+ this.applyModalStyles();
691
+ }
692
+ /** Open the try-on modal */
693
+ open() {
694
+ this.state = "upload";
695
+ this.render();
696
+ this.emit("ps:open");
697
+ }
698
+ /** Close the try-on modal */
699
+ close() {
700
+ this.state = "idle";
701
+ this.resetUpload();
702
+ this.render();
703
+ this.emit("ps:close");
704
+ }
705
+ /** Detect product image from the current page */
706
+ detectProduct() {
707
+ const image = detectProductImage();
708
+ if (image) {
709
+ this.productImageUrl = image;
710
+ this.emit("ps:product-detected", { imageUrl: image });
711
+ }
712
+ return image;
713
+ }
714
+ // ── Private ─────────────────────────────────
715
+ init() {
716
+ const btnStylesAttr = this.getAttribute("button-styles");
717
+ if (btnStylesAttr) {
718
+ try {
719
+ this.buttonStyles = JSON.parse(btnStylesAttr);
720
+ } catch {
721
+ }
722
+ }
723
+ const modalStylesAttr = this.getAttribute("modal-styles");
724
+ if (modalStylesAttr) {
725
+ try {
726
+ this.modalStyles = JSON.parse(modalStylesAttr);
727
+ } catch {
728
+ }
729
+ }
730
+ this.productImageUrl = this.getAttribute("product-image") || null;
731
+ if (!this.productImageUrl) {
732
+ this.productImageUrl = detectProductImage();
733
+ if (this.productImageUrl) {
734
+ this.emit("ps:product-detected", { imageUrl: this.productImageUrl });
735
+ }
736
+ }
737
+ this.initApi();
738
+ }
739
+ initApi() {
740
+ const apiKey = this.getAttribute("api-key");
741
+ if (!apiKey) return;
742
+ const apiUrl = this.getAttribute("api-url") || void 0;
743
+ this.apiClient = new ApiClient(apiKey, apiUrl);
744
+ this.sseClient = new SseClient(this.apiClient.getStreamUrl());
745
+ }
746
+ cleanup() {
747
+ if (this.sseUnsubscribe) {
748
+ this.sseUnsubscribe();
749
+ this.sseUnsubscribe = null;
750
+ }
751
+ if (this.sseClient) {
752
+ this.sseClient.disconnect();
753
+ this.sseClient = null;
754
+ }
755
+ if (this.previewUrl) {
756
+ URL.revokeObjectURL(this.previewUrl);
757
+ }
758
+ }
759
+ emit(name, detail) {
760
+ this.dispatchEvent(
761
+ new CustomEvent(name, { bubbles: true, composed: true, detail })
762
+ );
763
+ }
764
+ get buttonText() {
765
+ return this.getAttribute("button-text") || "Virtual Try-On";
766
+ }
767
+ get showPoweredBy() {
768
+ const attr = this.getAttribute("show-powered-by");
769
+ return attr !== "false";
770
+ }
771
+ // ── Rendering ───────────────────────────────
772
+ render() {
773
+ this.shadow.innerHTML = "";
774
+ const style = document.createElement("style");
775
+ style.textContent = getStyles();
776
+ this.shadow.appendChild(style);
777
+ const button = this.createButton();
778
+ this.shadow.appendChild(button);
779
+ if (this.state !== "idle") {
780
+ const overlay = this.createModal();
781
+ this.shadow.appendChild(overlay);
782
+ requestAnimationFrame(() => overlay.classList.add("ps-open"));
783
+ }
784
+ this.applyButtonStyles();
785
+ this.applyModalStyles();
786
+ }
787
+ createButton() {
788
+ const btn = document.createElement("button");
789
+ btn.className = "ps-button";
790
+ btn.innerHTML = `${ICONS.camera}<span>${this.buttonText}</span>`;
791
+ btn.addEventListener("click", () => this.open());
792
+ return btn;
793
+ }
794
+ createModal() {
795
+ const overlay = document.createElement("div");
796
+ overlay.className = "ps-overlay";
797
+ overlay.addEventListener("click", (e) => {
798
+ if (e.target === overlay) this.close();
799
+ });
800
+ const modal = document.createElement("div");
801
+ modal.className = "ps-modal";
802
+ const header = document.createElement("div");
803
+ header.className = "ps-header";
804
+ header.innerHTML = `
805
+ <span class="ps-header-title">Virtual Try-On</span>
806
+ `;
807
+ const closeBtn = document.createElement("button");
808
+ closeBtn.className = "ps-close";
809
+ closeBtn.innerHTML = ICONS.x;
810
+ closeBtn.addEventListener("click", () => this.close());
811
+ header.appendChild(closeBtn);
812
+ modal.appendChild(header);
813
+ const body = document.createElement("div");
814
+ body.className = "ps-body";
815
+ switch (this.state) {
816
+ case "upload":
817
+ body.appendChild(this.createUploadView());
818
+ break;
819
+ case "processing":
820
+ body.appendChild(this.createProcessingView());
821
+ break;
822
+ case "result":
823
+ body.appendChild(this.createResultView());
824
+ break;
825
+ case "error":
826
+ body.appendChild(this.createErrorView());
827
+ break;
828
+ }
829
+ modal.appendChild(body);
830
+ if (this.showPoweredBy) {
831
+ const powered = document.createElement("div");
832
+ powered.className = "ps-powered";
833
+ powered.innerHTML = `Powered by <a href="https://primestyleai.com" target="_blank" rel="noopener">PrimeStyle AI</a>`;
834
+ modal.appendChild(powered);
835
+ }
836
+ overlay.appendChild(modal);
837
+ return overlay;
838
+ }
839
+ createUploadView() {
840
+ const frag = document.createDocumentFragment();
841
+ if (this.selectedFile && this.previewUrl) {
842
+ const preview = document.createElement("div");
843
+ preview.className = "ps-preview";
844
+ const img = document.createElement("img");
845
+ img.src = this.previewUrl;
846
+ img.alt = "Your photo";
847
+ preview.appendChild(img);
848
+ const removeBtn = document.createElement("button");
849
+ removeBtn.className = "ps-preview-remove";
850
+ removeBtn.textContent = "×";
851
+ removeBtn.addEventListener("click", () => {
852
+ this.resetUpload();
853
+ this.render();
854
+ });
855
+ preview.appendChild(removeBtn);
856
+ frag.appendChild(preview);
857
+ const submit = document.createElement("button");
858
+ submit.className = "ps-submit";
859
+ submit.textContent = "Try It On";
860
+ submit.addEventListener("click", () => this.handleSubmit());
861
+ frag.appendChild(submit);
862
+ } else {
863
+ const zone = document.createElement("div");
864
+ zone.className = "ps-upload-zone";
865
+ const input = document.createElement("input");
866
+ input.type = "file";
867
+ input.accept = "image/jpeg,image/png,image/webp";
868
+ input.addEventListener("change", (e) => {
869
+ const file = e.target.files?.[0];
870
+ if (file) this.handleFileSelect(file);
871
+ });
872
+ zone.innerHTML = `
873
+ <svg class="ps-upload-icon" viewBox="0 0 24 24">${ICONS.upload.replace(/<\/?svg[^>]*>/g, "")}</svg>
874
+ <p class="ps-upload-text">Drop your photo here or click to upload</p>
875
+ <p class="ps-upload-hint">JPEG, PNG or WebP (max 10MB)</p>
876
+ `;
877
+ zone.appendChild(input);
878
+ zone.addEventListener("click", () => input.click());
879
+ zone.addEventListener("dragover", (e) => {
880
+ e.preventDefault();
881
+ zone.classList.add("ps-drag-over");
882
+ });
883
+ zone.addEventListener("dragleave", () => {
884
+ zone.classList.remove("ps-drag-over");
885
+ });
886
+ zone.addEventListener("drop", (e) => {
887
+ e.preventDefault();
888
+ zone.classList.remove("ps-drag-over");
889
+ const file = e.dataTransfer?.files?.[0];
890
+ if (file) this.handleFileSelect(file);
891
+ });
892
+ frag.appendChild(zone);
893
+ }
894
+ return frag;
895
+ }
896
+ createProcessingView() {
897
+ const div = document.createElement("div");
898
+ div.className = "ps-processing";
899
+ div.innerHTML = `
900
+ <div class="ps-spinner"></div>
901
+ <p class="ps-processing-text">Generating your try-on...</p>
902
+ <p class="ps-processing-sub">This usually takes 15-20 seconds</p>
903
+ `;
904
+ return div;
905
+ }
906
+ createResultView() {
907
+ const div = document.createElement("div");
908
+ div.className = "ps-result";
909
+ if (this.resultImageUrl) {
910
+ const img = document.createElement("img");
911
+ img.src = this.resultImageUrl;
912
+ img.alt = "Try-on result";
913
+ div.appendChild(img);
914
+ }
915
+ const actions = document.createElement("div");
916
+ actions.className = "ps-result-actions";
917
+ const downloadBtn = document.createElement("button");
918
+ downloadBtn.className = "ps-btn-download";
919
+ downloadBtn.textContent = "Download";
920
+ downloadBtn.addEventListener("click", () => this.handleDownload());
921
+ actions.appendChild(downloadBtn);
922
+ const retryBtn = document.createElement("button");
923
+ retryBtn.className = "ps-btn-retry";
924
+ retryBtn.textContent = "Try Another";
925
+ retryBtn.addEventListener("click", () => {
926
+ this.resetUpload();
927
+ this.state = "upload";
928
+ this.render();
929
+ });
930
+ actions.appendChild(retryBtn);
931
+ div.appendChild(actions);
932
+ return div;
933
+ }
934
+ createErrorView() {
935
+ const div = document.createElement("div");
936
+ div.className = "ps-error";
937
+ div.innerHTML = `
938
+ <svg class="ps-error-icon" viewBox="0 0 24 24">${ICONS.alert.replace(/<\/?svg[^>]*>/g, "")}</svg>
939
+ <p class="ps-error-text">${this.errorMessage || "Something went wrong"}</p>
940
+ `;
941
+ const retryBtn = document.createElement("button");
942
+ retryBtn.className = "ps-submit";
943
+ retryBtn.textContent = "Try Again";
944
+ retryBtn.addEventListener("click", () => {
945
+ this.state = "upload";
946
+ this.errorMessage = null;
947
+ this.render();
948
+ });
949
+ div.appendChild(retryBtn);
950
+ return div;
951
+ }
952
+ // ── Handlers ────────────────────────────────
953
+ handleFileSelect(file) {
954
+ if (!isValidImageFile(file)) {
955
+ this.errorMessage = "Please upload a JPEG, PNG, or WebP image.";
956
+ this.state = "error";
957
+ this.render();
958
+ return;
959
+ }
960
+ if (file.size > 10 * 1024 * 1024) {
961
+ this.errorMessage = "Image must be under 10MB.";
962
+ this.state = "error";
963
+ this.render();
964
+ return;
965
+ }
966
+ this.selectedFile = file;
967
+ this.previewUrl = URL.createObjectURL(file);
968
+ this.emit("ps:upload", { file });
969
+ this.render();
970
+ }
971
+ async handleSubmit() {
972
+ if (!this.selectedFile || !this.apiClient || !this.sseClient) {
973
+ this.errorMessage = "SDK not configured. Please provide an API key.";
974
+ this.state = "error";
975
+ this.render();
976
+ return;
977
+ }
978
+ if (!this.productImageUrl) {
979
+ this.errorMessage = "No product image found. Please set the product-image attribute.";
980
+ this.state = "error";
981
+ this.render();
982
+ return;
983
+ }
984
+ this.state = "processing";
985
+ this.render();
986
+ try {
987
+ const modelImage = await compressImage(this.selectedFile);
988
+ const response = await this.apiClient.submitTryOn(
989
+ modelImage,
990
+ this.productImageUrl
991
+ );
992
+ this.currentJobId = response.jobId;
993
+ this.emit("ps:processing", { jobId: response.jobId });
994
+ this.sseUnsubscribe = this.sseClient.onJob(
995
+ response.jobId,
996
+ (update) => this.handleVtoUpdate(update)
997
+ );
998
+ this.startPolling(response.jobId);
999
+ } catch (err) {
1000
+ const message = err instanceof Error ? err.message : "Failed to start try-on";
1001
+ this.errorMessage = message;
1002
+ this.state = "error";
1003
+ this.emit("ps:error", { message, code: err?.code });
1004
+ this.render();
1005
+ }
1006
+ }
1007
+ handleVtoUpdate(update) {
1008
+ if (update.status === "completed" && update.imageUrl) {
1009
+ if (this.state !== "result" || this.resultImageUrl?.startsWith("data:") && !update.imageUrl.startsWith("data:")) {
1010
+ this.resultImageUrl = update.imageUrl;
1011
+ this.state = "result";
1012
+ this.emit("ps:complete", {
1013
+ jobId: update.galleryId,
1014
+ imageUrl: update.imageUrl
1015
+ });
1016
+ this.render();
1017
+ }
1018
+ } else if (update.status === "failed") {
1019
+ this.errorMessage = update.error || "Try-on generation failed";
1020
+ this.state = "error";
1021
+ this.emit("ps:error", { message: this.errorMessage });
1022
+ this.render();
1023
+ }
1024
+ }
1025
+ startPolling(jobId) {
1026
+ let attempts = 0;
1027
+ const maxAttempts = 60;
1028
+ const interval = setInterval(async () => {
1029
+ attempts++;
1030
+ if (attempts > maxAttempts || this.state === "result" || this.state === "idle") {
1031
+ clearInterval(interval);
1032
+ return;
1033
+ }
1034
+ if (!this.apiClient) {
1035
+ clearInterval(interval);
1036
+ return;
1037
+ }
1038
+ try {
1039
+ const status = await this.apiClient.getStatus(jobId);
1040
+ if (status.status === "completed" && status.imageUrl) {
1041
+ if (this.state === "processing") {
1042
+ this.handleVtoUpdate({
1043
+ galleryId: jobId,
1044
+ status: "completed",
1045
+ imageUrl: status.imageUrl,
1046
+ error: null,
1047
+ timestamp: Date.now()
1048
+ });
1049
+ }
1050
+ clearInterval(interval);
1051
+ } else if (status.status === "failed") {
1052
+ if (this.state === "processing") {
1053
+ this.handleVtoUpdate({
1054
+ galleryId: jobId,
1055
+ status: "failed",
1056
+ imageUrl: null,
1057
+ error: status.message,
1058
+ timestamp: Date.now()
1059
+ });
1060
+ }
1061
+ clearInterval(interval);
1062
+ }
1063
+ } catch {
1064
+ }
1065
+ }, 2e3);
1066
+ }
1067
+ handleDownload() {
1068
+ if (!this.resultImageUrl) return;
1069
+ const link = document.createElement("a");
1070
+ link.href = this.resultImageUrl;
1071
+ link.download = `primestyle-tryon-${Date.now()}.png`;
1072
+ link.target = "_blank";
1073
+ if (this.resultImageUrl.startsWith("data:")) {
1074
+ link.click();
1075
+ } else {
1076
+ fetch(this.resultImageUrl).then((r) => r.blob()).then((blob) => {
1077
+ const url = URL.createObjectURL(blob);
1078
+ link.href = url;
1079
+ link.click();
1080
+ setTimeout(() => URL.revokeObjectURL(url), 100);
1081
+ }).catch(() => {
1082
+ window.open(this.resultImageUrl, "_blank");
1083
+ });
1084
+ }
1085
+ }
1086
+ resetUpload() {
1087
+ if (this.previewUrl) {
1088
+ URL.revokeObjectURL(this.previewUrl);
1089
+ }
1090
+ this.selectedFile = null;
1091
+ this.previewUrl = null;
1092
+ this.resultImageUrl = null;
1093
+ this.errorMessage = null;
1094
+ this.currentJobId = null;
1095
+ if (this.sseUnsubscribe) {
1096
+ this.sseUnsubscribe();
1097
+ this.sseUnsubscribe = null;
1098
+ }
1099
+ }
1100
+ // ── Custom Style Application ────────────────
1101
+ applyButtonStyles() {
1102
+ const btn = this.shadow.querySelector(".ps-button");
1103
+ if (!btn) return;
1104
+ const map = {
1105
+ backgroundColor: "--ps-btn-bg",
1106
+ textColor: "--ps-btn-color",
1107
+ borderRadius: "--ps-btn-radius",
1108
+ fontSize: "--ps-btn-font-size",
1109
+ fontFamily: "--ps-btn-font",
1110
+ fontWeight: "--ps-btn-font-weight",
1111
+ padding: "--ps-btn-padding",
1112
+ border: "--ps-btn-border",
1113
+ width: "--ps-btn-width",
1114
+ height: "--ps-btn-height",
1115
+ hoverBackgroundColor: "--ps-btn-hover-bg",
1116
+ hoverTextColor: "--ps-btn-hover-color",
1117
+ iconSize: "--ps-btn-icon-size",
1118
+ iconColor: "--ps-btn-icon-color",
1119
+ boxShadow: "--ps-btn-shadow"
1120
+ };
1121
+ for (const [key, cssVar] of Object.entries(map)) {
1122
+ const value = this.buttonStyles[key];
1123
+ if (value) {
1124
+ this.style.setProperty(cssVar, value);
1125
+ }
1126
+ }
1127
+ }
1128
+ applyModalStyles() {
1129
+ const map = {
1130
+ overlayColor: "--ps-modal-overlay",
1131
+ backgroundColor: "--ps-modal-bg",
1132
+ textColor: "--ps-modal-color",
1133
+ borderRadius: "--ps-modal-radius",
1134
+ width: "--ps-modal-width",
1135
+ maxWidth: "--ps-modal-max-width",
1136
+ fontFamily: "--ps-modal-font",
1137
+ headerBackgroundColor: "--ps-modal-header-bg",
1138
+ headerTextColor: "--ps-modal-header-color",
1139
+ closeButtonColor: "--ps-modal-close-color",
1140
+ uploadBorderColor: "--ps-upload-border",
1141
+ uploadBackgroundColor: "--ps-upload-bg",
1142
+ uploadTextColor: "--ps-upload-color",
1143
+ uploadIconColor: "--ps-upload-icon-color",
1144
+ primaryButtonBackgroundColor: "--ps-modal-primary-bg",
1145
+ primaryButtonTextColor: "--ps-modal-primary-color",
1146
+ primaryButtonBorderRadius: "--ps-modal-primary-radius",
1147
+ loaderColor: "--ps-loader",
1148
+ resultBorderRadius: "--ps-result-radius"
1149
+ };
1150
+ for (const [key, cssVar] of Object.entries(map)) {
1151
+ const value = this.modalStyles[key];
1152
+ if (value) {
1153
+ this.style.setProperty(cssVar, value);
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ if (typeof window !== "undefined" && !customElements.get("primestyle-tryon")) {
1159
+ customElements.define("primestyle-tryon", PrimeStyleTryon);
1160
+ }
1161
+ export {
1162
+ ApiClient,
1163
+ PrimeStyleError,
1164
+ PrimeStyleTryon,
1165
+ SseClient,
1166
+ compressImage,
1167
+ detectProductImage,
1168
+ isValidImageFile
1169
+ };