@primestyleai/tryon 5.10.105 → 5.10.107

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.
@@ -1,46 +1,4 @@
1
1
  "use client";
2
- const TAG$3 = "[primestyle-shadow]";
3
- const collected = [];
4
- if (typeof document !== "undefined" && document.head) {
5
- let shouldCapture = function(node) {
6
- return node instanceof HTMLStyleElement;
7
- };
8
- const origAppend = document.head.appendChild.bind(document.head);
9
- const origInsertBefore = document.head.insertBefore.bind(document.head);
10
- document.head.appendChild = function(node) {
11
- if (shouldCapture(node)) {
12
- collected.push(node);
13
- console.log(`${TAG$3} captured style tag (${node.textContent?.length ?? 0} chars)`);
14
- }
15
- return origAppend(node);
16
- };
17
- document.head.insertBefore = function(node, ref) {
18
- if (shouldCapture(node)) {
19
- collected.push(node);
20
- console.log(`${TAG$3} captured style tag via insertBefore (${node.textContent?.length ?? 0} chars)`);
21
- }
22
- return origInsertBefore(node, ref);
23
- };
24
- }
25
- function getCollectedCss() {
26
- const parts = [];
27
- for (const el2 of collected) {
28
- const text = el2.textContent;
29
- if (text) parts.push(text);
30
- }
31
- return parts.join("\n\n");
32
- }
33
- function injectStylesIntoShadow(shadow) {
34
- const css = getCollectedCss();
35
- if (!css) {
36
- console.warn(`${TAG$3} no SDK styles collected — widget may render unstyled`);
37
- return;
38
- }
39
- const style = document.createElement("style");
40
- style.setAttribute("data-primestyle", "sdk-styles");
41
- style.textContent = css;
42
- shadow.appendChild(style);
43
- }
44
2
  var react = { exports: {} };
45
3
  var react_production_min = {};
46
4
  /**
@@ -9298,1564 +9256,190 @@ const locales = {
9298
9256
  for (const [code, translations] of Object.entries(locales)) {
9299
9257
  registerLocale(code, translations);
9300
9258
  }
9301
- var jsxRuntime = { exports: {} };
9302
- var reactJsxRuntime_production_min = {};
9303
- /**
9304
- * @license React
9305
- * react-jsx-runtime.production.min.js
9306
- *
9307
- * Copyright (c) Facebook, Inc. and its affiliates.
9308
- *
9309
- * This source code is licensed under the MIT license found in the
9310
- * LICENSE file in the root directory of this source tree.
9311
- */
9312
- var f = reactExports, k = Symbol.for("react.element"), l = Symbol.for("react.fragment"), m = Object.prototype.hasOwnProperty, n = f.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner, p = { key: true, ref: true, __self: true, __source: true };
9313
- function q(c, a, g) {
9314
- var b, d = {}, e = null, h = null;
9315
- void 0 !== g && (e = "" + g);
9316
- void 0 !== a.key && (e = "" + a.key);
9317
- void 0 !== a.ref && (h = a.ref);
9318
- for (b in a) m.call(a, b) && !p.hasOwnProperty(b) && (d[b] = a[b]);
9319
- if (c && c.defaultProps) for (b in a = c.defaultProps, a) void 0 === d[b] && (d[b] = a[b]);
9320
- return { $$typeof: k, type: c, key: e, ref: h, props: d, _owner: n.current };
9321
- }
9322
- reactJsxRuntime_production_min.Fragment = l;
9323
- reactJsxRuntime_production_min.jsx = q;
9324
- reactJsxRuntime_production_min.jsxs = q;
9325
- {
9326
- jsxRuntime.exports = reactJsxRuntime_production_min;
9327
- }
9328
- var jsxRuntimeExports = jsxRuntime.exports;
9329
- const STORAGE_KEY = "ps_session";
9330
- const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
9331
- const FALLBACK_PREFIX = "ps_mem_";
9332
- let memoryFallback = null;
9333
- function hasLocalStorage() {
9334
- try {
9335
- return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
9336
- } catch {
9337
- return false;
9259
+ const STYLES$1 = `
9260
+ /* Variable defaults must live on BOTH the root (for the trigger button)
9261
+ and the overlay (which is React-portaled to <body> and therefore not
9262
+ a descendant of .ps-tryon-root, so the cascade is broken). Without
9263
+ this duplication the slider track gradient and the thumb border —
9264
+ both var(--ps-accent) — render as unset and become invisible. */
9265
+ .ps-tryon-root,
9266
+ .ps-tryon-overlay {
9267
+ --ps-accent: #3B82F6;
9268
+ --ps-accent-hover: #2563EB;
9269
+ --ps-accent-light: #BFDBFE;
9270
+ --ps-text-primary: #1C1D1E;
9271
+ --ps-text-secondary: #454545;
9272
+ --ps-text-muted: #84898C;
9273
+ --ps-text-dim: #696E71;
9274
+ --ps-border-color: #E7E7E7;
9275
+ --ps-border-subtle: #F0F0F0;
9276
+ --ps-bg-primary: #FFFFFF;
9277
+ --ps-bg-secondary: #F8FAFF;
9278
+ --ps-bg-tertiary: #F0F0F0;
9279
+ --ps-input-bg: #FFFFFF;
9280
+ --ps-input-border: #E7E7E7;
9281
+ --ps-input-focus-border: var(--ps-accent);
9282
+ --ps-error-color: #E7000B;
9283
+ --ps-success-color: #1C9D4C;
9284
+ --ps-warning-color: #DF8400;
9285
+ --ps-logo-height: 2.8vw;
9338
9286
  }
9339
- }
9340
- function uuid() {
9341
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
9342
- return crypto.randomUUID();
9287
+ .ps-tryon-root { display: inline-block; visibility: visible !important; }
9288
+
9289
+ .ps-tryon-btn {
9290
+ display: inline-flex; align-items: center; gap: max(6px, 0.42vw);
9291
+ padding: var(--ps-btn-padding, max(10px, 0.63vw) max(16px, 1.25vw));
9292
+ background: var(--ps-btn-bg, var(--ps-accent)); color: var(--ps-btn-color, #FFFFFF);
9293
+ font-family: var(--ps-btn-font, system-ui, -apple-system, sans-serif);
9294
+ font-size: var(--ps-btn-font-size, max(13px, 0.73vw)); font-weight: var(--ps-btn-font-weight, 600);
9295
+ border: var(--ps-btn-border, none); border-radius: var(--ps-btn-radius, max(6px, 0.42vw));
9296
+ cursor: pointer; transition: all 0.2s ease;
9297
+ width: var(--ps-btn-width, auto); height: var(--ps-btn-height, auto);
9298
+ box-shadow: var(--ps-btn-shadow, none); line-height: 1; white-space: nowrap;
9299
+ box-sizing: border-box;
9343
9300
  }
9344
- const chars = "0123456789abcdef";
9345
- let out = FALLBACK_PREFIX;
9346
- for (let i = 0; i < 32; i++) out += chars[Math.floor(Math.random() * 16)];
9347
- return out;
9348
- }
9349
- function readRecord() {
9350
- if (!hasLocalStorage()) return memoryFallback;
9351
- try {
9352
- const raw = window.localStorage.getItem(STORAGE_KEY);
9353
- if (!raw) return null;
9354
- const parsed = JSON.parse(raw);
9355
- if (typeof parsed.id !== "string" || typeof parsed.issuedAt !== "number") return null;
9356
- return {
9357
- id: parsed.id,
9358
- issuedAt: parsed.issuedAt,
9359
- lastSeenAt: typeof parsed.lastSeenAt === "number" ? parsed.lastSeenAt : parsed.issuedAt
9360
- };
9361
- } catch {
9362
- return null;
9301
+ .ps-tryon-btn svg { width: var(--ps-btn-icon-size, max(16px, 1.1vw)); height: var(--ps-btn-icon-size, max(16px, 1.1vw)); flex-shrink: 0; }
9302
+ .ps-tryon-btn:hover { background: var(--ps-btn-hover-bg, var(--ps-accent-hover)); transform: translateY(-1px); }
9303
+ .ps-tryon-btn:active { transform: translateY(0); }
9304
+
9305
+ .ps-tryon-overlay {
9306
+ position: fixed; inset: 0; background: var(--ps-modal-overlay, rgba(0,0,0,0.6));
9307
+ display: flex; align-items: center; justify-content: center;
9308
+ z-index: 2147483647;
9309
+ isolation: isolate;
9310
+ contain: layout style;
9311
+ padding: 0.83vw;
9312
+ padding: max(0.83vw, env(safe-area-inset-top)) max(0.83vw, env(safe-area-inset-right)) max(0.83vw, env(safe-area-inset-bottom)) max(0.83vw, env(safe-area-inset-left));
9313
+ animation: ps-fade-in 0.2s ease;
9314
+ overflow-y: auto; -webkit-overflow-scrolling: touch;
9315
+ font-family: system-ui, -apple-system, sans-serif;
9316
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
9317
+ box-sizing: border-box;
9363
9318
  }
9364
- }
9365
- function writeRecord(record) {
9366
- memoryFallback = record;
9367
- if (!hasLocalStorage()) return;
9368
- try {
9369
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
9370
- } catch {
9319
+ .ps-tryon-overlay, .ps-tryon-overlay *, .ps-tryon-overlay *::before, .ps-tryon-overlay *::after { box-sizing: border-box; }
9320
+ @keyframes ps-fade-in { from { opacity: 0; } to { opacity: 1; } }
9321
+
9322
+ .ps-tryon-modal {
9323
+ background: var(--ps-modal-bg, #FFFFFF); color: var(--ps-modal-color, #1C1D1E);
9324
+ border-radius: clamp(8px, 0.83vw, 16px); width: var(--ps-modal-width, 100%);
9325
+ max-width: var(--ps-modal-max-width, 36vw); max-height: 92dvh; overflow-y: auto;
9326
+ font-family: var(--ps-modal-font, system-ui, -apple-system, sans-serif);
9327
+ box-shadow: 0 8px 40px rgba(0,0,0,0.12); animation: ps-slide-up 0.3s ease;
9328
+ scrollbar-width: thin; scrollbar-color: var(--ps-border-color) transparent;
9329
+ flex-shrink: 0;
9330
+ container-type: inline-size;
9371
9331
  }
9372
- }
9373
- function getOrCreateSessionId() {
9374
- const now = Date.now();
9375
- const existing = readRecord();
9376
- if (existing && now - existing.lastSeenAt < MAX_AGE_MS) {
9377
- if (now - existing.lastSeenAt > 5 * 60 * 1e3) {
9378
- writeRecord({ ...existing, lastSeenAt: now });
9379
- }
9380
- return existing.id;
9332
+ .ps-tryon-modal-wide {
9333
+ /* Use the SMALLER of 55vw or 92dvh×(16/10) for width so the modal
9334
+ never gets wider than the height can support. Height bumped from
9335
+ 70dvh 82dvh to fit the longer accessory photo step (guidelines +
9336
+ legal notice + nav) without pushing content under the Analyze CTA. */
9337
+ width: min(55vw, calc(92dvh * 1.6));
9338
+ max-width: min(55vw, calc(92dvh * 1.6));
9339
+ height: min(82dvh, calc(55vw / 1.35));
9340
+ max-height: min(92dvh, calc(55vw / 1.35));
9341
+ display: flex; flex-direction: column; overflow: hidden;
9342
+ transition: height 0.45s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.45s cubic-bezier(0.32, 0.72, 0, 1);
9381
9343
  }
9382
- const fresh = { id: uuid(), issuedAt: now, lastSeenAt: now };
9383
- writeRecord(fresh);
9384
- return fresh.id;
9385
- }
9386
- function getDeviceHint() {
9387
- if (typeof navigator === "undefined") return null;
9388
- const ua2 = (navigator.userAgent || "").toLowerCase();
9389
- if (/ipad|tablet|(android(?!.*mobile))/.test(ua2)) return "tablet";
9390
- if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/.test(ua2)) return "mobile";
9391
- return "desktop";
9392
- }
9393
- const DEFAULT_API_URL = "http://localhost:4000";
9394
- class ApiClient {
9395
- constructor(apiKey, apiUrl) {
9396
- this.apiKey = apiKey;
9397
- this.baseUrl = (apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
9344
+ .ps-tryon-modal-tall.ps-tryon-modal-wide {
9345
+ height: min(92dvh, calc(55vw / 1.2));
9346
+ max-height: min(95dvh, calc(55vw / 1.2));
9398
9347
  }
9399
- get headers() {
9400
- return {
9401
- "Content-Type": "application/json",
9402
- Authorization: `Bearer ${this.apiKey}`
9403
- };
9348
+ .ps-tryon-modal:has(.ps-tryon-drawer-open) { overflow: hidden; }
9349
+ @keyframes ps-slide-up { from { transform: translateY(12px) scale(0.97); opacity: 0; filter: blur(4px); } to { transform: none; opacity: 1; filter: none; } }
9350
+
9351
+ /* Header */
9352
+ .ps-tryon-header {
9353
+ display: flex; align-items: center; justify-content: space-between;
9354
+ padding: max(8px, 0.7vw) max(12px, 1.25vw);
9355
+ background: var(--ps-modal-header-bg, rgba(255,255,255,0.95));
9356
+ border-bottom: 1px solid var(--ps-border-color);
9357
+ border-radius: max(8px, 0.83vw) max(8px, 0.83vw) 0 0;
9358
+ backdrop-filter: blur(12px); flex-shrink: 0;
9404
9359
  }
9405
- async submitTryOn(modelImage, garmentImage, fitInfo, category, context) {
9406
- const body = {
9407
- garmentImage,
9408
- // Attribution fields — backend logs these into the TryOnEvent collection
9409
- // so non-technical operators can answer "which products are being tried on
9410
- // and by how many customers". Safe fallbacks when absent.
9411
- sessionId: getOrCreateSessionId(),
9412
- deviceHint: getDeviceHint()
9413
- };
9414
- if (context?.modelImageId) body.modelImageId = context.modelImageId;
9415
- if (modelImage) body.modelImage = modelImage;
9416
- if (fitInfo && fitInfo.length > 0) body.fitInfo = fitInfo;
9417
- if (category && category !== "apparel") body.category = category;
9418
- if (context?.productId) body.productId = context.productId;
9419
- if (context?.productTitle) body.productTitle = context.productTitle;
9420
- if (context?.productDescription) body.productDescription = context.productDescription;
9421
- if (context?.productMaterial) body.productMaterial = context.productMaterial;
9422
- if (context?.silhouetteContext) body.silhouetteContext = context.silhouetteContext;
9423
- if (context?.editFromPrevious) body.editFromPrevious = true;
9424
- console.log("[ps-sdk:api] POST /api/v1/tryon payload", {
9425
- modelImageBytes: modelImage.length,
9426
- garmentImageBytes: garmentImage.length,
9427
- category: category || "apparel",
9428
- fitInfo: fitInfo?.map((f2) => ({
9429
- area: f2.area,
9430
- fit: f2.fit,
9431
- userValue: f2.userValue,
9432
- garmentRange: f2.garmentRange
9433
- })) || null,
9434
- silhouetteContext: context?.silhouetteContext || null
9435
- });
9436
- if (fitInfo && fitInfo.length > 0) {
9437
- console.log("[ps-sdk:api] Body vs Garment (what Gemini will see):");
9438
- for (const f2 of fitInfo) {
9439
- const parts = (f2.garmentRange || "").replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
9440
- let gap = "";
9441
- if (parts.length && f2.userValue != null) {
9442
- const mid = (Math.min(...parts) + Math.max(...parts)) / 2;
9443
- const diff = Math.round((f2.userValue - mid) * 10) / 10;
9444
- gap = diff > 0 ? `garment ${diff} smaller` : diff < 0 ? `garment ${Math.abs(diff)} larger` : "matched";
9445
- }
9446
- console.log(`[ps-sdk:api] ${f2.area.padEnd(10)} body=${f2.userValue ?? "?"} garment=${f2.garmentRange ?? "?"} → ${gap}`);
9447
- }
9448
- }
9449
- const res = await fetch(`${this.baseUrl}/api/v1/tryon`, {
9450
- method: "POST",
9451
- headers: this.headers,
9452
- body: JSON.stringify(body)
9453
- });
9454
- if (!res.ok) {
9455
- const data = await res.json().catch(() => ({}));
9456
- if (res.status === 402) {
9457
- throw new PrimeStyleError(
9458
- data.message || "Insufficient try-ons",
9459
- "INSUFFICIENT_BALANCE"
9460
- );
9461
- }
9462
- throw new PrimeStyleError(
9463
- data.message || "Failed to submit try-on",
9464
- "API_ERROR"
9465
- );
9466
- }
9467
- return res.json();
9360
+ .ps-tryon-header-minimal {
9361
+ justify-content: flex-end; gap: 0.4vw; padding: 0.4vw 0.8vw;
9468
9362
  }
9469
- async getStatus(jobId) {
9470
- const res = await fetch(`${this.baseUrl}/api/v1/tryon/status/${jobId}`, {
9471
- headers: this.headers
9472
- });
9473
- if (!res.ok) {
9474
- const data = await res.json().catch(() => ({}));
9475
- throw new PrimeStyleError(
9476
- data.message || "Failed to get status",
9477
- "API_ERROR"
9478
- );
9479
- }
9480
- return res.json();
9363
+ .ps-tryon-back-btn {
9364
+ display: flex; align-items: center; gap: 0.35vw;
9365
+ background: none; border: none; color: var(--ps-text-secondary); cursor: pointer;
9366
+ font-size: 0.73vw; padding: 0; margin-bottom: 0.63vw;
9367
+ transition: color 0.2s;
9481
9368
  }
9482
- getStreamUrl() {
9483
- return `${this.baseUrl}/api/v1/tryon/stream?key=${encodeURIComponent(this.apiKey)}`;
9369
+ .ps-tryon-back-btn:hover { color: var(--ps-text-primary); }
9370
+ .ps-tryon-back-btn svg { width: 1vw; height: 0.63vw; stroke: currentColor; flex-shrink: 0; }
9371
+
9372
+ /* Regen progress bar — absolutely-positioned fill div inside the
9373
+ "Generating new try-on…" button. Animation runs at the browser's
9374
+ refresh rate (smooth) instead of via React state ticks. The button
9375
+ itself sets position: relative + overflow: hidden so the fill is
9376
+ clipped to the button's rounded shape. */
9377
+ @keyframes ps-tryon-regen-fill {
9378
+ from { width: 0%; }
9379
+ to { width: 100%; }
9484
9380
  }
9485
- }
9486
- class PrimeStyleError extends Error {
9487
- constructor(message, code) {
9488
- super(message);
9489
- this.name = "PrimeStyleError";
9490
- this.code = code;
9381
+ .ps-tryon-regen-fill {
9382
+ position: absolute;
9383
+ left: 0; top: 0; bottom: 0;
9384
+ width: 0%;
9385
+ background: rgba(255, 255, 255, 0.28);
9386
+ animation: ps-tryon-regen-fill 22s linear forwards;
9387
+ pointer-events: none;
9388
+ z-index: 0;
9491
9389
  }
9492
- }
9493
- class SseClient {
9494
- constructor(streamUrl) {
9495
- this.eventSource = null;
9496
- this.listeners = /* @__PURE__ */ new Map();
9497
- this.reconnectTimer = null;
9498
- this.reconnectAttempts = 0;
9499
- this.maxReconnectAttempts = 5;
9500
- this.streamUrl = streamUrl;
9390
+ .ps-tryon-title { display: flex; align-items: center; }
9391
+ .ps-tryon-logo-img { height: var(--ps-logo-height); width: auto; }
9392
+ .ps-tryon-header-actions { display: flex; align-items: center; gap: 0.42vw; }
9393
+ .ps-tryon-header-icon {
9394
+ /* Pure vw sizing collapsed to ~8 px on 375 px mobile. Clamp so the icon
9395
+ stays finger-tappable (min 30 px) while scaling up on large screens. */
9396
+ width: clamp(30px, 2.2vw, 34px); height: clamp(30px, 2.2vw, 34px);
9397
+ display: flex; align-items: center; justify-content: center;
9398
+ border: 1.5px solid var(--ps-border-color); border-radius: clamp(6px, 0.52vw, 10px); background: transparent;
9399
+ cursor: pointer; color: var(--ps-text-secondary); transition: all 0.2s;
9501
9400
  }
9502
- connect() {
9503
- if (this.eventSource) return;
9504
- this.eventSource = new EventSource(this.streamUrl);
9505
- this.eventSource.addEventListener("vto-update", (event) => {
9506
- try {
9507
- const data = JSON.parse(event.data);
9508
- this.emit(data.galleryId, data);
9509
- } catch {
9510
- }
9511
- });
9512
- this.eventSource.onopen = () => {
9513
- this.reconnectAttempts = 0;
9514
- };
9515
- this.eventSource.onerror = () => {
9516
- this.eventSource?.close();
9517
- this.eventSource = null;
9518
- this.scheduleReconnect();
9519
- };
9401
+ .ps-tryon-header-icon:hover { border-color: var(--ps-accent); color: var(--ps-accent); }
9402
+ .ps-tryon-header-icon svg { stroke: currentColor; fill: none; width: clamp(14px, 0.9vw, 16px); height: clamp(14px, 0.9vw, 16px); }
9403
+ .ps-tryon-close {
9404
+ width: 2.2vw; height: 2.2vw; display: flex; align-items: center; justify-content: center;
9405
+ background: none; border: none; color: var(--ps-modal-close-color, #999);
9406
+ cursor: pointer; border-radius: 0.31vw; transition: background 0.15s;
9520
9407
  }
9521
- scheduleReconnect() {
9522
- if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
9523
- if (this.listeners.size === 0) return;
9524
- const delay = Math.min(1e3 * 2 ** this.reconnectAttempts, 3e4);
9525
- this.reconnectAttempts++;
9526
- this.reconnectTimer = setTimeout(() => {
9527
- this.connect();
9528
- }, delay);
9408
+ .ps-tryon-close:hover { background: rgba(0,0,0,0.06); }
9409
+ .ps-tryon-close svg { width: 1vw; height: 1vw; }
9410
+
9411
+ /* Language switcher */
9412
+ .ps-tryon-lang-wrap { position: relative; z-index: 99999; }
9413
+ .ps-tryon-lang-trigger {
9414
+ display: flex; align-items: center; gap: clamp(5px, 0.36vw, 7px);
9415
+ padding: clamp(6px, 0.36vw, 8px) clamp(10px, 0.73vw, 14px);
9416
+ border: 1.5px solid var(--ps-border-color); border-radius: clamp(8px, 0.57vw, 10px);
9417
+ background: transparent; cursor: pointer; color: var(--ps-text-secondary);
9418
+ transition: all 0.25s ease; font-family: inherit; white-space: nowrap;
9419
+ min-height: clamp(28px, 2.2vw, 38px);
9529
9420
  }
9530
- onJob(jobId, callback) {
9531
- if (!this.listeners.has(jobId)) {
9532
- this.listeners.set(jobId, /* @__PURE__ */ new Set());
9533
- }
9534
- this.listeners.get(jobId).add(callback);
9535
- if (!this.eventSource) {
9536
- this.connect();
9537
- }
9538
- return () => {
9539
- const jobListeners = this.listeners.get(jobId);
9540
- if (jobListeners) {
9541
- jobListeners.delete(callback);
9542
- if (jobListeners.size === 0) {
9543
- this.listeners.delete(jobId);
9544
- }
9545
- }
9546
- if (this.listeners.size === 0) {
9547
- this.disconnect();
9548
- }
9549
- };
9421
+ .ps-tryon-lang-trigger:hover, .ps-tryon-lang-trigger.ps-active {
9422
+ border-color: var(--ps-accent); color: var(--ps-accent); background: rgba(33,84,239,0.06);
9550
9423
  }
9551
- emit(jobId, update) {
9552
- const callbacks = this.listeners.get(jobId);
9553
- if (callbacks) {
9554
- callbacks.forEach((cb2) => cb2(update));
9555
- }
9424
+ .ps-tryon-lang-trigger svg { stroke: currentColor; fill: none; flex-shrink: 0; width: clamp(13px, 0.8vw, 16px); height: clamp(13px, 0.8vw, 16px); }
9425
+ .ps-tryon-lang-current {
9426
+ font-size: clamp(12px, 0.68vw, 14px); font-weight: 500; letter-spacing: 0.01em;
9556
9427
  }
9557
- disconnect() {
9558
- if (this.reconnectTimer) {
9559
- clearTimeout(this.reconnectTimer);
9560
- this.reconnectTimer = null;
9561
- }
9562
- if (this.eventSource) {
9563
- this.eventSource.close();
9564
- this.eventSource = null;
9565
- }
9566
- this.listeners.clear();
9567
- this.reconnectAttempts = 0;
9428
+ .ps-tryon-lang-arrow {
9429
+ font-size: clamp(10px, 0.57vw, 12px); transition: transform 0.25s ease; display: inline-block;
9568
9430
  }
9569
- }
9570
- const DEFAULT_MAX_DIMENSION = 512;
9571
- const DEFAULT_JPEG_QUALITY = 0.65;
9572
- function compressImage(file, opts = {}) {
9573
- const maxDim = opts.maxDimension ?? DEFAULT_MAX_DIMENSION;
9574
- const quality = opts.quality ?? DEFAULT_JPEG_QUALITY;
9575
- return new Promise((resolve, reject) => {
9576
- const reader = new FileReader();
9577
- reader.onload = () => {
9578
- const img = new Image();
9579
- img.onload = () => {
9580
- try {
9581
- const canvas = document.createElement("canvas");
9582
- let { width, height } = img;
9583
- if (width > maxDim || height > maxDim) {
9584
- if (width > height) {
9585
- height = Math.round(height * maxDim / width);
9586
- width = maxDim;
9587
- } else {
9588
- width = Math.round(width * maxDim / height);
9589
- height = maxDim;
9590
- }
9591
- }
9592
- canvas.width = width;
9593
- canvas.height = height;
9594
- const ctx = canvas.getContext("2d");
9595
- if (!ctx) {
9596
- reject(new Error("Canvas context not available"));
9597
- return;
9598
- }
9599
- ctx.drawImage(img, 0, 0, width, height);
9600
- const dataUrl = canvas.toDataURL("image/jpeg", quality);
9601
- resolve(dataUrl);
9602
- } catch (err) {
9603
- reject(err);
9604
- }
9605
- };
9606
- img.onerror = () => reject(new Error("Failed to load image"));
9607
- img.src = reader.result;
9608
- };
9609
- reader.onerror = () => reject(new Error("Failed to read file"));
9610
- reader.readAsDataURL(file);
9611
- });
9612
- }
9613
- function isValidImageFile(file) {
9614
- const accepted = ["image/jpeg", "image/png", "image/webp", "image/avif"];
9615
- return accepted.includes(file.type);
9616
- }
9617
- async function checkAgeBeforeUpload(imageOrBase64, apiUrl, apiKey) {
9618
- try {
9619
- const base64 = typeof imageOrBase64 === "string" ? imageOrBase64 : await compressImage(imageOrBase64, { maxDimension: 384, quality: 0.6 });
9620
- const resp = await fetch(`${apiUrl}/api/v1/sizing/age-check`, {
9621
- method: "POST",
9622
- headers: {
9623
- "Content-Type": "application/json",
9624
- Authorization: `Bearer ${apiKey}`
9625
- },
9626
- body: JSON.stringify({ bodyImage: base64 })
9627
- });
9628
- if (!resp.ok) {
9629
- console.warn(`[PS-SDK:AgeCheck] HTTP ${resp.status} — failing open`);
9630
- return { isAdult: true, confidence: "low" };
9631
- }
9632
- const data = await resp.json();
9633
- return {
9634
- isAdult: data?.isAdult !== false,
9635
- // anything other than explicit false → allow
9636
- confidence: data?.confidence === "high" ? "high" : "low",
9637
- reasoning: typeof data?.reasoning === "string" ? data.reasoning : void 0
9638
- };
9639
- } catch (err) {
9640
- console.warn(`[PS-SDK:AgeCheck] error — failing open:`, err);
9641
- return { isAdult: true, confidence: "low" };
9642
- }
9643
- }
9644
- const DB_NAME = "primestyle-photos";
9645
- const STORE = "photos";
9646
- const VERSION = 1;
9647
- let _dbPromise = null;
9648
- function openDb() {
9649
- if (_dbPromise) return _dbPromise;
9650
- _dbPromise = new Promise((resolve) => {
9651
- try {
9652
- if (typeof indexedDB === "undefined") return resolve(null);
9653
- const req = indexedDB.open(DB_NAME, VERSION);
9654
- req.onupgradeneeded = () => {
9655
- const db2 = req.result;
9656
- if (!db2.objectStoreNames.contains(STORE)) {
9657
- db2.createObjectStore(STORE);
9658
- }
9659
- };
9660
- req.onsuccess = () => resolve(req.result);
9661
- req.onerror = () => resolve(null);
9662
- req.onblocked = () => resolve(null);
9663
- } catch {
9664
- resolve(null);
9665
- }
9666
- });
9667
- return _dbPromise;
9668
- }
9669
- async function savePhoto(id2, blob) {
9670
- const db2 = await openDb();
9671
- if (!db2) return false;
9672
- return new Promise((resolve) => {
9673
- try {
9674
- const tx = db2.transaction(STORE, "readwrite");
9675
- tx.objectStore(STORE).put(blob, id2);
9676
- tx.oncomplete = () => resolve(true);
9677
- tx.onerror = () => resolve(false);
9678
- tx.onabort = () => resolve(false);
9679
- } catch {
9680
- resolve(false);
9681
- }
9682
- });
9683
- }
9684
- async function getPhoto(id2) {
9685
- const db2 = await openDb();
9686
- if (!db2) return null;
9687
- return new Promise((resolve) => {
9688
- try {
9689
- const tx = db2.transaction(STORE, "readonly");
9690
- const req = tx.objectStore(STORE).get(id2);
9691
- req.onsuccess = () => resolve(req.result instanceof Blob ? req.result : null);
9692
- req.onerror = () => resolve(null);
9693
- } catch {
9694
- resolve(null);
9695
- }
9696
- });
9697
- }
9698
- async function pruneToIds(keepIds) {
9699
- const db2 = await openDb();
9700
- if (!db2) return;
9701
- return new Promise((resolve) => {
9702
- try {
9703
- const tx = db2.transaction(STORE, "readwrite");
9704
- const store = tx.objectStore(STORE);
9705
- const cursorReq = store.openKeyCursor();
9706
- cursorReq.onsuccess = () => {
9707
- const cursor = cursorReq.result;
9708
- if (!cursor) return;
9709
- const key = String(cursor.key);
9710
- if (!keepIds.has(key)) cursor.delete();
9711
- cursor.continue();
9712
- };
9713
- tx.oncomplete = () => resolve();
9714
- tx.onerror = () => resolve();
9715
- } catch {
9716
- resolve();
9717
- }
9718
- });
9719
- }
9720
- const LEFT_SHOULDER = 11;
9721
- const RIGHT_SHOULDER = 12;
9722
- const LEFT_ELBOW = 13;
9723
- const RIGHT_ELBOW = 14;
9724
- const LEFT_WRIST = 15;
9725
- const RIGHT_WRIST = 16;
9726
- const LEFT_HIP = 23;
9727
- const RIGHT_HIP = 24;
9728
- const LEFT_KNEE = 25;
9729
- const RIGHT_KNEE = 26;
9730
- const LEFT_ANKLE = 27;
9731
- const RIGHT_ANKLE = 28;
9732
- const NOSE = 0;
9733
- let poseLandmarker = null;
9734
- let loadingPromise$1 = null;
9735
- async function loadMediaPipe() {
9736
- if (poseLandmarker) return;
9737
- if (loadingPromise$1) return loadingPromise$1;
9738
- loadingPromise$1 = (async () => {
9739
- const vision = await import(
9740
- /* webpackIgnore: true */
9741
- // @ts-ignore dynamic CDN import
9742
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
9743
- );
9744
- const { FilesetResolver, PoseLandmarker } = vision;
9745
- const filesetResolver = await FilesetResolver.forVisionTasks(
9746
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
9747
- );
9748
- poseLandmarker = await PoseLandmarker.createFromOptions(filesetResolver, {
9749
- baseOptions: {
9750
- modelAssetPath: "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task",
9751
- delegate: "GPU"
9752
- },
9753
- runningMode: "IMAGE",
9754
- numPoses: 1
9755
- });
9756
- })();
9757
- return loadingPromise$1;
9758
- }
9759
- async function detectMeasurementLines(imageSrc) {
9760
- try {
9761
- await loadMediaPipe();
9762
- const img = await loadImage$1(imageSrc);
9763
- const result = poseLandmarker.detect(img);
9764
- if (!result?.landmarks?.length || result.landmarks[0].length < 25) {
9765
- return null;
9766
- }
9767
- const landmarks = result.landmarks[0];
9768
- const lShoulder = landmarks[LEFT_SHOULDER];
9769
- const rShoulder = landmarks[RIGHT_SHOULDER];
9770
- const lHip = landmarks[LEFT_HIP];
9771
- const rHip = landmarks[RIGHT_HIP];
9772
- const shoulderY = (lShoulder.y + rShoulder.y) / 2;
9773
- const hipY = (lHip.y + rHip.y) / 2;
9774
- const torsoLen = hipY - shoulderY;
9775
- const chestY = shoulderY + torsoLen * 0.28;
9776
- const shoulderWidth = Math.abs(rShoulder.x - lShoulder.x);
9777
- const chestX1 = Math.min(lShoulder.x, rShoulder.x) - shoulderWidth * 0.05;
9778
- const chestX2 = Math.max(lShoulder.x, rShoulder.x) + shoulderWidth * 0.05;
9779
- const hipWidth = Math.abs(rHip.x - lHip.x);
9780
- const hipX1 = Math.min(lHip.x, rHip.x) - hipWidth * 0.15;
9781
- const hipX2 = Math.max(lHip.x, rHip.x) + hipWidth * 0.15;
9782
- const waistY = chestY + (hipY - chestY) * 0.55;
9783
- const waistWidth = shoulderWidth * 0.85;
9784
- const waistCenterX = (lShoulder.x + rShoulder.x + lHip.x + rHip.x) / 4;
9785
- const waistX1 = waistCenterX - waistWidth / 2;
9786
- const waistX2 = waistCenterX + waistWidth / 2;
9787
- return {
9788
- chest: { y: chestY, x1: chestX1, x2: chestX2 },
9789
- waist: { y: waistY, x1: waistX1, x2: waistX2 },
9790
- hips: { y: hipY, x1: hipX1, x2: hipX2 }
9791
- };
9792
- } catch (err) {
9793
- console.error("[PS-SDK] Pose detection failed:", err);
9794
- return null;
9795
- }
9796
- }
9797
- function loadImage$1(src) {
9798
- return new Promise((resolve, reject) => {
9799
- const img = new Image();
9800
- img.crossOrigin = "anonymous";
9801
- img.onload = () => resolve(img);
9802
- img.onerror = () => {
9803
- const img2 = new Image();
9804
- img2.onload = () => resolve(img2);
9805
- img2.onerror = () => reject(new Error("Failed to load image"));
9806
- img2.src = src;
9807
- };
9808
- img.src = src;
9809
- });
9810
- }
9811
- async function downsampleIfNeeded(img, maxLongEdge = 1024) {
9812
- const w2 = img.naturalWidth || img.width;
9813
- const h = img.naturalHeight || img.height;
9814
- const longEdge = Math.max(w2, h);
9815
- if (longEdge <= maxLongEdge) return img;
9816
- const scale = maxLongEdge / longEdge;
9817
- const newW = Math.round(w2 * scale);
9818
- const newH = Math.round(h * scale);
9819
- const canvas = document.createElement("canvas");
9820
- canvas.width = newW;
9821
- canvas.height = newH;
9822
- const ctx = canvas.getContext("2d");
9823
- if (!ctx) return img;
9824
- ctx.imageSmoothingQuality = "high";
9825
- ctx.drawImage(img, 0, 0, newW, newH);
9826
- console.log(`[PS-SDK:Downsample] ${w2}×${h} → ${newW}×${newH} (max long edge ${maxLongEdge}px)`);
9827
- return canvas;
9828
- }
9829
- async function detectBodyLandmarks(imageSrc) {
9830
- try {
9831
- await loadMediaPipe();
9832
- let img;
9833
- if (typeof imageSrc === "string") {
9834
- img = await loadImage$1(imageSrc);
9835
- } else {
9836
- img = imageSrc;
9837
- }
9838
- const inputForDetect = await downsampleIfNeeded(img, 1024);
9839
- const workingW = inputForDetect.width || img.naturalWidth || img.width;
9840
- const workingH = inputForDetect.height || img.naturalHeight || img.height;
9841
- const result = poseLandmarker.detect(inputForDetect);
9842
- if (!result?.landmarks?.length || result.landmarks[0].length < 29) {
9843
- console.warn("[PS-SDK:MediaPipe] detection returned no usable landmarks", {
9844
- gotLandmarks: !!result?.landmarks?.length,
9845
- jointCount: result?.landmarks?.[0]?.length ?? 0
9846
- });
9847
- return null;
9848
- }
9849
- const lm = result.landmarks[0];
9850
- const wlm = result.worldLandmarks?.[0] ?? null;
9851
- const pickPoint = (idx, useWorld = false) => {
9852
- const src = useWorld && wlm ? wlm[idx] : lm[idx];
9853
- const out2 = { x: src.x, y: src.y };
9854
- if (typeof src.z === "number") out2.z = src.z;
9855
- if (typeof src.visibility === "number") out2.visibility = src.visibility;
9856
- return out2;
9857
- };
9858
- const out = {
9859
- leftShoulder: pickPoint(LEFT_SHOULDER),
9860
- rightShoulder: pickPoint(RIGHT_SHOULDER),
9861
- leftHip: pickPoint(LEFT_HIP),
9862
- rightHip: pickPoint(RIGHT_HIP),
9863
- leftElbow: pickPoint(LEFT_ELBOW),
9864
- rightElbow: pickPoint(RIGHT_ELBOW),
9865
- leftWrist: pickPoint(LEFT_WRIST),
9866
- rightWrist: pickPoint(RIGHT_WRIST),
9867
- leftKnee: pickPoint(LEFT_KNEE),
9868
- rightKnee: pickPoint(RIGHT_KNEE),
9869
- leftAnkle: pickPoint(LEFT_ANKLE),
9870
- rightAnkle: pickPoint(RIGHT_ANKLE),
9871
- nose: pickPoint(NOSE),
9872
- imageWidth: workingW,
9873
- imageHeight: workingH
9874
- };
9875
- const isJointPoint = (v2) => !!v2 && typeof v2 === "object" && typeof v2.x === "number" && typeof v2.y === "number";
9876
- const jointCount = Object.values(out).filter(isJointPoint).length;
9877
- const hasZ = lm.some((p2) => typeof p2.z === "number");
9878
- const hasWorld = !!wlm;
9879
- console.groupCollapsed(`[PS-SDK:MediaPipe] ✓ ${jointCount} joints detected @ ${out.imageWidth}×${out.imageHeight}px (z=${hasZ ? "yes" : "no"}, worldLandmarks=${hasWorld ? "yes" : "no"})`);
9880
- for (const [name, v2] of Object.entries(out)) {
9881
- if (!isJointPoint(v2)) continue;
9882
- const p2 = v2;
9883
- const z2 = typeof p2.z === "number" ? `, z=${p2.z.toFixed(3)}` : "";
9884
- const vis = typeof p2.visibility === "number" ? `, vis=${p2.visibility.toFixed(2)}` : "";
9885
- console.log(` ${name.padEnd(15)} (${p2.x.toFixed(3)}, ${p2.y.toFixed(3)}${z2}${vis})`);
9886
- }
9887
- console.log(` imageWidth ${out.imageWidth}`);
9888
- console.log(` imageHeight ${out.imageHeight}`);
9889
- console.groupEnd();
9890
- return out;
9891
- } catch (err) {
9892
- console.error("[PS-SDK:MediaPipe] Body landmark detection failed:", err);
9893
- return null;
9894
- }
9895
- }
9896
- const IDX = {
9897
- noseTip: 1,
9898
- noseBridge: 168,
9899
- leftInnerEye: 133,
9900
- rightInnerEye: 362,
9901
- leftOuterEye: 33,
9902
- rightOuterEye: 263,
9903
- // Iris landmarks (478-point model with iris refinement)
9904
- leftIrisCenter: 468,
9905
- leftIrisRing: [469, 470, 471, 472],
9906
- rightIrisCenter: 473,
9907
- rightIrisRing: [474, 475, 476, 477],
9908
- // Tragus (ear attach point) — best approximations in the face mesh
9909
- leftTragus: 234,
9910
- rightTragus: 454,
9911
- forehead: 10,
9912
- chin: 152,
9913
- leftMouth: 61,
9914
- rightMouth: 291
9915
- };
9916
- const IRIS_DIAMETER_MM = 11.7;
9917
- let faceLandmarker = null;
9918
- let loadingPromise = null;
9919
- async function loadFaceLandmarker() {
9920
- if (faceLandmarker) {
9921
- console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — already loaded, reusing");
9922
- return;
9923
- }
9924
- if (loadingPromise) {
9925
- console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — awaiting in-flight load");
9926
- return loadingPromise;
9927
- }
9928
- console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — starting fresh init");
9929
- loadingPromise = (async () => {
9930
- try {
9931
- console.log("[PS-SDK:FaceDetect] importing vision_bundle.mjs from CDN…");
9932
- const t0 = performance.now();
9933
- const vision = await import(
9934
- /* webpackIgnore: true */
9935
- // @ts-ignore dynamic CDN import
9936
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
9937
- );
9938
- console.log(`[PS-SDK:FaceDetect] vision bundle imported in ${Math.round(performance.now() - t0)}ms`);
9939
- const { FilesetResolver, FaceLandmarker } = vision;
9940
- if (!FilesetResolver || !FaceLandmarker) {
9941
- console.error("[PS-SDK:FaceDetect] vision bundle missing expected exports", { keys: Object.keys(vision) });
9942
- throw new Error("MediaPipe vision bundle returned no FilesetResolver/FaceLandmarker");
9943
- }
9944
- console.log("[PS-SDK:FaceDetect] resolving WASM filesets…");
9945
- const t1 = performance.now();
9946
- const filesetResolver = await FilesetResolver.forVisionTasks(
9947
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
9948
- );
9949
- console.log(`[PS-SDK:FaceDetect] WASM resolver ready in ${Math.round(performance.now() - t1)}ms`);
9950
- console.log("[PS-SDK:FaceDetect] creating FaceLandmarker (GPU delegate, model=latest)…");
9951
- const t2 = performance.now();
9952
- faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
9953
- baseOptions: {
9954
- modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task",
9955
- delegate: "GPU"
9956
- },
9957
- runningMode: "IMAGE",
9958
- numFaces: 1,
9959
- outputFaceBlendshapes: false,
9960
- outputFacialTransformationMatrixes: false
9961
- });
9962
- console.log(`[PS-SDK:FaceDetect] FaceLandmarker ready in ${Math.round(performance.now() - t2)}ms`);
9963
- } catch (err) {
9964
- console.error("[PS-SDK:FaceDetect] loadFaceLandmarker FAILED:", err);
9965
- loadingPromise = null;
9966
- throw err;
9967
- }
9968
- })();
9969
- return loadingPromise;
9970
- }
9971
- function loadImage(src) {
9972
- return new Promise((resolve, reject) => {
9973
- const img = new Image();
9974
- img.crossOrigin = "anonymous";
9975
- img.onload = () => {
9976
- console.log("[PS-SDK:FaceDetect] loadImage — primary (crossOrigin) succeeded", { w: img.naturalWidth, h: img.naturalHeight });
9977
- resolve(img);
9978
- };
9979
- img.onerror = () => {
9980
- console.warn("[PS-SDK:FaceDetect] loadImage — primary (crossOrigin) failed, retrying without crossOrigin");
9981
- const img2 = new Image();
9982
- img2.onload = () => {
9983
- console.log("[PS-SDK:FaceDetect] loadImage — fallback succeeded", { w: img2.naturalWidth, h: img2.naturalHeight });
9984
- resolve(img2);
9985
- };
9986
- img2.onerror = () => {
9987
- console.error("[PS-SDK:FaceDetect] loadImage — both attempts failed for src:", src.slice(0, 120));
9988
- reject(new Error("Failed to load image"));
9989
- };
9990
- img2.src = src;
9991
- };
9992
- img.src = src;
9993
- });
9994
- }
9995
- function irisDiameterPx(ring, imageWidth, imageHeight) {
9996
- if (ring.length < 4) return 0;
9997
- const toPx = (p4) => ({ x: p4.x * imageWidth, y: p4.y * imageHeight });
9998
- const [p0, p1, p2, p3] = ring.map(toPx);
9999
- const d1 = Math.hypot(p0.x - p2.x, p0.y - p2.y);
10000
- const d2 = Math.hypot(p1.x - p3.x, p1.y - p3.y);
10001
- return (d1 + d2) / 2;
10002
- }
10003
- function extractLandmarks(raw) {
10004
- if (raw.length < 478) {
10005
- console.warn(`[PS-SDK:FaceDetect] extractLandmarks REJECTED — need 478 landmarks (iris refinement), got ${raw.length}. The model is missing iris data — probably the CDN "latest" tag moved to a non-iris variant.`);
10006
- return null;
10007
- }
10008
- const at = (i) => ({ x: raw[i].x, y: raw[i].y, z: raw[i].z });
10009
- return {
10010
- noseTip: at(IDX.noseTip),
10011
- noseBridge: at(IDX.noseBridge),
10012
- leftInnerEye: at(IDX.leftInnerEye),
10013
- rightInnerEye: at(IDX.rightInnerEye),
10014
- leftOuterEye: at(IDX.leftOuterEye),
10015
- rightOuterEye: at(IDX.rightOuterEye),
10016
- leftIrisCenter: at(IDX.leftIrisCenter),
10017
- rightIrisCenter: at(IDX.rightIrisCenter),
10018
- leftIrisRing: IDX.leftIrisRing.map(at),
10019
- rightIrisRing: IDX.rightIrisRing.map(at),
10020
- leftTragus: at(IDX.leftTragus),
10021
- rightTragus: at(IDX.rightTragus),
10022
- forehead: at(IDX.forehead),
10023
- chin: at(IDX.chin),
10024
- leftMouth: at(IDX.leftMouth),
10025
- rightMouth: at(IDX.rightMouth)
10026
- };
10027
- }
10028
- function computeMeasurements(lm, imageWidth, imageHeight) {
10029
- const leftPx = irisDiameterPx(lm.leftIrisRing, imageWidth, imageHeight);
10030
- const rightPx = irisDiameterPx(lm.rightIrisRing, imageWidth, imageHeight);
10031
- const irisPx = (leftPx + rightPx) / 2;
10032
- let irisConfidence = 1;
10033
- if (irisPx < 8) irisConfidence = 0.3;
10034
- else if (Math.abs(leftPx - rightPx) / irisPx > 0.3) irisConfidence = 0.5;
10035
- else if (Math.abs(leftPx - rightPx) / irisPx > 0.15) irisConfidence = 0.8;
10036
- const pxToMm = irisPx > 0 ? IRIS_DIAMETER_MM / irisPx : 0;
10037
- const mmBetween = (a2, b2) => {
10038
- const pxDist = Math.hypot(
10039
- (a2.x - b2.x) * imageWidth,
10040
- (a2.y - b2.y) * imageHeight
10041
- );
10042
- return pxDist * pxToMm;
10043
- };
10044
- const pd2 = mmBetween(lm.leftIrisCenter, lm.rightIrisCenter);
10045
- const innerEyeGap = mmBetween(lm.leftInnerEye, lm.rightInnerEye);
10046
- const bridgeWidth = innerEyeGap / 1.6;
10047
- const faceWidth = mmBetween(lm.leftTragus, lm.rightTragus);
10048
- const templeLengthLeft = mmBetween(lm.leftTragus, lm.leftOuterEye);
10049
- const templeLengthRight = mmBetween(lm.rightTragus, lm.rightOuterEye);
10050
- const templeLength = (templeLengthLeft + templeLengthRight) / 2;
10051
- const headWidth = faceWidth * 1.07;
10052
- const zDepthNorm = Math.abs((lm.forehead.z ?? 0) - (lm.chin.z ?? 0));
10053
- const rawHeadDepthMm = zDepthNorm * imageWidth * pxToMm;
10054
- const headDepth = Math.max(170, Math.min(210, rawHeadDepthMm || 190));
10055
- const a = headWidth / 2;
10056
- const b = headDepth / 2;
10057
- const headCircumference = Math.PI * Math.sqrt(2 * (a * a + b * b));
10058
- return {
10059
- measurements: {
10060
- irisDiameter: IRIS_DIAMETER_MM,
10061
- pd: round1(pd2),
10062
- bridgeWidth: round1(bridgeWidth),
10063
- faceWidth: round1(faceWidth),
10064
- templeLengthLeft: round1(templeLengthLeft),
10065
- templeLengthRight: round1(templeLengthRight),
10066
- templeLength: round1(templeLength),
10067
- headWidth: round1(headWidth),
10068
- headDepth: round1(headDepth),
10069
- headCircumference: round1(headCircumference)
10070
- },
10071
- irisConfidence
10072
- };
10073
- }
10074
- function round1(n2) {
10075
- return Math.round(n2 * 10) / 10;
10076
- }
10077
- async function detectFaceMeasurements(imageSrc) {
10078
- console.log("[PS-SDK:FaceDetect] detectFaceMeasurements called", {
10079
- srcType: typeof imageSrc === "string" ? imageSrc.startsWith("blob:") ? "blob-url" : imageSrc.startsWith("data:") ? "data-url" : "other" : "HTMLImageElement",
10080
- srcLength: typeof imageSrc === "string" ? imageSrc.length : "N/A"
10081
- });
10082
- const tStart = performance.now();
10083
- try {
10084
- console.log("[PS-SDK:FaceDetect] step 1/4 — loading FaceLandmarker");
10085
- await loadFaceLandmarker();
10086
- console.log("[PS-SDK:FaceDetect] step 2/4 — loading image");
10087
- const img = typeof imageSrc === "string" ? await loadImage(imageSrc) : imageSrc;
10088
- const imageWidth = img.naturalWidth || img.width;
10089
- const imageHeight = img.naturalHeight || img.height;
10090
- console.log("[PS-SDK:FaceDetect] step 3/4 — running faceLandmarker.detect()", { imageWidth, imageHeight });
10091
- const tDetect = performance.now();
10092
- const result = faceLandmarker.detect(img);
10093
- const detectMs = Math.round(performance.now() - tDetect);
10094
- const faceCount = result?.faceLandmarks?.length ?? 0;
10095
- console.log(`[PS-SDK:FaceDetect] detect() returned in ${detectMs}ms — faces: ${faceCount}`);
10096
- if (!faceCount) {
10097
- console.warn("[PS-SDK:FaceDetect] NO FACES detected — returning null");
10098
- return null;
10099
- }
10100
- const raw = result.faceLandmarks[0];
10101
- console.log(`[PS-SDK:FaceDetect] first face has ${raw.length} landmarks`);
10102
- const landmarks = extractLandmarks(raw);
10103
- if (!landmarks) {
10104
- return null;
10105
- }
10106
- console.log("[PS-SDK:FaceDetect] step 4/4 — computing measurements");
10107
- const { measurements, irisConfidence } = computeMeasurements(landmarks, imageWidth, imageHeight);
10108
- console.log(`[PS-SDK:FaceDetect] ✓ DONE in ${Math.round(performance.now() - tStart)}ms`, { irisConfidence, measurements });
10109
- return { landmarks, measurementsMm: measurements, irisConfidence, imageWidth, imageHeight };
10110
- } catch (err) {
10111
- console.error("[PS-SDK:FaceDetect] detectFaceMeasurements FAILED:", err);
10112
- return null;
10113
- }
10114
- }
10115
- function parseRange(s) {
10116
- const ns = s.replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
10117
- return ns.length ? { min: Math.min(...ns), max: Math.max(...ns) } : { min: 0, max: 0 };
10118
- }
10119
- function parseNum(s) {
10120
- const n2 = parseFloat(s.replace(/[^\d.]/g, ""));
10121
- return isNaN(n2) ? 0 : n2;
10122
- }
10123
- function computeFit(userValue, chartRange, unit) {
10124
- const { min: rMin, max: rMax } = parseRange(chartRange);
10125
- if (rMin === 0 && rMax === 0) return "good";
10126
- const range = rMax - rMin;
10127
- const threshold = range > 0 ? range * 0.5 : rMin * 0.05 || 3;
10128
- const perfectTol = unit === "cm" ? 2.54 : unit === "mm" ? 25.4 : 1;
10129
- if (userValue > rMin - perfectTol && userValue < rMax + perfectTol) return "good";
10130
- if (userValue < rMin) {
10131
- const diff2 = rMin - userValue;
10132
- if (diff2 > threshold * 2) return "too-loose";
10133
- if (diff2 > threshold) return "loose";
10134
- return "a-bit-loose";
10135
- }
10136
- const diff = userValue - rMax;
10137
- if (diff > threshold * 2) return "too-tight";
10138
- if (diff > threshold) return "tight";
10139
- return "a-bit-tight";
10140
- }
10141
- const AREA_TO_POSE_KEY = {
10142
- chest: "chest",
10143
- bust: "chest",
10144
- waist: "waist",
10145
- hips: "hips",
10146
- hip: "hips"
10147
- };
10148
- const SKIP_AREAS_FOR_FIT = /* @__PURE__ */ new Set([
10149
- "height",
10150
- "altezza",
10151
- "estatura"
10152
- ]);
10153
- function buildFitInfo(matchDetails, poseLines, unit) {
10154
- return matchDetails.filter((m2) => !SKIP_AREAS_FOR_FIT.has(m2.measurement.toLowerCase().replace(/\s*\(.*?\)\s*/g, "").trim())).map((m2) => {
10155
- const userNum = parseNum(m2.userValue);
10156
- const fit = computeFit(userNum, m2.chartRange, unit);
10157
- const info = {
10158
- area: m2.measurement,
10159
- section: m2.section || void 0,
10160
- fit,
10161
- userValue: userNum || void 0,
10162
- garmentRange: m2.chartRange || void 0
10163
- };
10164
- if (poseLines) {
10165
- const poseKey = AREA_TO_POSE_KEY[m2.measurement.toLowerCase()];
10166
- if (poseKey && poseLines[poseKey]) {
10167
- const line = poseLines[poseKey];
10168
- info.y = Math.round(line.y * 1e3) / 1e3;
10169
- info.x1 = Math.round(line.x1 * 1e3) / 1e3;
10170
- info.x2 = Math.round(line.x2 * 1e3) / 1e3;
10171
- }
10172
- }
10173
- return info;
10174
- });
10175
- }
10176
- function buildSilhouetteContext(sizingResult, sizeGuide, selectedSizeOverride) {
10177
- if (!sizingResult && !sizeGuide) return void 0;
10178
- const out = {};
10179
- if (selectedSizeOverride) out.recommendedSize = selectedSizeOverride;
10180
- else if (sizingResult?.recommendedSize) out.recommendedSize = sizingResult.recommendedSize;
10181
- const seen = /* @__PURE__ */ new Set();
10182
- const userLines = [];
10183
- const push = (md2) => {
10184
- if (!md2) return;
10185
- for (const m2 of md2) {
10186
- const k2 = m2.measurement.toLowerCase();
10187
- if (seen.has(k2)) continue;
10188
- seen.add(k2);
10189
- if (m2.userValue) userLines.push(`${m2.measurement} ${m2.userValue}`);
10190
- }
10191
- };
10192
- push(sizingResult?.matchDetails);
10193
- if (sizingResult?.sections) {
10194
- for (const sec of Object.values(sizingResult.sections)) push(sec.matchDetails);
10195
- }
10196
- if (userLines.length) out.userMeasurementsText = userLines.join(", ");
10197
- if (sizeGuide?.headers?.length && sizeGuide.rows?.length) {
10198
- const hdrs = sizeGuide.headers;
10199
- const rowStrings = sizeGuide.rows.slice(0, 20).map(
10200
- (row) => hdrs.map((h, i) => `${h} ${row[i] ?? "—"}`).join(", ")
10201
- );
10202
- out.sizeChartSummary = rowStrings.join(" | ");
10203
- }
10204
- return Object.keys(out).length ? out : void 0;
10205
- }
10206
- const FALLBACK_FIELDS_FEMALE = [
10207
- { key: "bust", label: "Bust", required: true, unit: "cm", placeholder: "e.g. 88", category: "body" },
10208
- { key: "waist", label: "Waist", required: true, unit: "cm", placeholder: "e.g. 70", category: "body" },
10209
- { key: "hips", label: "Hips", required: true, unit: "cm", placeholder: "e.g. 96", category: "body" },
10210
- { key: "shoulderWidth", label: "Shoulders", required: false, unit: "cm", placeholder: "e.g. 39", category: "body" },
10211
- { key: "inseam", label: "Inseam", required: false, unit: "cm", placeholder: "e.g. 76", category: "body" },
10212
- { key: "footLengthCm", label: "Foot length", required: false, unit: "cm", placeholder: "e.g. 24", category: "shoe" }
10213
- ];
10214
- const FALLBACK_FIELDS_MALE = [
10215
- { key: "chest", label: "Chest", required: true, unit: "cm", placeholder: "e.g. 104", category: "body" },
10216
- { key: "waist", label: "Waist", required: true, unit: "cm", placeholder: "e.g. 84", category: "body" },
10217
- { key: "shoulderWidth", label: "Shoulders", required: false, unit: "cm", placeholder: "e.g. 46", category: "body" },
10218
- { key: "sleeveLength", label: "Sleeve", required: false, unit: "cm", placeholder: "e.g. 64", category: "body" },
10219
- { key: "inseam", label: "Inseam", required: false, unit: "cm", placeholder: "e.g. 81", category: "body" },
10220
- { key: "footLengthCm", label: "Foot length", required: false, unit: "cm", placeholder: "e.g. 27", category: "shoe" }
10221
- ];
10222
- const TOTAL_STEPS = 3;
10223
- const LS_PREFIX = "primestyle_";
10224
- const PROFILES_KEY = "profiles";
10225
- const ACTIVE_PROFILE_KEY = "active_profile_id";
10226
- const PS_STORAGE_CHANGE_EVENT = "ps:storage-change";
10227
- function emitStorageChange(reason) {
10228
- if (typeof window === "undefined") return;
10229
- try {
10230
- window.dispatchEvent(new CustomEvent(PS_STORAGE_CHANGE_EVENT, { detail: { reason } }));
10231
- } catch {
10232
- }
10233
- }
10234
- function lsGet(key, fallback) {
10235
- if (typeof window === "undefined") return fallback;
10236
- try {
10237
- const raw = localStorage.getItem(LS_PREFIX + key);
10238
- return raw ? JSON.parse(raw) : fallback;
10239
- } catch {
10240
- return fallback;
10241
- }
10242
- }
10243
- function lsSet(key, value) {
10244
- try {
10245
- localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
10246
- } catch {
10247
- }
10248
- }
10249
- function getProfiles() {
10250
- return lsGet(PROFILES_KEY, []);
10251
- }
10252
- function saveProfiles(profiles) {
10253
- lsSet(PROFILES_KEY, profiles);
10254
- emitStorageChange("profiles");
10255
- }
10256
- function getActiveProfileId() {
10257
- return lsGet(ACTIVE_PROFILE_KEY, null);
10258
- }
10259
- function setActiveProfileId(id2) {
10260
- lsSet(ACTIVE_PROFILE_KEY, id2);
10261
- emitStorageChange("active-profile");
10262
- }
10263
- function getActiveProfile() {
10264
- const profiles = getProfiles();
10265
- if (profiles.length === 0) return null;
10266
- const activeId = getActiveProfileId();
10267
- if (activeId) {
10268
- const found = profiles.find((p2) => p2.id === activeId);
10269
- if (found) return found;
10270
- }
10271
- const sorted = [...profiles].sort(
10272
- (a, b) => (b.lastUsedAt || b.createdAt || 0) - (a.lastUsedAt || a.createdAt || 0)
10273
- );
10274
- return sorted[0] || null;
10275
- }
10276
- function updateProfile(id2, patch) {
10277
- const profiles = getProfiles();
10278
- const idx = profiles.findIndex((p2) => p2.id === id2);
10279
- if (idx < 0) return null;
10280
- const updated = { ...profiles[idx], ...patch, lastEditedAt: Date.now() };
10281
- profiles[idx] = updated;
10282
- saveProfiles(profiles);
10283
- return updated;
10284
- }
10285
- function updateProfileMeasurements(id2, measurements, unit = "cm") {
10286
- return updateProfile(id2, { measurements, measurementsUnit: unit });
10287
- }
10288
- function addSizeToHistory(profileId, entry) {
10289
- const profiles = getProfiles();
10290
- const idx = profiles.findIndex((p2) => p2.id === profileId);
10291
- if (idx < 0) return null;
10292
- const profile = profiles[idx];
10293
- const history = (profile.sizeHistory || []).filter(
10294
- (h) => h.productId !== entry.productId
10295
- );
10296
- history.unshift(entry);
10297
- const trimmed = history.slice(0, 50);
10298
- profiles[idx] = {
10299
- ...profile,
10300
- sizeHistory: trimmed,
10301
- lastUsedAt: Date.now()
10302
- };
10303
- saveProfiles(profiles);
10304
- return profiles[idx];
10305
- }
10306
- function detectLocale() {
10307
- if (typeof navigator === "undefined") return "US";
10308
- const lang = navigator.language || "";
10309
- const region = lang.split("-")[1]?.toUpperCase();
10310
- if (region === "GB") return "UK";
10311
- if (region) return region;
10312
- const map = {
10313
- en: "US",
10314
- ja: "JP",
10315
- ko: "KR",
10316
- zh: "CN",
10317
- fr: "FR",
10318
- it: "IT",
10319
- de: "DE",
10320
- es: "ES",
10321
- pt: "BR"
10322
- };
10323
- return map[lang.split("-")[0].toLowerCase()] || "US";
10324
- }
10325
- function getApiKey() {
10326
- let key = "";
10327
- try {
10328
- key = "shopify-proxy";
10329
- } catch {
10330
- }
10331
- if (!key) throw new PrimeStyleError("Missing NEXT_PUBLIC_PRIMESTYLE_API_KEY", "MISSING_API_KEY");
10332
- return key;
10333
- }
10334
- function getApiUrl(override) {
10335
- if (override) return override;
10336
- let envUrl = "";
10337
- try {
10338
- envUrl = "";
10339
- } catch {
10340
- }
10341
- return envUrl || "http://localhost:4000";
10342
- }
10343
- let cachedMP = null;
10344
- const MP_CACHE_TTL_MS = 6e4;
10345
- function setCachedMediaPipe(landmarks) {
10346
- cachedMP = { landmarks, storedAt: Date.now() };
10347
- }
10348
- function getCachedMediaPipe() {
10349
- if (!cachedMP) return null;
10350
- if (Date.now() - cachedMP.storedAt > MP_CACHE_TTL_MS) {
10351
- cachedMP = null;
10352
- return null;
10353
- }
10354
- return cachedMP;
10355
- }
10356
- async function recommendForProduct(input) {
10357
- const log = (...args) => console.log("[ps-sdk:recommend]", ...args);
10358
- const profile = input.profile ?? getActiveProfile();
10359
- if (!profile) {
10360
- log("no active profile — returning null");
10361
- return null;
10362
- }
10363
- log("start", {
10364
- productId: input.productId,
10365
- profileId: profile.id,
10366
- profileName: profile.name,
10367
- hasMeasurements: !!profile.measurements && Object.keys(profile.measurements || {}).length,
10368
- measurementsCount: Object.keys(profile.measurements || {}).length
10369
- });
10370
- log("cache MISS — calling backend");
10371
- let apiKey;
10372
- try {
10373
- apiKey = input.apiKey ?? getApiKey();
10374
- } catch (e) {
10375
- log("no api key — aborting", e);
10376
- return null;
10377
- }
10378
- const apiUrl = (input.apiUrl ?? getApiUrl()).replace(/\/+$/, "");
10379
- if (!apiKey) {
10380
- log("no api key — aborting");
10381
- return null;
10382
- }
10383
- let sizeGuide = null;
10384
- if (input.sizeGuideData != null) {
10385
- try {
10386
- const sgRes = await fetch(`${apiUrl}/api/v1/sizing/sizeguide`, {
10387
- method: "POST",
10388
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
10389
- body: JSON.stringify({
10390
- product: { title: input.productTitle, productId: input.productId },
10391
- sizeGuideRaw: input.sizeGuideData
10392
- })
10393
- });
10394
- if (sgRes.ok) {
10395
- sizeGuide = await sgRes.json();
10396
- log("sizeguide OK", { found: sizeGuide?.found, sectionCount: Object.keys(sizeGuide?.sections || {}).length });
10397
- } else {
10398
- log("sizeguide FAILED", sgRes.status, sgRes.statusText);
10399
- }
10400
- } catch (e) {
10401
- log("sizeguide threw", e);
10402
- }
10403
- } else {
10404
- log("no sizeGuideData provided");
10405
- }
10406
- const measurements = {
10407
- gender: profile.gender,
10408
- sizingUnit: profile.measurementsUnit || "cm"
10409
- };
10410
- if (profile.measurements) {
10411
- for (const [key, value] of Object.entries(profile.measurements)) {
10412
- if (value != null) measurements[key] = value;
10413
- }
10414
- }
10415
- if (profile.height != null) measurements.height = profile.height;
10416
- if (profile.weight != null) measurements.weight = profile.weight;
10417
- if (profile.heightUnit) measurements.heightUnit = profile.heightUnit;
10418
- if (profile.weightUnit) measurements.weightUnit = profile.weightUnit;
10419
- if (profile.age) measurements.age = profile.age;
10420
- if (profile.chestProfile) measurements.chestProfile = profile.chestProfile;
10421
- if (profile.midsectionProfile) measurements.midsectionProfile = profile.midsectionProfile;
10422
- if (profile.hipProfile) measurements.hipProfile = profile.hipProfile;
10423
- const payload = {
10424
- method: "exact",
10425
- locale: profile.country || "US",
10426
- sizingUnit: profile.measurementsUnit || profile.sizingUnit || "cm",
10427
- product: { title: input.productTitle, productId: input.productId, description: "", variants: [] },
10428
- measurements
10429
- };
10430
- if (profile.gender === "female" && profile.bandSize && profile.cupSize) {
10431
- const region = profile.braRegion ?? profile.braSizeRegion ?? "US";
10432
- payload.braSize = {
10433
- band: parseFloat(String(profile.bandSize)),
10434
- cup: String(profile.cupSize),
10435
- region
10436
- };
10437
- }
10438
- if (profile.knownMeasurements) {
10439
- payload.knownMeasurements = profile.knownMeasurements;
10440
- }
10441
- if (sizeGuide && sizeGuide.found) {
10442
- payload.sizeGuide = sizeGuide;
10443
- }
10444
- log("calling /sizing/recommend", { measurements: Object.keys(measurements), hasSizeGuide: !!payload.sizeGuide });
10445
- let result = null;
10446
- try {
10447
- const res = await fetch(`${apiUrl}/api/v1/sizing/recommend`, {
10448
- method: "POST",
10449
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
10450
- body: JSON.stringify(payload)
10451
- });
10452
- if (!res.ok) {
10453
- log("recommend FAILED", res.status, res.statusText);
10454
- return null;
10455
- }
10456
- result = await res.json();
10457
- log("recommend OK", {
10458
- recommendedSize: result?.recommendedSize,
10459
- sectionKeys: result?.sections ? Object.keys(result.sections) : null,
10460
- sections: result?.sections
10461
- });
10462
- } catch (e) {
10463
- log("recommend threw", e);
10464
- return null;
10465
- }
10466
- if (!result || !result.recommendedSize) {
10467
- log("recommend returned no recommendedSize — bailing");
10468
- return null;
10469
- }
10470
- const sectionsMap = result.sections ? Object.fromEntries(
10471
- Object.entries(result.sections).map(([name, sec]) => [name, sec.recommendedSize])
10472
- ) : void 0;
10473
- const sectionsFull = result.sections ? Object.fromEntries(
10474
- Object.entries(result.sections).map(([name, sec]) => [
10475
- name,
10476
- {
10477
- recommendedSize: sec.recommendedSize,
10478
- // Backend may include these on merged Jacket/Pants entries
10479
- size: sec.size,
10480
- length: sec.length,
10481
- availableSizes: sec.availableSizes,
10482
- availableLengths: sec.availableLengths
10483
- }
10484
- ])
10485
- ) : void 0;
10486
- addSizeToHistory(profile.id, {
10487
- productId: input.productId,
10488
- productTitle: input.productTitle,
10489
- productImage: input.productImage,
10490
- recommendedSize: result.recommendedSize,
10491
- confidence: result.confidence,
10492
- sections: sectionsMap,
10493
- sectionsFull,
10494
- recommendedLength: result.recommendedLength || void 0,
10495
- savedAt: Date.now()
10496
- });
10497
- return {
10498
- recommendedSize: result.recommendedSize,
10499
- confidence: result.confidence,
10500
- sections: sectionsMap,
10501
- profileId: profile.id,
10502
- fromCache: false,
10503
- raw: result,
10504
- found: result.found
10505
- };
10506
- }
10507
- async function estimateFullMeasurements(args) {
10508
- let apiKey;
10509
- try {
10510
- apiKey = args.apiKey ?? getApiKey();
10511
- } catch {
10512
- return null;
10513
- }
10514
- const apiUrl = (args.apiUrl ?? getApiUrl()).replace(/\/+$/, "");
10515
- if (!apiKey) return null;
10516
- const requiredFields = [
10517
- "chest",
10518
- "bust",
10519
- "waist",
10520
- "hips",
10521
- "shoulderWidth",
10522
- "sleeveLength",
10523
- "inseam",
10524
- "neckCircumference",
10525
- "thighCircumference",
10526
- "footLengthCm"
10527
- ];
10528
- const payload = {
10529
- height: args.height ?? 0,
10530
- weight: args.weight ?? 0,
10531
- heightUnit: args.heightUnit,
10532
- weightUnit: args.weightUnit,
10533
- gender: args.gender,
10534
- requiredFields
10535
- };
10536
- if (args.age) payload.age = args.age;
10537
- if (args.chestProfile) payload.chestProfile = args.chestProfile;
10538
- if (args.midsectionProfile) payload.midsectionProfile = args.midsectionProfile;
10539
- if (args.hipProfile) payload.hipProfile = args.hipProfile;
10540
- if (args.bodyImage) payload.bodyImage = args.bodyImage;
10541
- let landmarksForPayload = args.bodyLandmarks ?? null;
10542
- if (!landmarksForPayload) {
10543
- const cached = getCachedMediaPipe();
10544
- if (cached) {
10545
- landmarksForPayload = cached.landmarks;
10546
- console.log("[PS-SDK:estimate] reusing cached MediaPipe result from recent /recommend run");
10547
- }
10548
- }
10549
- if (!landmarksForPayload && args.bodyImage) {
10550
- console.log("[PS-SDK:estimate] bodyImage present but bodyLandmarks missing — running MediaPipe now");
10551
- try {
10552
- const detected = await detectBodyLandmarks(args.bodyImage);
10553
- if (detected) {
10554
- landmarksForPayload = detected;
10555
- console.log("[PS-SDK:estimate] MediaPipe auto-detect ✓ anchors available for backend");
10556
- } else {
10557
- console.warn("[PS-SDK:estimate] MediaPipe auto-detect returned null — backend will fall back to low-confidence visual estimation");
10558
- }
10559
- } catch (e) {
10560
- console.warn("[PS-SDK:estimate] MediaPipe auto-detect threw:", e);
10561
- }
10562
- }
10563
- if (landmarksForPayload) payload.bodyLandmarks = landmarksForPayload;
10564
- if (landmarksForPayload) {
10565
- const lm = landmarksForPayload;
10566
- const required = [
10567
- ["leftShoulder", lm.leftShoulder?.visibility],
10568
- ["rightShoulder", lm.rightShoulder?.visibility],
10569
- ["leftHip", lm.leftHip?.visibility],
10570
- ["rightHip", lm.rightHip?.visibility]
10571
- ];
10572
- const lowVis = required.filter(([, v2]) => typeof v2 === "number" && v2 < 0.5);
10573
- if (lowVis.length > 0) {
10574
- console.warn(`[PS-SDK:estimate] LOW joint visibility — ${lowVis.map(([k2, v2]) => `${k2}=${v2.toFixed(2)}`).join(", ")} — accuracy may suffer; ask user to retake with better framing/lighting`);
10575
- }
10576
- }
10577
- try {
10578
- console.groupCollapsed(`[PS-SDK:estimate] POST /api/v1/sizing/estimate`);
10579
- console.log("url:", `${apiUrl}/api/v1/sizing/estimate`);
10580
- console.log("payload (sans bodyImage):", {
10581
- ...payload,
10582
- bodyImage: payload.bodyImage ? `[base64 ~${Math.round(String(payload.bodyImage).length / 1024)}KB]` : void 0,
10583
- bodyLandmarks: payload.bodyLandmarks ? {
10584
- count: Object.keys(payload.bodyLandmarks).length,
10585
- imageWidth: payload.bodyLandmarks.imageWidth,
10586
- imageHeight: payload.bodyLandmarks.imageHeight
10587
- } : void 0
10588
- });
10589
- console.groupEnd();
10590
- {
10591
- const lmObj = payload.bodyLandmarks;
10592
- const jointCount = lmObj ? Object.values(lmObj).filter(
10593
- (v2) => v2 && typeof v2 === "object" && typeof v2.x === "number" && typeof v2.y === "number"
10594
- ).length : 0;
10595
- console.log(`[ps-sdk:debug] /estimate payload → bodyLandmarks=${!!lmObj}(${jointCount} joints)`);
10596
- }
10597
- const res = await fetch(`${apiUrl}/api/v1/sizing/estimate`, {
10598
- method: "POST",
10599
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
10600
- body: JSON.stringify(payload)
10601
- });
10602
- if (!res.ok) {
10603
- console.warn(`[PS-SDK:estimate] ✗ ${res.status} ${res.statusText}`);
10604
- return null;
10605
- }
10606
- const data = await res.json();
10607
- console.groupCollapsed(`[PS-SDK:estimate] ✓ response (confidence=${data?.confidence ?? "—"})`);
10608
- console.log("estimates:", data.estimates);
10609
- console.log("userEstimates:", data.userEstimates);
10610
- console.log("method:", data.method, "unit:", data.unit);
10611
- console.log("full response:", data);
10612
- console.groupEnd();
10613
- if (!data?.estimates) return null;
10614
- return {
10615
- estimates: data.estimates,
10616
- unit: data.unit || "cm",
10617
- userEstimates: data.userEstimates
10618
- };
10619
- } catch (err) {
10620
- console.error("[PS-SDK:estimate] threw:", err);
10621
- return null;
10622
- }
10623
- }
10624
- function isImperial(locale) {
10625
- return ["US", "UK", "AU"].includes(locale);
10626
- }
10627
- function getUnitLabel(unit) {
10628
- if (unit === "in" || unit === "inches" || unit === "lbs") return "Imperial";
10629
- if (unit === "cm" || unit === "kg") return "Metric";
10630
- if (unit === "mm") return "mm";
10631
- return "";
10632
- }
10633
- const cache = /* @__PURE__ */ new Map();
10634
- function scoreLandmarks(lm) {
10635
- if (!lm) return 0;
10636
- let joints = 0;
10637
- for (const [k2, v2] of Object.entries(lm)) {
10638
- if (k2 === "imageWidth" || k2 === "imageHeight") continue;
10639
- if (v2 && typeof v2 === "object" && typeof v2.x === "number") joints++;
10640
- }
10641
- let score = joints * 10;
10642
- if (lm.nose) score += 50;
10643
- if (lm.leftAnkle && lm.rightAnkle) score += 5;
10644
- return score;
10645
- }
10646
- async function scoreImage(url) {
10647
- try {
10648
- const lm = await detectBodyLandmarks(url);
10649
- return scoreLandmarks(lm);
10650
- } catch {
10651
- return 0;
10652
- }
10653
- }
10654
- async function pickBestGarmentImage(images) {
10655
- if (!images || !images.length) return null;
10656
- if (images.length === 1) return images[0];
10657
- const cacheKey = images.join("|");
10658
- const cached = cache.get(cacheKey);
10659
- if (cached) return cached;
10660
- const t0 = Date.now();
10661
- const scored = await Promise.all(images.map(async (url) => ({
10662
- url,
10663
- score: await scoreImage(url)
10664
- })));
10665
- scored.sort((a, b) => b.score - a.score);
10666
- const best = (scored[0]?.score ?? 0) > 0 ? scored[0].url : images[0];
10667
- cache.set(cacheKey, best);
10668
- console.log(`[ps-sdk:garment-pick] ${Date.now() - t0}ms — chose ${images.indexOf(best)}/${images.length}`);
10669
- for (const s of scored) console.log(`[ps-sdk:garment-pick] ${s.score.toString().padStart(4, " ")} ${s.url}`);
10670
- return best;
10671
- }
10672
- function cx(base, override) {
10673
- return override ? `${base} ${override}` : base;
10674
- }
10675
- const STYLES$1 = `
10676
- /* Variable defaults must live on BOTH the root (for the trigger button)
10677
- and the overlay (which is React-portaled to <body> and therefore not
10678
- a descendant of .ps-tryon-root, so the cascade is broken). Without
10679
- this duplication the slider track gradient and the thumb border —
10680
- both var(--ps-accent) — render as unset and become invisible. */
10681
- .ps-tryon-root,
10682
- .ps-tryon-overlay {
10683
- --ps-accent: #3B82F6;
10684
- --ps-accent-hover: #2563EB;
10685
- --ps-accent-light: #BFDBFE;
10686
- --ps-text-primary: #1C1D1E;
10687
- --ps-text-secondary: #454545;
10688
- --ps-text-muted: #84898C;
10689
- --ps-text-dim: #696E71;
10690
- --ps-border-color: #E7E7E7;
10691
- --ps-border-subtle: #F0F0F0;
10692
- --ps-bg-primary: #FFFFFF;
10693
- --ps-bg-secondary: #F8FAFF;
10694
- --ps-bg-tertiary: #F0F0F0;
10695
- --ps-input-bg: #FFFFFF;
10696
- --ps-input-border: #E7E7E7;
10697
- --ps-input-focus-border: var(--ps-accent);
10698
- --ps-error-color: #E7000B;
10699
- --ps-success-color: #1C9D4C;
10700
- --ps-warning-color: #DF8400;
10701
- --ps-logo-height: 2.8vw;
10702
- }
10703
- .ps-tryon-root { display: inline-block; visibility: visible !important; }
10704
-
10705
- .ps-tryon-btn {
10706
- display: inline-flex; align-items: center; gap: max(6px, 0.42vw);
10707
- padding: var(--ps-btn-padding, max(10px, 0.63vw) max(16px, 1.25vw));
10708
- background: var(--ps-btn-bg, var(--ps-accent)); color: var(--ps-btn-color, #FFFFFF);
10709
- font-family: var(--ps-btn-font, system-ui, -apple-system, sans-serif);
10710
- font-size: var(--ps-btn-font-size, max(13px, 0.73vw)); font-weight: var(--ps-btn-font-weight, 600);
10711
- border: var(--ps-btn-border, none); border-radius: var(--ps-btn-radius, max(6px, 0.42vw));
10712
- cursor: pointer; transition: all 0.2s ease;
10713
- width: var(--ps-btn-width, auto); height: var(--ps-btn-height, auto);
10714
- box-shadow: var(--ps-btn-shadow, none); line-height: 1; white-space: nowrap;
10715
- box-sizing: border-box;
10716
- }
10717
- .ps-tryon-btn svg { width: var(--ps-btn-icon-size, max(16px, 1.1vw)); height: var(--ps-btn-icon-size, max(16px, 1.1vw)); flex-shrink: 0; }
10718
- .ps-tryon-btn:hover { background: var(--ps-btn-hover-bg, var(--ps-accent-hover)); transform: translateY(-1px); }
10719
- .ps-tryon-btn:active { transform: translateY(0); }
10720
-
10721
- .ps-tryon-overlay {
10722
- position: fixed; inset: 0; background: var(--ps-modal-overlay, rgba(0,0,0,0.6));
10723
- display: flex; align-items: center; justify-content: center;
10724
- z-index: 2147483647;
10725
- isolation: isolate;
10726
- contain: layout style;
10727
- padding: 0.83vw;
10728
- padding: max(0.83vw, env(safe-area-inset-top)) max(0.83vw, env(safe-area-inset-right)) max(0.83vw, env(safe-area-inset-bottom)) max(0.83vw, env(safe-area-inset-left));
10729
- animation: ps-fade-in 0.2s ease;
10730
- overflow-y: auto; -webkit-overflow-scrolling: touch;
10731
- font-family: system-ui, -apple-system, sans-serif;
10732
- -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
10733
- box-sizing: border-box;
10734
- }
10735
- .ps-tryon-overlay, .ps-tryon-overlay *, .ps-tryon-overlay *::before, .ps-tryon-overlay *::after { box-sizing: border-box; }
10736
- @keyframes ps-fade-in { from { opacity: 0; } to { opacity: 1; } }
10737
-
10738
- .ps-tryon-modal {
10739
- background: var(--ps-modal-bg, #FFFFFF); color: var(--ps-modal-color, #1C1D1E);
10740
- border-radius: clamp(8px, 0.83vw, 16px); width: var(--ps-modal-width, 100%);
10741
- max-width: var(--ps-modal-max-width, 36vw); max-height: 92dvh; overflow-y: auto;
10742
- font-family: var(--ps-modal-font, system-ui, -apple-system, sans-serif);
10743
- box-shadow: 0 8px 40px rgba(0,0,0,0.12); animation: ps-slide-up 0.3s ease;
10744
- scrollbar-width: thin; scrollbar-color: var(--ps-border-color) transparent;
10745
- flex-shrink: 0;
10746
- container-type: inline-size;
10747
- }
10748
- .ps-tryon-modal-wide {
10749
- /* Use the SMALLER of 55vw or 92dvh×(16/10) for width so the modal
10750
- never gets wider than the height can support. Height bumped from
10751
- 70dvh → 82dvh to fit the longer accessory photo step (guidelines +
10752
- legal notice + nav) without pushing content under the Analyze CTA. */
10753
- width: min(55vw, calc(92dvh * 1.6));
10754
- max-width: min(55vw, calc(92dvh * 1.6));
10755
- height: min(82dvh, calc(55vw / 1.35));
10756
- max-height: min(92dvh, calc(55vw / 1.35));
10757
- display: flex; flex-direction: column; overflow: hidden;
10758
- transition: height 0.45s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.45s cubic-bezier(0.32, 0.72, 0, 1);
10759
- }
10760
- .ps-tryon-modal-tall.ps-tryon-modal-wide {
10761
- height: min(92dvh, calc(55vw / 1.2));
10762
- max-height: min(95dvh, calc(55vw / 1.2));
10763
- }
10764
- .ps-tryon-modal:has(.ps-tryon-drawer-open) { overflow: hidden; }
10765
- @keyframes ps-slide-up { from { transform: translateY(12px) scale(0.97); opacity: 0; filter: blur(4px); } to { transform: none; opacity: 1; filter: none; } }
10766
-
10767
- /* Header */
10768
- .ps-tryon-header {
10769
- display: flex; align-items: center; justify-content: space-between;
10770
- padding: max(8px, 0.7vw) max(12px, 1.25vw);
10771
- background: var(--ps-modal-header-bg, rgba(255,255,255,0.95));
10772
- border-bottom: 1px solid var(--ps-border-color);
10773
- border-radius: max(8px, 0.83vw) max(8px, 0.83vw) 0 0;
10774
- backdrop-filter: blur(12px); flex-shrink: 0;
10775
- }
10776
- .ps-tryon-header-minimal {
10777
- justify-content: flex-end; gap: 0.4vw; padding: 0.4vw 0.8vw;
10778
- }
10779
- .ps-tryon-back-btn {
10780
- display: flex; align-items: center; gap: 0.35vw;
10781
- background: none; border: none; color: var(--ps-text-secondary); cursor: pointer;
10782
- font-size: 0.73vw; padding: 0; margin-bottom: 0.63vw;
10783
- transition: color 0.2s;
10784
- }
10785
- .ps-tryon-back-btn:hover { color: var(--ps-text-primary); }
10786
- .ps-tryon-back-btn svg { width: 1vw; height: 0.63vw; stroke: currentColor; flex-shrink: 0; }
10787
-
10788
- /* Regen progress bar — absolutely-positioned fill div inside the
10789
- "Generating new try-on…" button. Animation runs at the browser's
10790
- refresh rate (smooth) instead of via React state ticks. The button
10791
- itself sets position: relative + overflow: hidden so the fill is
10792
- clipped to the button's rounded shape. */
10793
- @keyframes ps-tryon-regen-fill {
10794
- from { width: 0%; }
10795
- to { width: 100%; }
10796
- }
10797
- .ps-tryon-regen-fill {
10798
- position: absolute;
10799
- left: 0; top: 0; bottom: 0;
10800
- width: 0%;
10801
- background: rgba(255, 255, 255, 0.28);
10802
- animation: ps-tryon-regen-fill 22s linear forwards;
10803
- pointer-events: none;
10804
- z-index: 0;
10805
- }
10806
- .ps-tryon-title { display: flex; align-items: center; }
10807
- .ps-tryon-logo-img { height: var(--ps-logo-height); width: auto; }
10808
- .ps-tryon-header-actions { display: flex; align-items: center; gap: 0.42vw; }
10809
- .ps-tryon-header-icon {
10810
- /* Pure vw sizing collapsed to ~8 px on 375 px mobile. Clamp so the icon
10811
- stays finger-tappable (min 30 px) while scaling up on large screens. */
10812
- width: clamp(30px, 2.2vw, 34px); height: clamp(30px, 2.2vw, 34px);
10813
- display: flex; align-items: center; justify-content: center;
10814
- border: 1.5px solid var(--ps-border-color); border-radius: clamp(6px, 0.52vw, 10px); background: transparent;
10815
- cursor: pointer; color: var(--ps-text-secondary); transition: all 0.2s;
10816
- }
10817
- .ps-tryon-header-icon:hover { border-color: var(--ps-accent); color: var(--ps-accent); }
10818
- .ps-tryon-header-icon svg { stroke: currentColor; fill: none; width: clamp(14px, 0.9vw, 16px); height: clamp(14px, 0.9vw, 16px); }
10819
- .ps-tryon-close {
10820
- width: 2.2vw; height: 2.2vw; display: flex; align-items: center; justify-content: center;
10821
- background: none; border: none; color: var(--ps-modal-close-color, #999);
10822
- cursor: pointer; border-radius: 0.31vw; transition: background 0.15s;
10823
- }
10824
- .ps-tryon-close:hover { background: rgba(0,0,0,0.06); }
10825
- .ps-tryon-close svg { width: 1vw; height: 1vw; }
10826
-
10827
- /* Language switcher */
10828
- .ps-tryon-lang-wrap { position: relative; z-index: 99999; }
10829
- .ps-tryon-lang-trigger {
10830
- display: flex; align-items: center; gap: clamp(5px, 0.36vw, 7px);
10831
- padding: clamp(6px, 0.36vw, 8px) clamp(10px, 0.73vw, 14px);
10832
- border: 1.5px solid var(--ps-border-color); border-radius: clamp(8px, 0.57vw, 10px);
10833
- background: transparent; cursor: pointer; color: var(--ps-text-secondary);
10834
- transition: all 0.25s ease; font-family: inherit; white-space: nowrap;
10835
- min-height: clamp(28px, 2.2vw, 38px);
10836
- }
10837
- .ps-tryon-lang-trigger:hover, .ps-tryon-lang-trigger.ps-active {
10838
- border-color: var(--ps-accent); color: var(--ps-accent); background: rgba(33,84,239,0.06);
10839
- }
10840
- .ps-tryon-lang-trigger svg { stroke: currentColor; fill: none; flex-shrink: 0; width: clamp(13px, 0.8vw, 16px); height: clamp(13px, 0.8vw, 16px); }
10841
- .ps-tryon-lang-current {
10842
- font-size: clamp(12px, 0.68vw, 14px); font-weight: 500; letter-spacing: 0.01em;
10843
- }
10844
- .ps-tryon-lang-arrow {
10845
- font-size: clamp(10px, 0.57vw, 12px); transition: transform 0.25s ease; display: inline-block;
10846
- }
10847
- .ps-tryon-lang-arrow.ps-open { transform: rotate(180deg); }
10848
-
10849
- /* Dropdown is portaled to document.body — uses hardcoded colors (no CSS vars available) */
10850
- .ps-tryon-lang-dropdown {
10851
- min-width: min(13vw, 180px);
10852
- background: #FFFFFF; border: 1.5px solid rgba(0,0,0,0.1);
10853
- border-radius: clamp(8px, 0.73vw, 12px);
10854
- box-shadow: 0 clamp(6px, 0.5vw, 10px) clamp(16px, 2vw, 32px) rgba(0,0,0,0.15),
10855
- 0 0 0 1px rgba(0,0,0,0.03);
10856
- overflow: hidden;
10857
- animation: ps-lang-open 0.2s ease both;
10858
- font-family: system-ui, -apple-system, sans-serif;
9431
+ .ps-tryon-lang-arrow.ps-open { transform: rotate(180deg); }
9432
+
9433
+ /* Dropdown is portaled to document.body — uses hardcoded colors (no CSS vars available) */
9434
+ .ps-tryon-lang-dropdown {
9435
+ min-width: min(13vw, 180px);
9436
+ background: #FFFFFF; border: 1.5px solid rgba(0,0,0,0.1);
9437
+ border-radius: clamp(8px, 0.73vw, 12px);
9438
+ box-shadow: 0 clamp(6px, 0.5vw, 10px) clamp(16px, 2vw, 32px) rgba(0,0,0,0.15),
9439
+ 0 0 0 1px rgba(0,0,0,0.03);
9440
+ overflow: hidden;
9441
+ animation: ps-lang-open 0.2s ease both;
9442
+ font-family: system-ui, -apple-system, sans-serif;
10859
9443
  }
10860
9444
  @keyframes ps-lang-open {
10861
9445
  from { opacity: 0; transform: translateY(-0.42vw) scale(0.96); }
@@ -17445,850 +16029,2224 @@ const STYLES$1 = `
17445
16029
  border: 1.5px solid rgba(255, 255, 255, 0.35);
17446
16030
  border-top-color: #FFFFFF;
17447
16031
  border-radius: 50%;
17448
- animation: ps-pmv-spin 0.8s linear infinite;
16032
+ animation: ps-pmv-spin 0.8s linear infinite;
16033
+ }
16034
+
16035
+ /* Big product / try-on image */
16036
+ .ps-msd-image {
16037
+ width: 100%; height: 420px;
16038
+ border-radius: 12px; overflow: hidden;
16039
+ background: var(--ps-bg-secondary);
16040
+ display: flex; align-items: center; justify-content: center;
16041
+ flex-shrink: 0;
16042
+ }
16043
+ .ps-msd-image-img {
16044
+ width: 100%; height: 100%;
16045
+ object-fit: contain; display: block;
16046
+ }
16047
+
16048
+ /* RECOMMENDED SIZE / FIT ACCURACY card */
16049
+ .ps-msd-card {
16050
+ background: var(--ps-bg-primary);
16051
+ border: 1px solid var(--ps-border-subtle);
16052
+ border-radius: 12px;
16053
+ padding: 22px 22px 18px;
16054
+ display: flex; flex-direction: column; gap: 8px;
16055
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
16056
+ }
16057
+ .ps-msd-card-eyebrow {
16058
+ font-size: 10px; font-weight: 700;
16059
+ letter-spacing: 0.16em; text-transform: uppercase;
16060
+ color: var(--ps-text-muted);
16061
+ }
16062
+ .ps-msd-card-size-row {
16063
+ display: flex; align-items: baseline; gap: 12px;
16064
+ margin-top: 2px;
16065
+ }
16066
+ .ps-msd-card-size {
16067
+ font-size: 56px; font-weight: 700;
16068
+ color: var(--ps-text-primary);
16069
+ line-height: 1; letter-spacing: -0.02em;
16070
+ font-feature-settings: "tnum" 1;
16071
+ }
16072
+ .ps-msd-card-size-meta {
16073
+ font-size: 12px; font-weight: 600;
16074
+ letter-spacing: 0.05em;
16075
+ color: var(--ps-text-muted);
16076
+ }
16077
+ .ps-msd-card-divider {
16078
+ height: 1px; background: var(--ps-border-subtle);
16079
+ margin: 6px 0;
16080
+ }
16081
+ .ps-msd-card-pct-row {
16082
+ display: flex; align-items: center; gap: 12px;
16083
+ margin-top: 2px;
16084
+ }
16085
+ .ps-msd-card-pct {
16086
+ font-size: 30px; font-weight: 700;
16087
+ color: var(--ps-accent);
16088
+ line-height: 1; font-feature-settings: "tnum" 1;
16089
+ }
16090
+ .ps-msd-card-bar {
16091
+ flex: 1; height: 2px;
16092
+ background: var(--ps-border-color); border-radius: 1px;
16093
+ overflow: hidden;
16094
+ }
16095
+ .ps-msd-card-bar-fill {
16096
+ height: 100%; background: var(--ps-accent);
16097
+ border-radius: 1px;
16098
+ }
16099
+ .ps-msd-card-note {
16100
+ font-size: 11px; color: var(--ps-text-muted);
16101
+ line-height: 1.5; margin: 2px 0 0;
16102
+ }
16103
+
16104
+ /* MEASUREMENT BLUEPRINT — rich rows with number, description, USER/SIZE
16105
+ labeled values, status badge with check icon */
16106
+ .ps-msd-blueprint {
16107
+ display: flex; flex-direction: column; gap: 6px;
16108
+ padding-top: 6px;
16109
+ }
16110
+ .ps-msd-blueprint-title {
16111
+ font-size: 13px; font-weight: 800;
16112
+ letter-spacing: 0.12em; text-transform: uppercase;
16113
+ color: var(--ps-text-primary);
16114
+ margin-bottom: 0;
16115
+ }
16116
+ .ps-msd-blueprint-underline {
16117
+ width: 36px; height: 2px;
16118
+ background: var(--ps-text-primary);
16119
+ margin-bottom: 8px;
16120
+ }
16121
+ .ps-msd-rows {
16122
+ display: flex; flex-direction: column;
16123
+ }
16124
+ .ps-msd-row {
16125
+ display: flex; flex-direction: column;
16126
+ padding: 22px 0 24px;
16127
+ border-bottom: 1px solid var(--ps-border-subtle);
16128
+ }
16129
+ .ps-msd-row:last-child { border-bottom: none; }
16130
+ .ps-msd-row-num {
16131
+ font-size: 11px; font-weight: 700;
16132
+ letter-spacing: 0.14em; text-transform: uppercase;
16133
+ color: var(--ps-text-primary);
16134
+ margin-bottom: 4px;
16135
+ }
16136
+ .ps-msd-row-desc {
16137
+ font-size: 12px; font-weight: 400;
16138
+ color: var(--ps-text-muted);
16139
+ margin: 0 0 14px;
16140
+ line-height: 1.5;
16141
+ }
16142
+ .ps-msd-row-cells {
16143
+ display: flex; justify-content: space-between; align-items: flex-end;
16144
+ gap: 16px; margin-bottom: 14px;
16145
+ }
16146
+ .ps-msd-row-cell {
16147
+ display: flex; flex-direction: column; gap: 4px;
16148
+ min-width: 0;
16149
+ }
16150
+ .ps-msd-row-cell.ps-right { text-align: right; }
16151
+ .ps-msd-cell-label {
16152
+ font-size: 9px; font-weight: 600;
16153
+ letter-spacing: 0.14em; text-transform: uppercase;
16154
+ color: var(--ps-text-muted);
16155
+ }
16156
+ .ps-msd-cell-value {
16157
+ font-size: 18px; font-weight: 700;
16158
+ color: var(--ps-text-primary);
16159
+ font-feature-settings: "tnum" 1;
16160
+ line-height: 1.1;
16161
+ }
16162
+ .ps-msd-row-badge {
16163
+ align-self: flex-end;
16164
+ display: inline-flex; align-items: center; gap: 5px;
16165
+ font-size: 10px; font-weight: 700;
16166
+ letter-spacing: 0.12em; text-transform: uppercase;
16167
+ white-space: nowrap;
16168
+ }
16169
+ .ps-msd-row-badge svg { flex-shrink: 0; }
16170
+ .ps-msd-row-badge.ps-good {
16171
+ color: var(--ps-accent);
16172
+ }
16173
+ .ps-msd-row-badge.ps-tight {
16174
+ color: #dc2626;
16175
+ }
16176
+ .ps-msd-row-badge.ps-loose {
16177
+ color: #d97706;
16178
+ }
16179
+
16180
+ /* TRY ANOTHER SIZE picker */
16181
+ .ps-msd-sizes {
16182
+ display: flex; flex-direction: column; gap: 10px;
16183
+ padding-top: 18px;
16184
+ }
16185
+ .ps-msd-sizes-label {
16186
+ font-size: 11px; font-weight: 700;
16187
+ letter-spacing: 0.14em; text-transform: uppercase;
16188
+ color: var(--ps-text-muted);
16189
+ text-align: center;
16190
+ }
16191
+ .ps-msd-sizes-pills {
16192
+ display: flex; flex-wrap: wrap; gap: 8px;
16193
+ justify-content: center;
16194
+ }
16195
+ .ps-msd-size-pill {
16196
+ min-width: 48px;
16197
+ padding: 10px 16px;
16198
+ background: transparent;
16199
+ border: 1.5px solid var(--ps-border-color);
16200
+ border-radius: 8px;
16201
+ font-family: inherit; font-size: 13px; font-weight: 600;
16202
+ color: var(--ps-text-primary);
16203
+ cursor: pointer;
16204
+ transition: all 0.15s;
16205
+ }
16206
+ .ps-msd-size-pill:hover {
16207
+ border-color: var(--ps-accent);
16208
+ }
16209
+ .ps-msd-size-pill.ps-active {
16210
+ background: var(--ps-accent); color: #FFFFFF;
16211
+ border-color: var(--ps-accent);
16212
+ }
16213
+
16214
+ /* Bottom action area — Next button + tab bar grouped together,
16215
+ pushed to the bottom of the modal body by .ps-bpm-spacer above. */
16216
+ .ps-bpm-bottom {
16217
+ display: flex; flex-direction: column;
16218
+ margin: 0 -16px;
16219
+ border-top: 1px solid var(--ps-border-subtle);
16220
+ background: var(--ps-bg-primary);
16221
+ flex-shrink: 0;
16222
+ padding-bottom: env(safe-area-inset-bottom);
16223
+ }
16224
+ .ps-bpm-next-btn {
16225
+ margin: max(14px, 3.5vw) max(16px, 4.1vw) max(12px, 3vw);
16226
+ padding: max(14px, 3.5vw) max(18px, 4.6vw);
16227
+ background: var(--ps-accent); color: #FFFFFF;
16228
+ border: none; border-radius: max(10px, 2.5vw);
16229
+ font-family: inherit; font-size: max(14px, 3.6vw); font-weight: 700;
16230
+ cursor: pointer; transition: opacity 0.15s, transform 0.15s;
16231
+ letter-spacing: 0.02em;
16232
+ }
16233
+ .ps-bpm-next-btn:active { transform: scale(0.98); }
16234
+ .ps-bpm-next-btn:disabled { opacity: 0.4; cursor: not-allowed; }
16235
+ .ps-bpm-bottom-tabs {
16236
+ display: flex; align-items: stretch;
16237
+ border-top: 1px solid var(--ps-border-subtle);
16238
+ background: var(--ps-bg-primary);
16239
+ }
16240
+ .ps-bpm-bottom-tab {
16241
+ flex: 1; background: none; border: none;
16242
+ padding: max(14px, 3.5vw) max(8px, 2vw) max(12px, 3vw);
16243
+ font-family: inherit; font-size: max(10px, 2.5vw); font-weight: 700;
16244
+ letter-spacing: 0.12em; text-transform: uppercase;
16245
+ color: var(--ps-text-muted); cursor: pointer;
16246
+ border-bottom: 2px solid transparent;
16247
+ transition: color 0.15s, border-color 0.15s;
16248
+ display: flex; align-items: center; justify-content: center; gap: max(6px, 1.5vw);
16249
+ }
16250
+ .ps-bpm-bottom-tab.ps-active {
16251
+ color: var(--ps-accent);
16252
+ border-bottom-color: var(--ps-accent);
16253
+ }
16254
+ .ps-bpm-bottom-tab svg { width: max(13px, 3.3vw); height: max(13px, 3.3vw); flex-shrink: 0; }
16255
+
16256
+ .ps-bp-optional { font-weight: 400; color: var(--ps-text-muted); }
16257
+
16258
+ /* Bra size step — vertically centered */
16259
+ .ps-bp-bra-step { display: flex; flex-direction: column; justify-content: center; flex: 1; }
16260
+ .ps-bp-bra-step .ps-bp-title { text-align: center; margin-bottom: 1.5vw; }
16261
+
16262
+ /* Bra size selector */
16263
+ .ps-bp-bra-section { margin-bottom: 1.2vw; }
16264
+ .ps-bp-bra-header {
16265
+ display: flex; align-items: center; justify-content: space-between;
16266
+ margin-bottom: 0.5vw;
16267
+ }
16268
+ .ps-bp-bra-label {
16269
+ font-size: 0.6vw; font-weight: 600; color: rgba(255,255,255,0.45);
16270
+ letter-spacing: 0.08em; text-transform: uppercase;
16271
+ }
16272
+ /* Region dropdown */
16273
+ /* ── Photo upload overlay (shown during compress + age-check) ── */
16274
+ .ps-bp-photo-overlay {
16275
+ position: absolute;
16276
+ inset: 0;
16277
+ background: rgba(255, 255, 255, 0.92);
16278
+ display: flex;
16279
+ flex-direction: column;
16280
+ align-items: center;
16281
+ justify-content: center;
16282
+ gap: 0.9vw;
16283
+ z-index: 10;
16284
+ border-radius: inherit;
16285
+ backdrop-filter: blur(2px);
16286
+ animation: ps-bp-fade 0.18s ease both;
16287
+ }
16288
+ .ps-bp-photo-overlay-spinner {
16289
+ width: 2.2vw;
16290
+ height: 2.2vw;
16291
+ min-width: 28px;
16292
+ min-height: 28px;
16293
+ border-radius: 50%;
16294
+ border: 2.5px solid rgba(33, 84, 239, 0.18);
16295
+ border-top-color: var(--ps-accent);
16296
+ animation: ps-bp-spin 0.7s linear infinite;
17449
16297
  }
17450
-
17451
- /* Big product / try-on image */
17452
- .ps-msd-image {
17453
- width: 100%; height: 420px;
17454
- border-radius: 12px; overflow: hidden;
17455
- background: var(--ps-bg-secondary);
17456
- display: flex; align-items: center; justify-content: center;
17457
- flex-shrink: 0;
16298
+ @keyframes ps-bp-spin {
16299
+ to { transform: rotate(360deg); }
17458
16300
  }
17459
- .ps-msd-image-img {
17460
- width: 100%; height: 100%;
17461
- object-fit: contain; display: block;
16301
+ .ps-bp-photo-overlay-label {
16302
+ font-size: max(12px, 0.85vw);
16303
+ font-weight: 600;
16304
+ color: var(--ps-text-primary);
16305
+ letter-spacing: 0.01em;
17462
16306
  }
17463
16307
 
17464
- /* RECOMMENDED SIZE / FIT ACCURACY card */
17465
- .ps-msd-card {
17466
- background: var(--ps-bg-primary);
16308
+ /* ── Photo-help popover (Do / Don't / Tip) — desktop default ── */
16309
+ .ps-bp-photo-help-backdrop { display: none; }
16310
+ .ps-bp-photo-help {
16311
+ position: absolute; top: 2.2vw; right: 0;
16312
+ width: min(360px, 22vw);
16313
+ background: #FFFFFF;
17467
16314
  border: 1px solid var(--ps-border-subtle);
17468
- border-radius: 12px;
17469
- padding: 22px 22px 18px;
17470
- display: flex; flex-direction: column; gap: 8px;
17471
- box-shadow: 0 1px 3px rgba(0,0,0,0.04);
17472
- }
17473
- .ps-msd-card-eyebrow {
17474
- font-size: 10px; font-weight: 700;
17475
- letter-spacing: 0.16em; text-transform: uppercase;
17476
- color: var(--ps-text-muted);
16315
+ border-radius: 0.8vw;
16316
+ box-shadow: 0 20px 40px -12px rgba(17,24,39,0.25), 0 8px 16px -8px rgba(17,24,39,0.15);
16317
+ padding: 0.8vw 1vw;
16318
+ display: flex; flex-direction: column; gap: 0.6vw;
16319
+ z-index: 5;
17477
16320
  }
17478
- .ps-msd-card-size-row {
17479
- display: flex; align-items: baseline; gap: 12px;
17480
- margin-top: 2px;
16321
+ .ps-bp-photo-help-handle { display: none; }
16322
+ .ps-bp-photo-help-header { display: flex; align-items: center; justify-content: space-between; }
16323
+ .ps-bp-photo-help-title { font-size: 0.8vw; font-weight: 700; color: var(--ps-text-primary); }
16324
+ .ps-bp-photo-help-close {
16325
+ width: 1.4vw; height: 1.4vw; border-radius: 50%;
16326
+ background: transparent; border: none; color: var(--ps-text-muted);
16327
+ cursor: pointer; font-size: 1vw; line-height: 1;
17481
16328
  }
17482
- .ps-msd-card-size {
17483
- font-size: 56px; font-weight: 700;
16329
+ .ps-bp-photo-help-section { border-radius: 0.5vw; padding: 0.55vw 0.75vw; }
16330
+ .ps-bp-photo-help-section-label { font-size: 0.7vw; font-weight: 700; margin-bottom: 0.3vw; }
16331
+ .ps-bp-photo-help-section-body { font-size: 0.6vw; color: var(--ps-text-primary); line-height: 1.6; }
16332
+ .ps-bp-photo-help-do { background: #ddfbe7; }
16333
+ .ps-bp-photo-help-do .ps-bp-photo-help-section-label { color: #1c9d4c; }
16334
+ .ps-bp-photo-help-dont { background: #ffe2e2; }
16335
+ .ps-bp-photo-help-dont .ps-bp-photo-help-section-label { color: #e7000b; }
16336
+ .ps-bp-photo-help-tip {
16337
+ background: #c6e1f7; font-size: 0.6vw; line-height: 1.55;
17484
16338
  color: var(--ps-text-primary);
17485
- line-height: 1; letter-spacing: -0.02em;
17486
- font-feature-settings: "tnum" 1;
17487
16339
  }
17488
- .ps-msd-card-size-meta {
17489
- font-size: 12px; font-weight: 600;
17490
- letter-spacing: 0.05em;
17491
- color: var(--ps-text-muted);
16340
+ .ps-bp-photo-help-tip strong { color: #3267c3; }
16341
+
16342
+ /* ── MiniSelect: compact animated dropdown for inline bra inputs ── */
16343
+ .ps-bp-mini-select {
16344
+ position: relative;
16345
+ display: inline-flex;
16346
+ width: 100%;
17492
16347
  }
17493
- .ps-msd-card-divider {
17494
- height: 1px; background: var(--ps-border-subtle);
17495
- margin: 6px 0;
16348
+ .ps-bp-mini-select-trigger {
16349
+ display: flex;
16350
+ align-items: center;
16351
+ justify-content: space-between;
16352
+ gap: 0.4vw;
16353
+ width: 100%;
16354
+ min-width: 4vw;
16355
+ padding: 0.45vw 0.7vw;
16356
+ background: var(--ps-bg-secondary, #fff);
16357
+ border: 1.5px solid rgba(0, 0, 0, 0.12);
16358
+ border-radius: 0.4vw;
16359
+ color: var(--ps-text-primary);
16360
+ font-size: max(11px, 0.78vw);
16361
+ font-weight: 600;
16362
+ font-family: inherit;
16363
+ cursor: pointer;
16364
+ transition: border-color 0.18s, background 0.18s, transform 0.12s;
17496
16365
  }
17497
- .ps-msd-card-pct-row {
17498
- display: flex; align-items: center; gap: 12px;
17499
- margin-top: 2px;
16366
+ .ps-bp-mini-select-trigger:hover {
16367
+ border-color: var(--ps-accent);
17500
16368
  }
17501
- .ps-msd-card-pct {
17502
- font-size: 30px; font-weight: 700;
17503
- color: var(--ps-accent);
17504
- line-height: 1; font-feature-settings: "tnum" 1;
16369
+ .ps-bp-mini-select[data-open="true"] .ps-bp-mini-select-trigger {
16370
+ border-color: var(--ps-accent);
16371
+ background: rgba(33, 84, 239, 0.04);
17505
16372
  }
17506
- .ps-msd-card-bar {
17507
- flex: 1; height: 2px;
17508
- background: var(--ps-border-color); border-radius: 1px;
17509
- overflow: hidden;
16373
+ .ps-bp-mini-select-value { display: inline-flex; align-items: center; }
16374
+ .ps-bp-mini-select-placeholder {
16375
+ color: var(--ps-text-secondary);
16376
+ opacity: 0.7;
16377
+ font-weight: 500;
17510
16378
  }
17511
- .ps-msd-card-bar-fill {
17512
- height: 100%; background: var(--ps-accent);
17513
- border-radius: 1px;
16379
+ .ps-bp-mini-select-arrow {
16380
+ font-size: 0.55vw;
16381
+ color: var(--ps-text-secondary);
16382
+ transition: transform 0.22s ease;
17514
16383
  }
17515
- .ps-msd-card-note {
17516
- font-size: 11px; color: var(--ps-text-muted);
17517
- line-height: 1.5; margin: 2px 0 0;
16384
+ .ps-bp-mini-select-arrow.ps-open {
16385
+ transform: rotate(180deg);
16386
+ color: var(--ps-accent);
17518
16387
  }
17519
-
17520
- /* MEASUREMENT BLUEPRINT — rich rows with number, description, USER/SIZE
17521
- labeled values, status badge with check icon */
17522
- .ps-msd-blueprint {
17523
- display: flex; flex-direction: column; gap: 6px;
17524
- padding-top: 6px;
16388
+ .ps-bp-mini-select-panel {
16389
+ position: absolute;
16390
+ left: 0;
16391
+ right: 0;
16392
+ max-height: 9vw;
16393
+ overflow-y: auto;
16394
+ background: var(--ps-bg-secondary, #fff);
16395
+ border: 1.5px solid rgba(0, 0, 0, 0.08);
16396
+ border-radius: 0.45vw;
16397
+ box-shadow:
16398
+ 0 0.25vw 0.55vw rgba(15, 23, 42, 0.08),
16399
+ 0 0.85vw 2.0vw rgba(15, 23, 42, 0.14);
16400
+ padding: 0.25vw;
16401
+ z-index: 250;
17525
16402
  }
17526
- .ps-msd-blueprint-title {
17527
- font-size: 13px; font-weight: 800;
17528
- letter-spacing: 0.12em; text-transform: uppercase;
17529
- color: var(--ps-text-primary);
17530
- margin-bottom: 0;
16403
+ .ps-bp-mini-select[data-dir="down"] .ps-bp-mini-select-panel {
16404
+ top: calc(100% + 0.3vw);
16405
+ transform-origin: top center;
16406
+ animation: ps-bp-mini-slide-down 0.18s cubic-bezier(0.2, 0.8, 0.4, 1) both;
17531
16407
  }
17532
- .ps-msd-blueprint-underline {
17533
- width: 36px; height: 2px;
17534
- background: var(--ps-text-primary);
17535
- margin-bottom: 8px;
16408
+ .ps-bp-mini-select[data-dir="up"] .ps-bp-mini-select-panel {
16409
+ bottom: calc(100% + 0.3vw);
16410
+ transform-origin: bottom center;
16411
+ animation: ps-bp-mini-slide-up 0.18s cubic-bezier(0.2, 0.8, 0.4, 1) both;
17536
16412
  }
17537
- .ps-msd-rows {
17538
- display: flex; flex-direction: column;
16413
+ @keyframes ps-bp-mini-slide-down {
16414
+ from { opacity: 0; transform: translateY(-0.35vw) scale(0.96); }
16415
+ to { opacity: 1; transform: translateY(0) scale(1); }
17539
16416
  }
17540
- .ps-msd-row {
17541
- display: flex; flex-direction: column;
17542
- padding: 22px 0 24px;
17543
- border-bottom: 1px solid var(--ps-border-subtle);
16417
+ @keyframes ps-bp-mini-slide-up {
16418
+ from { opacity: 0; transform: translateY(0.35vw) scale(0.96); }
16419
+ to { opacity: 1; transform: translateY(0) scale(1); }
17544
16420
  }
17545
- .ps-msd-row:last-child { border-bottom: none; }
17546
- .ps-msd-row-num {
17547
- font-size: 11px; font-weight: 700;
17548
- letter-spacing: 0.14em; text-transform: uppercase;
16421
+ .ps-bp-mini-select-item {
16422
+ display: block;
16423
+ width: 100%;
16424
+ padding: 0.45vw 0.7vw;
16425
+ background: transparent;
16426
+ border: none;
16427
+ border-radius: 0.35vw;
16428
+ color: var(--ps-text-secondary);
16429
+ font-size: max(11px, 0.78vw);
16430
+ font-weight: 600;
16431
+ font-family: inherit;
16432
+ text-align: left;
16433
+ cursor: pointer;
16434
+ transition: background 0.12s, color 0.12s, transform 0.12s;
16435
+ }
16436
+ .ps-bp-mini-select-item:hover {
16437
+ background: rgba(33, 84, 239, 0.06);
17549
16438
  color: var(--ps-text-primary);
17550
- margin-bottom: 4px;
17551
16439
  }
17552
- .ps-msd-row-desc {
17553
- font-size: 12px; font-weight: 400;
17554
- color: var(--ps-text-muted);
17555
- margin: 0 0 14px;
17556
- line-height: 1.5;
16440
+ .ps-bp-mini-select-item.ps-selected {
16441
+ background: rgba(33, 84, 239, 0.12);
16442
+ color: var(--ps-accent);
17557
16443
  }
17558
- .ps-msd-row-cells {
17559
- display: flex; justify-content: space-between; align-items: flex-end;
17560
- gap: 16px; margin-bottom: 14px;
16444
+
16445
+ .ps-bp-bra-region-wrap { position: relative; z-index: 100; }
16446
+ .ps-bp-bra-region-trigger {
16447
+ display: flex; align-items: center; gap: 0.35vw;
16448
+ padding: 0.45vw 0.9vw; border: 1.5px solid rgba(0,0,0,0.1);
16449
+ border-radius: 0.4vw; background: transparent;
16450
+ color: var(--ps-text-secondary); font-size: 0.8vw; font-weight: 600;
16451
+ cursor: pointer; font-family: inherit; transition: all 0.25s;
17561
16452
  }
17562
- .ps-msd-row-cell {
17563
- display: flex; flex-direction: column; gap: 4px;
17564
- min-width: 0;
16453
+ .ps-bp-bra-region-trigger:hover { border-color: var(--ps-accent); color: var(--ps-accent); }
16454
+ .ps-bp-bra-region-arrow {
16455
+ font-size: 0.55vw; transition: transform 0.25s;
17565
16456
  }
17566
- .ps-msd-row-cell.ps-right { text-align: right; }
17567
- .ps-msd-cell-label {
17568
- font-size: 9px; font-weight: 600;
17569
- letter-spacing: 0.14em; text-transform: uppercase;
17570
- color: var(--ps-text-muted);
16457
+ .ps-bp-bra-region-arrow.ps-open { transform: rotate(180deg); }
16458
+ .ps-bp-bra-region-dropdown {
16459
+ position: absolute; top: calc(100% + 0.3vw); right: 0;
16460
+ min-width: 5vw; background: var(--ps-bg-secondary);
16461
+ border: 1.5px solid rgba(0,0,0,0.08); border-radius: 0.4vw;
16462
+ box-shadow: 0 0.5vw 1.5vw rgba(0,0,0,0.5);
16463
+ overflow: hidden; animation: ps-bp-fade 0.2s ease both;
16464
+ padding: 0.25vw; z-index: 200;
16465
+ }
16466
+ .ps-bp-bra-region-item {
16467
+ display: block; width: 100%; padding: 0.4vw 0.7vw;
16468
+ background: transparent; border: none; border-radius: 0.3vw;
16469
+ color: var(--ps-text-secondary); font-size: 0.75vw; font-weight: 600;
16470
+ cursor: pointer; font-family: inherit; text-align: left;
16471
+ transition: all 0.15s;
17571
16472
  }
17572
- .ps-msd-cell-value {
17573
- font-size: 18px; font-weight: 700;
17574
- color: var(--ps-text-primary);
17575
- font-feature-settings: "tnum" 1;
17576
- line-height: 1.1;
16473
+ .ps-bp-bra-region-item:hover { background: rgba(0,0,0,0.04); color: var(--ps-text-primary); }
16474
+ .ps-bp-bra-region-item.ps-selected { color: var(--ps-accent); background: rgba(33,84,239,0.1); }
16475
+ .ps-bp-bra-grid {
16476
+ display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.35vw;
17577
16477
  }
17578
- .ps-msd-row-badge {
17579
- align-self: flex-end;
17580
- display: inline-flex; align-items: center; gap: 5px;
17581
- font-size: 10px; font-weight: 700;
17582
- letter-spacing: 0.12em; text-transform: uppercase;
17583
- white-space: nowrap;
16478
+ .ps-bp-bra-btn {
16479
+ padding: 0.4vw 0; text-align: center;
16480
+ background: transparent; border: 1px solid rgba(0,0,0,0.08);
16481
+ border-radius: 0.35vw; color: var(--ps-text-secondary);
16482
+ font-size: 0.65vw; font-weight: 500; cursor: pointer;
16483
+ font-family: inherit; transition: all 0.2s;
17584
16484
  }
17585
- .ps-msd-row-badge svg { flex-shrink: 0; }
17586
- .ps-msd-row-badge.ps-good {
17587
- color: var(--ps-accent);
16485
+ .ps-bp-bra-btn:hover { border-color: rgba(0,0,0,0.3); color: var(--ps-text-primary); }
16486
+ .ps-bp-bra-btn-selected {
16487
+ background: var(--ps-accent) !important; border-color: var(--ps-accent) !important;
16488
+ color: #fff !important; font-weight: 700;
17588
16489
  }
17589
- .ps-msd-row-badge.ps-tight {
17590
- color: #dc2626;
16490
+ /* Bra inline result — shown in header next to region switcher */
16491
+ .ps-bp-bra-inline-result {
16492
+ margin-left: auto; font-size: max(11px, 0.75vw); color: var(--ps-accent);
16493
+ font-weight: 600; white-space: nowrap;
17591
16494
  }
17592
- .ps-msd-row-badge.ps-loose {
17593
- color: #d97706;
16495
+ .ps-bp-bra-inline-result strong {
16496
+ font-weight: 700;
17594
16497
  }
17595
16498
 
17596
- /* TRY ANOTHER SIZE picker */
17597
- .ps-msd-sizes {
17598
- display: flex; flex-direction: column; gap: 10px;
17599
- padding-top: 18px;
16499
+ /* Profile option rows WAIR style stacked list */
16500
+ .ps-bp-options {
16501
+ display: flex; flex-direction: column; gap: 0.5vw;
16502
+ margin-top: 0.8vw;
17600
16503
  }
17601
- .ps-msd-sizes-label {
17602
- font-size: 11px; font-weight: 700;
17603
- letter-spacing: 0.14em; text-transform: uppercase;
17604
- color: var(--ps-text-muted);
17605
- text-align: center;
16504
+ .ps-bp-option {
16505
+ display: flex; align-items: center; gap: 0.8vw;
16506
+ padding: 0.35vw 1vw 0.35vw 0.35vw;
16507
+ background: #fff; border: 1.5px solid rgba(0,0,0,0.06);
16508
+ border-radius: 0.6vw; cursor: pointer;
16509
+ transition: all 0.25s ease; font-family: inherit;
16510
+ box-shadow: 0 1px 4px rgba(0,0,0,0.05);
17606
16511
  }
17607
- .ps-msd-sizes-pills {
17608
- display: flex; flex-wrap: wrap; gap: 8px;
17609
- justify-content: center;
16512
+ .ps-bp-option:hover {
16513
+ border-color: rgba(33,84,239,0.5);
16514
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
16515
+ transform: translateY(-1px);
17610
16516
  }
17611
- .ps-msd-size-pill {
17612
- min-width: 48px;
17613
- padding: 10px 16px;
17614
- background: transparent;
17615
- border: 1.5px solid var(--ps-border-color);
17616
- border-radius: 8px;
17617
- font-family: inherit; font-size: 13px; font-weight: 600;
17618
- color: var(--ps-text-primary);
17619
- cursor: pointer;
17620
- transition: all 0.15s;
16517
+ .ps-bp-option:hover .ps-bp-illust svg { transform: scale(1.05); }
16518
+ .ps-bp-option-selected {
16519
+ border-color: var(--ps-accent) !important;
16520
+ box-shadow: 0 4px 12px rgba(33,84,239,0.18) !important;
17621
16521
  }
17622
- .ps-msd-size-pill:hover {
17623
- border-color: var(--ps-accent);
16522
+ .ps-bp-option-icon {
16523
+ width: 6vw; height: 6vw; flex-shrink: 0;
16524
+ display: flex; align-items: center; justify-content: center;
16525
+ border-radius: 0.4vw; overflow: hidden;
17624
16526
  }
17625
- .ps-msd-size-pill.ps-active {
17626
- background: var(--ps-accent); color: #FFFFFF;
17627
- border-color: var(--ps-accent);
16527
+ .ps-bp-illust { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
16528
+ .ps-bp-illust svg { width: 100%; height: 100%; transition: transform 0.25s ease; }
16529
+ .ps-bp-option-label {
16530
+ flex: 1; font-size: 0.73vw; font-weight: 500;
16531
+ color: #555; text-align: center;
16532
+ }
16533
+ .ps-bp-option-selected .ps-bp-option-label { color: var(--ps-accent); font-weight: 600; }
16534
+ .ps-bp-option::after {
16535
+ content: "›"; font-size: 1.1vw; color: var(--ps-text-secondary); flex-shrink: 0;
16536
+ transition: all 0.25s ease;
17628
16537
  }
16538
+ .ps-bp-option:hover::after { color: var(--ps-accent); transform: translateX(2px); }
16539
+ .ps-bp-option-selected::after { color: var(--ps-accent); }
17629
16540
 
17630
- /* Bottom action areaNext button + tab bar grouped together,
17631
- pushed to the bottom of the modal body by .ps-bpm-spacer above. */
17632
- .ps-bpm-bottom {
17633
- display: flex; flex-direction: column;
17634
- margin: 0 -16px;
17635
- border-top: 1px solid var(--ps-border-subtle);
17636
- background: var(--ps-bg-primary);
17637
- flex-shrink: 0;
17638
- padding-bottom: env(safe-area-inset-bottom);
16541
+ /* Single question step3 cards centered */
16542
+ .ps-bp-single-q { display: flex; flex-direction: column; flex: 1; justify-content: center; align-items: center; }
16543
+ .ps-bp-single-q .ps-bp-title { margin-bottom: 1vw; width: 100%; text-align: center; font-size: 1.05vw; font-weight: 700; letter-spacing: 0.01em; }
16544
+ .ps-bp-shape-row-full { width: 100%; margin: 1.5vw auto 0; }
16545
+ .ps-bp-shape-row-full .ps-bp-img-card { max-height: 18vw; padding: 0.8vw 0.6vw 0; }
16546
+ .ps-bp-shape-row-full .ps-bp-img-card-thumb { height: 13vw; }
16547
+
16548
+ /* Body shapes screen — fit all questions without scrolling */
16549
+ .ps-bp-shapes-fit { display: flex; flex-direction: column; flex: 1; overflow: hidden; gap: 0.4vw; justify-content: flex-start !important; }
16550
+
16551
+ /* Body shapes combined screen */
16552
+ .ps-bp-shape-section {
16553
+ margin-bottom: 0.4vw; flex: 1; display: flex; flex-direction: column; min-height: 0;
17639
16554
  }
17640
- .ps-bpm-next-btn {
17641
- margin: max(14px, 3.5vw) max(16px, 4.1vw) max(12px, 3vw);
17642
- padding: max(14px, 3.5vw) max(18px, 4.6vw);
17643
- background: var(--ps-accent); color: #FFFFFF;
17644
- border: none; border-radius: max(10px, 2.5vw);
17645
- font-family: inherit; font-size: max(14px, 3.6vw); font-weight: 700;
17646
- cursor: pointer; transition: opacity 0.15s, transform 0.15s;
17647
- letter-spacing: 0.02em;
16555
+ .ps-bp-shape-label {
16556
+ font-size: 0.55vw; font-weight: 500; color: rgba(255,255,255,0.7);
16557
+ margin-bottom: 0.3vw; display: flex; align-items: center; gap: 0.6vw;
16558
+ text-transform: uppercase; letter-spacing: 0.05em;
17648
16559
  }
17649
- .ps-bpm-next-btn:active { transform: scale(0.98); }
17650
- .ps-bpm-next-btn:disabled { opacity: 0.4; cursor: not-allowed; }
17651
- .ps-bpm-bottom-tabs {
17652
- display: flex; align-items: stretch;
17653
- border-top: 1px solid var(--ps-border-subtle);
17654
- background: var(--ps-bg-primary);
16560
+ .ps-bp-shape-label::after {
16561
+ content: ""; flex: 1; height: 1px;
16562
+ background: linear-gradient(to right, rgba(0,0,0,0.08), transparent);
17655
16563
  }
17656
- .ps-bpm-bottom-tab {
17657
- flex: 1; background: none; border: none;
17658
- padding: max(14px, 3.5vw) max(8px, 2vw) max(12px, 3vw);
17659
- font-family: inherit; font-size: max(10px, 2.5vw); font-weight: 700;
17660
- letter-spacing: 0.12em; text-transform: uppercase;
17661
- color: var(--ps-text-muted); cursor: pointer;
17662
- border-bottom: 2px solid transparent;
17663
- transition: color 0.15s, border-color 0.15s;
17664
- display: flex; align-items: center; justify-content: center; gap: max(6px, 1.5vw);
16564
+ .ps-bp-shape-row {
16565
+ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5vw;
16566
+ flex: 1; min-height: 0;
17665
16567
  }
17666
- .ps-bpm-bottom-tab.ps-active {
17667
- color: var(--ps-accent);
17668
- border-bottom-color: var(--ps-accent);
16568
+ .ps-bp-img-card {
16569
+ display: flex; flex-direction: column; align-items: center;
16570
+ background: transparent; border: 2px solid rgba(0,0,0,0.08);
16571
+ border-radius: 0.5vw; overflow: hidden; cursor: pointer;
16572
+ transition: all 0.2s ease; padding: 0; font-family: inherit;
16573
+ position: relative;
17669
16574
  }
17670
- .ps-bpm-bottom-tab svg { width: max(13px, 3.3vw); height: max(13px, 3.3vw); flex-shrink: 0; }
17671
-
17672
- .ps-bp-optional { font-weight: 400; color: var(--ps-text-muted); }
17673
-
17674
- /* Bra size step — vertically centered */
17675
- .ps-bp-bra-step { display: flex; flex-direction: column; justify-content: center; flex: 1; }
17676
- .ps-bp-bra-step .ps-bp-title { text-align: center; margin-bottom: 1.5vw; }
17677
-
17678
- /* Bra size selector */
17679
- .ps-bp-bra-section { margin-bottom: 1.2vw; }
17680
- .ps-bp-bra-header {
17681
- display: flex; align-items: center; justify-content: space-between;
17682
- margin-bottom: 0.5vw;
16575
+ .ps-bp-img-card:hover {
16576
+ border-color: rgba(33,84,239,0.4);
16577
+ transform: translateY(-0.1vw);
17683
16578
  }
17684
- .ps-bp-bra-label {
17685
- font-size: 0.6vw; font-weight: 600; color: rgba(255,255,255,0.45);
17686
- letter-spacing: 0.08em; text-transform: uppercase;
16579
+ .ps-bp-img-card-selected {
16580
+ border-color: var(--ps-accent) !important;
16581
+ border-width: 2.5px !important;
17687
16582
  }
17688
- /* Region dropdown */
17689
- /* ── Photo upload overlay (shown during compress + age-check) ── */
17690
- .ps-bp-photo-overlay {
17691
- position: absolute;
17692
- inset: 0;
17693
- background: rgba(255, 255, 255, 0.92);
17694
- display: flex;
17695
- flex-direction: column;
17696
- align-items: center;
17697
- justify-content: center;
17698
- gap: 0.9vw;
17699
- z-index: 10;
17700
- border-radius: inherit;
17701
- backdrop-filter: blur(2px);
17702
- animation: ps-bp-fade 0.18s ease both;
16583
+ .ps-bp-img-card-selected .ps-bp-img-card-label {
16584
+ font-weight: 700; color: var(--ps-accent);
17703
16585
  }
17704
- .ps-bp-photo-overlay-spinner {
17705
- width: 2.2vw;
17706
- height: 2.2vw;
17707
- min-width: 28px;
17708
- min-height: 28px;
17709
- border-radius: 50%;
17710
- border: 2.5px solid rgba(33, 84, 239, 0.18);
17711
- border-top-color: var(--ps-accent);
17712
- animation: ps-bp-spin 0.7s linear infinite;
16586
+ .ps-bp-img-card-check {
16587
+ display: none; position: absolute; top: 0.3vw; right: 0.3vw;
16588
+ width: 1.1vw; height: 1.1vw; border-radius: 50%;
16589
+ background: var(--ps-accent); align-items: center; justify-content: center;
16590
+ z-index: 1;
17713
16591
  }
17714
- @keyframes ps-bp-spin {
17715
- to { transform: rotate(360deg); }
16592
+ .ps-bp-img-card-check svg { width: 0.6vw; height: 0.6vw; }
16593
+ .ps-bp-img-card-selected .ps-bp-img-card-check { display: flex; }
16594
+ .ps-bp-img-card .ps-bp-img-card-thumb { opacity: 1; }
16595
+ .ps-bp-img-card-thumb {
16596
+ width: 100%; flex: 1; min-height: 0; overflow: hidden;
16597
+ display: flex; align-items: center; justify-content: center;
16598
+ background: transparent;
17716
16599
  }
17717
- .ps-bp-photo-overlay-label {
17718
- font-size: max(12px, 0.85vw);
17719
- font-weight: 600;
17720
- color: var(--ps-text-primary);
17721
- letter-spacing: 0.01em;
16600
+ .ps-bp-img-card-img {
16601
+ width: 100%; height: 100%; object-fit: contain;
16602
+ }
16603
+ .ps-bp-img-card .ps-bp-illust { width: 80%; height: 80%; }
16604
+ .ps-bp-img-card .ps-bp-illust svg { width: 100%; height: 100%; }
16605
+ .ps-bp-img-card-label {
16606
+ padding: 0.3vw 0.2vw; font-size: 0.6vw; font-weight: 600;
16607
+ color: var(--ps-text-secondary); text-align: center; width: 100%;
16608
+ transition: opacity 0.2s;
16609
+ }
16610
+ /* Hover hint text below cards */
16611
+ .ps-bp-hover-hint {
16612
+ text-align: center; font-size: 0.8vw; color: rgba(0,0,0,0.45);
16613
+ margin: 0.15vw 0 0; min-height: 1em; transition: opacity 0.2s;
16614
+ font-weight: 400; letter-spacing: 0.01em;
16615
+ }
16616
+
16617
+ /* Photo upload */
16618
+ .ps-bp-photo-upload {
16619
+ display: flex; align-items: center; gap: 0.6vw;
16620
+ width: 100%; padding: 0.7vw 0.8vw;
16621
+ background: transparent; border: 1.5px dashed var(--ps-border-color);
16622
+ border-radius: 0.5vw; cursor: pointer;
16623
+ transition: all 0.2s; color: var(--ps-text-muted);
16624
+ font-family: inherit; text-align: left;
16625
+ }
16626
+ .ps-bp-photo-upload:hover {
16627
+ border-color: var(--ps-accent); background: rgba(33,84,239,0.04);
16628
+ color: var(--ps-text-secondary);
17722
16629
  }
16630
+ .ps-bp-photo-upload svg { flex-shrink: 0; opacity: 0.6; }
16631
+ .ps-bp-photo-upload-text { font-size: 0.78vw; font-weight: 500; }
16632
+ .ps-bp-photo-upload-hint { font-size: 0.62vw; color: var(--ps-text-muted); margin-left: auto; }
17723
16633
 
17724
- /* ── Photo-help popover (Do / Don't / Tip) — desktop default ── */
17725
- .ps-bp-photo-help-backdrop { display: none; }
17726
- .ps-bp-photo-help {
17727
- position: absolute; top: 2.2vw; right: 0;
17728
- width: min(360px, 22vw);
17729
- background: #FFFFFF;
17730
- border: 1px solid var(--ps-border-subtle);
17731
- border-radius: 0.8vw;
17732
- box-shadow: 0 20px 40px -12px rgba(17,24,39,0.25), 0 8px 16px -8px rgba(17,24,39,0.15);
17733
- padding: 0.8vw 1vw;
17734
- display: flex; flex-direction: column; gap: 0.6vw;
17735
- z-index: 5;
16634
+ .ps-bp-photo-preview {
16635
+ display: flex; align-items: center; gap: 0.6vw;
16636
+ padding: 0.5vw; border: 1.5px solid var(--ps-accent);
16637
+ border-radius: 0.5vw; background: rgba(33,84,239,0.06);
17736
16638
  }
17737
- .ps-bp-photo-help-handle { display: none; }
17738
- .ps-bp-photo-help-header { display: flex; align-items: center; justify-content: space-between; }
17739
- .ps-bp-photo-help-title { font-size: 0.8vw; font-weight: 700; color: var(--ps-text-primary); }
17740
- .ps-bp-photo-help-close {
17741
- width: 1.4vw; height: 1.4vw; border-radius: 50%;
17742
- background: transparent; border: none; color: var(--ps-text-muted);
17743
- cursor: pointer; font-size: 1vw; line-height: 1;
16639
+ .ps-bp-photo-thumb {
16640
+ width: 2.8vw; height: 2.8vw; object-fit: cover;
16641
+ border-radius: 0.4vw; flex-shrink: 0;
17744
16642
  }
17745
- .ps-bp-photo-help-section { border-radius: 0.5vw; padding: 0.55vw 0.75vw; }
17746
- .ps-bp-photo-help-section-label { font-size: 0.7vw; font-weight: 700; margin-bottom: 0.3vw; }
17747
- .ps-bp-photo-help-section-body { font-size: 0.6vw; color: var(--ps-text-primary); line-height: 1.6; }
17748
- .ps-bp-photo-help-do { background: #ddfbe7; }
17749
- .ps-bp-photo-help-do .ps-bp-photo-help-section-label { color: #1c9d4c; }
17750
- .ps-bp-photo-help-dont { background: #ffe2e2; }
17751
- .ps-bp-photo-help-dont .ps-bp-photo-help-section-label { color: #e7000b; }
17752
- .ps-bp-photo-help-tip {
17753
- background: #c6e1f7; font-size: 0.6vw; line-height: 1.55;
17754
- color: var(--ps-text-primary);
16643
+ .ps-bp-photo-info { display: flex; flex-direction: column; gap: 0.1vw; flex: 1; min-width: 0; }
16644
+ .ps-bp-photo-status {
16645
+ font-size: 0.73vw; font-weight: 600; color: var(--ps-text-primary);
16646
+ display: flex; align-items: center; gap: 0.3vw;
17755
16647
  }
17756
- .ps-bp-photo-help-tip strong { color: #3267c3; }
16648
+ .ps-bp-photo-hint { font-size: 0.62vw; color: var(--ps-text-muted); }
16649
+ .ps-bp-photo-remove {
16650
+ width: 1.5vw; height: 1.5vw; display: flex; align-items: center; justify-content: center;
16651
+ background: none; border: none; color: var(--ps-text-muted);
16652
+ cursor: pointer; font-size: 1vw; line-height: 1; border-radius: 0.25vw;
16653
+ transition: all 0.15s; flex-shrink: 0;
16654
+ }
16655
+ .ps-bp-photo-remove:hover { background: rgba(0,0,0,0.06); color: var(--ps-text-primary); }
17757
16656
 
17758
- /* ── MiniSelect: compact animated dropdown for inline bra inputs ── */
17759
- .ps-bp-mini-select {
17760
- position: relative;
17761
- display: inline-flex;
17762
- width: 100%;
16657
+ /* Error */
16658
+ .ps-bp-error {
16659
+ color: var(--ps-error-color); font-size: 0.73vw; margin: 0;
17763
16660
  }
17764
- .ps-bp-mini-select-trigger {
17765
- display: flex;
17766
- align-items: center;
16661
+
16662
+ /* Navigation — back left, next right */
16663
+ .ps-bp-nav {
16664
+ display: flex; align-items: center; margin-top: auto;
17767
16665
  justify-content: space-between;
17768
- gap: 0.4vw;
17769
- width: 100%;
17770
- min-width: 4vw;
17771
- padding: 0.45vw 0.7vw;
17772
- background: var(--ps-bg-secondary, #fff);
17773
- border: 1.5px solid rgba(0, 0, 0, 0.12);
17774
- border-radius: 0.4vw;
17775
- color: var(--ps-text-primary);
17776
- font-size: max(11px, 0.78vw);
17777
- font-weight: 600;
17778
- font-family: inherit;
17779
- cursor: pointer;
17780
- transition: border-color 0.18s, background 0.18s, transform 0.12s;
17781
16666
  }
17782
- .ps-bp-mini-select-trigger:hover {
17783
- border-color: var(--ps-accent);
16667
+ .ps-bp-back-btn {
16668
+ background: none; border: none; padding: 0;
16669
+ color: var(--ps-text-muted); cursor: pointer;
16670
+ font-size: 0.83vw; font-weight: 500; font-family: inherit;
16671
+ white-space: nowrap; display: flex; align-items: center; gap: 0.3vw;
16672
+ transition: color 0.2s;
17784
16673
  }
17785
- .ps-bp-mini-select[data-open="true"] .ps-bp-mini-select-trigger {
17786
- border-color: var(--ps-accent);
17787
- background: rgba(33, 84, 239, 0.04);
16674
+ .ps-bp-back-btn:hover { color: var(--ps-text-primary); }
16675
+ .ps-bp-back-arrow {
16676
+ display: inline-block; font-size: 1.1vw; transition: transform 0.25s ease;
17788
16677
  }
17789
- .ps-bp-mini-select-value { display: inline-flex; align-items: center; }
17790
- .ps-bp-mini-select-placeholder {
17791
- color: var(--ps-text-secondary);
17792
- opacity: 0.7;
17793
- font-weight: 500;
16678
+ .ps-bp-back-btn:hover .ps-bp-back-arrow { transform: translateX(-4px); }
16679
+ .ps-bp-next-btn {
16680
+ display: flex; align-items: center; gap: 0.5vw;
16681
+ padding: 0.45vw 1.2vw;
16682
+ background: var(--ps-accent); border: none; border-radius: 0.35vw;
16683
+ color: #fff; font-size: 0.73vw; font-weight: 700;
16684
+ cursor: pointer; transition: all 0.15s; font-family: inherit;
16685
+ white-space: nowrap;
17794
16686
  }
17795
- .ps-bp-mini-select-arrow {
17796
- font-size: 0.55vw;
17797
- color: var(--ps-text-secondary);
17798
- transition: transform 0.22s ease;
16687
+ .ps-bp-next-btn svg { width: 0.75vw; height: 0.75vw; }
16688
+ .ps-bp-next-btn:hover { background: var(--ps-accent-hover); }
16689
+ .ps-bp-btn-disabled {
16690
+ opacity: 0.35; cursor: not-allowed; pointer-events: none;
17799
16691
  }
17800
- .ps-bp-mini-select-arrow.ps-open {
17801
- transform: rotate(180deg);
17802
- color: var(--ps-accent);
16692
+
16693
+ /* ── Mobile responsive ── */
16694
+ @media (max-width: 768px) {
16695
+ .ps-bp-layout { flex-direction: column; height: auto; gap: 0; overflow: visible; }
16696
+ /* Desktop layout's product image — hidden on mobile because BasicsStepMobile
16697
+ has its own minimal layout. The other steps (chest/midsection/hips) don't
16698
+ need the giant left-column image on a phone either. */
16699
+ .ps-bp-image { display: none !important; }
16700
+ .ps-bp-wrapper { overflow-y: auto; -webkit-overflow-scrolling: touch; flex: 1; min-height: 0; display: flex; flex-direction: column; }
16701
+ .ps-bp-layout { flex: 1; min-height: 0; }
16702
+ /* Override desktop justify-content:center so BasicsStepMobile can
16703
+ stretch to the bottom of the modal — its bottom action bar (Next +
16704
+ tabs) needs to land at the bottom edge, not floating in the middle. */
16705
+ .ps-bp-root {
16706
+ justify-content: stretch !important;
16707
+ height: auto !important;
16708
+ max-height: none !important;
16709
+ overflow: visible !important;
16710
+ flex: 1 !important;
16711
+ }
16712
+ .ps-bp-upload-placeholder svg { width: 32px; height: 32px; }
16713
+ .ps-bp-upload-text { font-size: 14px; }
16714
+ .ps-bp-upload-hint { font-size: 11px; }
16715
+ .ps-bp-root { padding: 16px; gap: 14px; overflow: visible; }
16716
+ .ps-bp-dots { gap: 6px; }
16717
+ .ps-bp-dot { width: 6px; height: 6px; }
16718
+ .ps-bp-dot-active { width: 18px; border-radius: 4px; }
16719
+ .ps-bp-title { font-size: 18px; }
16720
+ .ps-bp-subtitle { font-size: 13px; margin-top: -4px; }
16721
+ .ps-bp-system-toggle { gap: 20px; margin: 4px 0; }
16722
+ .ps-bp-system-btn { font-size: 13px; }
16723
+ .ps-bp-inline-row { padding: 14px 0; gap: 12px; }
16724
+ .ps-bp-inline-label { flex: 0 0 85px; font-size: 11px; white-space: nowrap; }
16725
+ .ps-bp-inline-input { font-size: 18px; }
16726
+ .ps-bp-inline-unit { font-size: 12px; }
16727
+ .ps-bp-photo-cta { font-size: 13px; gap: 6px; margin-top: 4px; }
16728
+ .ps-bp-photo-cta svg { width: 16px; height: 16px; }
16729
+ .ps-bp-options { gap: 6px; }
16730
+ .ps-bp-option { border-radius: 10px; padding: 6px 12px 6px 6px; gap: 10px; }
16731
+ .ps-bp-option-icon { width: 70px; height: 70px; }
16732
+ .ps-bp-option-label { font-size: 14px; }
16733
+ .ps-bp-option::after { font-size: 18px; }
16734
+ .ps-bp-option { padding: 14px 8px; border-radius: 10px; gap: 8px; }
16735
+ .ps-bp-option-icon { width: 56px; height: 56px; }
16736
+ .ps-bp-option-label { font-size: 13px; }
16737
+ .ps-bp-error { font-size: 12px; }
16738
+ .ps-bp-shape-section { margin-bottom: 12px; }
16739
+ .ps-bp-shape-label { font-size: 13px; margin-bottom: 6px; }
16740
+ .ps-bp-shape-row { gap: 6px; }
16741
+ .ps-bp-img-card { border-radius: 8px; border-width: 2px; }
16742
+ .ps-bp-img-card-label { font-size: 11px; padding: 4px 2px; }
16743
+ .ps-bp-hover-hint { font-size: 10px; margin-top: 6px; }
16744
+ .ps-bp-single-q .ps-bp-title { font-size: 13px; font-weight: 400; }
16745
+ .ps-bp-photo-upload { padding: 10px 12px; gap: 8px; border-radius: 8px; }
16746
+ .ps-bp-photo-upload-text { font-size: 13px; }
16747
+ .ps-bp-photo-upload-hint { font-size: 10px; }
16748
+ .ps-bp-photo-preview { padding: 8px; gap: 8px; border-radius: 8px; }
16749
+ .ps-bp-photo-thumb { width: 40px; height: 40px; border-radius: 6px; }
16750
+ .ps-bp-photo-status { font-size: 12px; gap: 4px; }
16751
+ .ps-bp-photo-hint { font-size: 10px; }
16752
+ .ps-bp-photo-remove { width: 22px; height: 22px; font-size: 16px; }
16753
+ .ps-bp-nav { padding-top: 6px; }
16754
+ .ps-bp-back-btn { font-size: 14px; }
16755
+ .ps-bp-back-arrow { font-size: 18px; }
16756
+ .ps-bp-next-btn {
16757
+ display: flex; align-items: center; gap: 0.5vw; padding: 10px 16px; font-size: 13px; border-radius: 6px; }
16758
+ .ps-bp-root { padding: 12px; overflow: hidden; }
16759
+ /* Accessory sizing inputs — mobile */
16760
+ .ps-acc-input { padding: 3vw; font-size: 3.6vw; border-radius: 2vw; }
16761
+ .ps-acc-label { font-size: 2.8vw; margin-bottom: 1.5vw; }
16762
+ .ps-acc-field { margin: 3vw 0; }
16763
+ .ps-acc-error { font-size: 2.8vw; }
16764
+ .ps-acc-hint { font-size: 2.5vw; }
16765
+ .ps-tryon-unit-toggle { border-radius: 2vw; }
16766
+ .ps-tryon-unit-btn { padding: 2vw 4vw; font-size: 3vw; }
17803
16767
  }
17804
- .ps-bp-mini-select-panel {
17805
- position: absolute;
17806
- left: 0;
17807
- right: 0;
17808
- max-height: 9vw;
17809
- overflow-y: auto;
17810
- background: var(--ps-bg-secondary, #fff);
17811
- border: 1.5px solid rgba(0, 0, 0, 0.08);
17812
- border-radius: 0.45vw;
17813
- box-shadow:
17814
- 0 0.25vw 0.55vw rgba(15, 23, 42, 0.08),
17815
- 0 0.85vw 2.0vw rgba(15, 23, 42, 0.14);
17816
- padding: 0.25vw;
17817
- z-index: 250;
16768
+
16769
+ /* Upload hover overlay */
16770
+ .ps-tryon-upload-hover:hover .ps-tryon-upload-hover-overlay { opacity: 1 !important; }
16771
+
16772
+ /* ─────────── Unified PhotoUploadZone ─────────── */
16773
+ .ps-photo-zone {
16774
+ flex: 1; min-height: 220px;
16775
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
16776
+ border: 2px dashed var(--ps-border-color);
16777
+ border-radius: 0.5vw;
16778
+ background: var(--ps-bg-secondary);
16779
+ cursor: pointer; position: relative; overflow: hidden;
16780
+ transition: border-color 0.18s, background 0.18s, transform 0.18s;
17818
16781
  }
17819
- .ps-bp-mini-select[data-dir="down"] .ps-bp-mini-select-panel {
17820
- top: calc(100% + 0.3vw);
17821
- transform-origin: top center;
17822
- animation: ps-bp-mini-slide-down 0.18s cubic-bezier(0.2, 0.8, 0.4, 1) both;
16782
+ .ps-photo-zone:hover { border-color: var(--ps-accent); background: rgba(33,84,239,0.02); }
16783
+ .ps-photo-zone.ps-photo-zone-drag {
16784
+ border-color: var(--ps-accent); border-style: solid;
16785
+ background: rgba(33,84,239,0.06);
16786
+ transform: scale(1.005);
17823
16787
  }
17824
- .ps-bp-mini-select[data-dir="up"] .ps-bp-mini-select-panel {
17825
- bottom: calc(100% + 0.3vw);
17826
- transform-origin: bottom center;
17827
- animation: ps-bp-mini-slide-up 0.18s cubic-bezier(0.2, 0.8, 0.4, 1) both;
16788
+ .ps-photo-zone.ps-photo-zone-has { border: none; cursor: default; padding: 0; }
16789
+ .ps-photo-zone.ps-photo-zone-inline { min-height: 100%; height: 100%; }
16790
+
16791
+ .ps-photo-zone-empty {
16792
+ display: flex; flex-direction: column; align-items: center; gap: 0.4vw;
16793
+ padding: 1vw; text-align: center; pointer-events: none;
17828
16794
  }
17829
- @keyframes ps-bp-mini-slide-down {
17830
- from { opacity: 0; transform: translateY(-0.35vw) scale(0.96); }
17831
- to { opacity: 1; transform: translateY(0) scale(1); }
16795
+ .ps-photo-zone-title { font-size: 0.85vw; font-weight: 600; color: var(--ps-text-primary); }
16796
+ .ps-photo-zone-hint { font-size: 0.6vw; color: var(--ps-text-muted); line-height: 1.4; max-width: 24vw; }
16797
+
16798
+ .ps-photo-zone-img {
16799
+ width: 100%; height: 100%; object-fit: contain;
16800
+ display: block; cursor: pointer;
17832
16801
  }
17833
- @keyframes ps-bp-mini-slide-up {
17834
- from { opacity: 0; transform: translateY(0.35vw) scale(0.96); }
17835
- to { opacity: 1; transform: translateY(0) scale(1); }
16802
+ .ps-photo-zone-hover-overlay {
16803
+ position: absolute; inset: 0;
16804
+ display: flex; align-items: center; justify-content: center;
16805
+ background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s;
16806
+ color: #fff; font-size: 0.8vw; font-weight: 600;
16807
+ border-radius: 0.5vw; cursor: pointer;
17836
16808
  }
17837
- .ps-bp-mini-select-item {
17838
- display: block;
17839
- width: 100%;
17840
- padding: 0.45vw 0.7vw;
17841
- background: transparent;
17842
- border: none;
17843
- border-radius: 0.35vw;
17844
- color: var(--ps-text-secondary);
17845
- font-size: max(11px, 0.78vw);
17846
- font-weight: 600;
17847
- font-family: inherit;
17848
- text-align: left;
17849
- cursor: pointer;
17850
- transition: background 0.12s, color 0.12s, transform 0.12s;
16809
+ .ps-photo-zone:hover .ps-photo-zone-hover-overlay { opacity: 1; }
16810
+ .ps-photo-zone-remove {
16811
+ position: absolute; top: 0.5vw; right: 0.5vw;
16812
+ background: rgba(0,0,0,0.55); color: #fff; border: none; border-radius: 50%;
16813
+ width: 24px; height: 24px; min-width: 24px;
16814
+ display: flex; align-items: center; justify-content: center;
16815
+ cursor: pointer; transition: background 0.15s;
16816
+ z-index: 2;
17851
16817
  }
17852
- .ps-bp-mini-select-item:hover {
17853
- background: rgba(33, 84, 239, 0.06);
17854
- color: var(--ps-text-primary);
16818
+ .ps-photo-zone-remove:hover { background: rgba(239,68,68,0.85); }
16819
+
16820
+ .ps-photo-zone-processing {
16821
+ display: flex; flex-direction: column; align-items: center; gap: 0.5vw;
16822
+ padding: 1vw;
17855
16823
  }
17856
- .ps-bp-mini-select-item.ps-selected {
17857
- background: rgba(33, 84, 239, 0.12);
17858
- color: var(--ps-accent);
16824
+ .ps-photo-zone-spinner {
16825
+ width: 28px; height: 28px;
16826
+ border: 3px solid rgba(33,84,239,0.18);
16827
+ border-top-color: var(--ps-accent);
16828
+ border-radius: 50%;
16829
+ animation: ps-spin 0.7s linear infinite;
17859
16830
  }
16831
+ .ps-photo-zone-status { font-size: 0.7vw; color: var(--ps-text-secondary); }
17860
16832
 
17861
- .ps-bp-bra-region-wrap { position: relative; z-index: 100; }
17862
- .ps-bp-bra-region-trigger {
17863
- display: flex; align-items: center; gap: 0.35vw;
17864
- padding: 0.45vw 0.9vw; border: 1.5px solid rgba(0,0,0,0.1);
17865
- border-radius: 0.4vw; background: transparent;
17866
- color: var(--ps-text-secondary); font-size: 0.8vw; font-weight: 600;
17867
- cursor: pointer; font-family: inherit; transition: all 0.25s;
16833
+ .ps-photo-zone-rejection {
16834
+ display: flex; flex-direction: column; align-items: center; gap: 0.5vw;
16835
+ padding: 1vw 1.2vw; max-width: 22vw; text-align: center;
16836
+ cursor: default;
17868
16837
  }
17869
- .ps-bp-bra-region-trigger:hover { border-color: var(--ps-accent); color: var(--ps-accent); }
17870
- .ps-bp-bra-region-arrow {
17871
- font-size: 0.55vw; transition: transform 0.25s;
16838
+ .ps-photo-zone-rejection-icon {
16839
+ width: 36px; height: 36px; border-radius: 50%;
16840
+ background: rgba(239,68,68,0.12); color: #dc2626;
16841
+ display: flex; align-items: center; justify-content: center;
16842
+ font-size: 18px; font-weight: 700;
17872
16843
  }
17873
- .ps-bp-bra-region-arrow.ps-open { transform: rotate(180deg); }
17874
- .ps-bp-bra-region-dropdown {
17875
- position: absolute; top: calc(100% + 0.3vw); right: 0;
17876
- min-width: 5vw; background: var(--ps-bg-secondary);
17877
- border: 1.5px solid rgba(0,0,0,0.08); border-radius: 0.4vw;
17878
- box-shadow: 0 0.5vw 1.5vw rgba(0,0,0,0.5);
17879
- overflow: hidden; animation: ps-bp-fade 0.2s ease both;
17880
- padding: 0.25vw; z-index: 200;
16844
+ .ps-photo-zone-rejection-title { font-size: 0.85vw; font-weight: 700; color: var(--ps-text-primary); }
16845
+ .ps-photo-zone-rejection-msg { font-size: 0.65vw; color: var(--ps-text-secondary); line-height: 1.5; }
16846
+ .ps-photo-zone-rejection-cta {
16847
+ margin-top: 0.3vw; padding: 0.55vw 1vw;
16848
+ background: var(--ps-accent); color: #fff; border: none;
16849
+ border-radius: 0.4vw; font-family: inherit;
16850
+ font-size: 0.7vw; font-weight: 600; cursor: pointer;
16851
+ transition: opacity 0.15s;
17881
16852
  }
17882
- .ps-bp-bra-region-item {
17883
- display: block; width: 100%; padding: 0.4vw 0.7vw;
17884
- background: transparent; border: none; border-radius: 0.3vw;
17885
- color: var(--ps-text-secondary); font-size: 0.75vw; font-weight: 600;
17886
- cursor: pointer; font-family: inherit; text-align: left;
17887
- transition: all 0.15s;
16853
+ .ps-photo-zone-rejection-cta:hover { opacity: 0.9; }
16854
+
16855
+ .ps-photo-zone-error {
16856
+ position: absolute; bottom: 0.6vw; left: 0.6vw; right: 0.6vw;
16857
+ background: rgba(239,68,68,0.08); color: #dc2626;
16858
+ border: 1px solid rgba(239,68,68,0.2); border-radius: 0.4vw;
16859
+ padding: 0.4vw 0.6vw; font-size: 0.6vw; line-height: 1.4;
16860
+ text-align: center; pointer-events: none;
17888
16861
  }
17889
- .ps-bp-bra-region-item:hover { background: rgba(0,0,0,0.04); color: var(--ps-text-primary); }
17890
- .ps-bp-bra-region-item.ps-selected { color: var(--ps-accent); background: rgba(33,84,239,0.1); }
17891
- .ps-bp-bra-grid {
17892
- display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.35vw;
16862
+
16863
+ @media (max-width: 700px) {
16864
+ .ps-photo-zone { min-height: 200px; border-radius: 12px; }
16865
+ .ps-photo-zone-title { font-size: 14px; }
16866
+ .ps-photo-zone-hint { font-size: 11px; max-width: 90%; }
16867
+ .ps-photo-zone-hover-overlay { font-size: 13px; }
16868
+ .ps-photo-zone-status { font-size: 12px; }
16869
+ .ps-photo-zone-rejection { max-width: 90%; gap: 8px; padding: 14px; }
16870
+ .ps-photo-zone-rejection-title { font-size: 14px; }
16871
+ .ps-photo-zone-rejection-msg { font-size: 12px; }
16872
+ .ps-photo-zone-rejection-cta { font-size: 13px; padding: 10px 16px; border-radius: 8px; }
16873
+ .ps-photo-zone-error { font-size: 11px; padding: 8px 10px; border-radius: 8px; }
17893
16874
  }
17894
- .ps-bp-bra-btn {
17895
- padding: 0.4vw 0; text-align: center;
17896
- background: transparent; border: 1px solid rgba(0,0,0,0.08);
17897
- border-radius: 0.35vw; color: var(--ps-text-secondary);
17898
- font-size: 0.65vw; font-weight: 500; cursor: pointer;
17899
- font-family: inherit; transition: all 0.2s;
16875
+ `;
16876
+ var jsxRuntime = { exports: {} };
16877
+ var reactJsxRuntime_production_min = {};
16878
+ /**
16879
+ * @license React
16880
+ * react-jsx-runtime.production.min.js
16881
+ *
16882
+ * Copyright (c) Facebook, Inc. and its affiliates.
16883
+ *
16884
+ * This source code is licensed under the MIT license found in the
16885
+ * LICENSE file in the root directory of this source tree.
16886
+ */
16887
+ var f = reactExports, k = Symbol.for("react.element"), l = Symbol.for("react.fragment"), m = Object.prototype.hasOwnProperty, n = f.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner, p = { key: true, ref: true, __self: true, __source: true };
16888
+ function q(c, a, g) {
16889
+ var b, d = {}, e = null, h = null;
16890
+ void 0 !== g && (e = "" + g);
16891
+ void 0 !== a.key && (e = "" + a.key);
16892
+ void 0 !== a.ref && (h = a.ref);
16893
+ for (b in a) m.call(a, b) && !p.hasOwnProperty(b) && (d[b] = a[b]);
16894
+ if (c && c.defaultProps) for (b in a = c.defaultProps, a) void 0 === d[b] && (d[b] = a[b]);
16895
+ return { $$typeof: k, type: c, key: e, ref: h, props: d, _owner: n.current };
16896
+ }
16897
+ reactJsxRuntime_production_min.Fragment = l;
16898
+ reactJsxRuntime_production_min.jsx = q;
16899
+ reactJsxRuntime_production_min.jsxs = q;
16900
+ {
16901
+ jsxRuntime.exports = reactJsxRuntime_production_min;
16902
+ }
16903
+ var jsxRuntimeExports = jsxRuntime.exports;
16904
+ const STORAGE_KEY = "ps_session";
16905
+ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
16906
+ const FALLBACK_PREFIX = "ps_mem_";
16907
+ let memoryFallback = null;
16908
+ function hasLocalStorage() {
16909
+ try {
16910
+ return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
16911
+ } catch {
16912
+ return false;
17900
16913
  }
17901
- .ps-bp-bra-btn:hover { border-color: rgba(0,0,0,0.3); color: var(--ps-text-primary); }
17902
- .ps-bp-bra-btn-selected {
17903
- background: var(--ps-accent) !important; border-color: var(--ps-accent) !important;
17904
- color: #fff !important; font-weight: 700;
16914
+ }
16915
+ function uuid() {
16916
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
16917
+ return crypto.randomUUID();
17905
16918
  }
17906
- /* Bra inline result — shown in header next to region switcher */
17907
- .ps-bp-bra-inline-result {
17908
- margin-left: auto; font-size: max(11px, 0.75vw); color: var(--ps-accent);
17909
- font-weight: 600; white-space: nowrap;
16919
+ const chars = "0123456789abcdef";
16920
+ let out = FALLBACK_PREFIX;
16921
+ for (let i = 0; i < 32; i++) out += chars[Math.floor(Math.random() * 16)];
16922
+ return out;
16923
+ }
16924
+ function readRecord() {
16925
+ if (!hasLocalStorage()) return memoryFallback;
16926
+ try {
16927
+ const raw = window.localStorage.getItem(STORAGE_KEY);
16928
+ if (!raw) return null;
16929
+ const parsed = JSON.parse(raw);
16930
+ if (typeof parsed.id !== "string" || typeof parsed.issuedAt !== "number") return null;
16931
+ return {
16932
+ id: parsed.id,
16933
+ issuedAt: parsed.issuedAt,
16934
+ lastSeenAt: typeof parsed.lastSeenAt === "number" ? parsed.lastSeenAt : parsed.issuedAt
16935
+ };
16936
+ } catch {
16937
+ return null;
17910
16938
  }
17911
- .ps-bp-bra-inline-result strong {
17912
- font-weight: 700;
16939
+ }
16940
+ function writeRecord(record) {
16941
+ memoryFallback = record;
16942
+ if (!hasLocalStorage()) return;
16943
+ try {
16944
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
16945
+ } catch {
17913
16946
  }
17914
-
17915
- /* Profile option rows — WAIR style stacked list */
17916
- .ps-bp-options {
17917
- display: flex; flex-direction: column; gap: 0.5vw;
17918
- margin-top: 0.8vw;
16947
+ }
16948
+ function getOrCreateSessionId() {
16949
+ const now = Date.now();
16950
+ const existing = readRecord();
16951
+ if (existing && now - existing.lastSeenAt < MAX_AGE_MS) {
16952
+ if (now - existing.lastSeenAt > 5 * 60 * 1e3) {
16953
+ writeRecord({ ...existing, lastSeenAt: now });
16954
+ }
16955
+ return existing.id;
17919
16956
  }
17920
- .ps-bp-option {
17921
- display: flex; align-items: center; gap: 0.8vw;
17922
- padding: 0.35vw 1vw 0.35vw 0.35vw;
17923
- background: #fff; border: 1.5px solid rgba(0,0,0,0.06);
17924
- border-radius: 0.6vw; cursor: pointer;
17925
- transition: all 0.25s ease; font-family: inherit;
17926
- box-shadow: 0 1px 4px rgba(0,0,0,0.05);
16957
+ const fresh = { id: uuid(), issuedAt: now, lastSeenAt: now };
16958
+ writeRecord(fresh);
16959
+ return fresh.id;
16960
+ }
16961
+ function getDeviceHint() {
16962
+ if (typeof navigator === "undefined") return null;
16963
+ const ua2 = (navigator.userAgent || "").toLowerCase();
16964
+ if (/ipad|tablet|(android(?!.*mobile))/.test(ua2)) return "tablet";
16965
+ if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/.test(ua2)) return "mobile";
16966
+ return "desktop";
16967
+ }
16968
+ const DEFAULT_API_URL = "http://localhost:4000";
16969
+ class ApiClient {
16970
+ constructor(apiKey, apiUrl) {
16971
+ this.apiKey = apiKey;
16972
+ this.baseUrl = (apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
17927
16973
  }
17928
- .ps-bp-option:hover {
17929
- border-color: rgba(33,84,239,0.5);
17930
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
17931
- transform: translateY(-1px);
16974
+ get headers() {
16975
+ return {
16976
+ "Content-Type": "application/json",
16977
+ Authorization: `Bearer ${this.apiKey}`
16978
+ };
17932
16979
  }
17933
- .ps-bp-option:hover .ps-bp-illust svg { transform: scale(1.05); }
17934
- .ps-bp-option-selected {
17935
- border-color: var(--ps-accent) !important;
17936
- box-shadow: 0 4px 12px rgba(33,84,239,0.18) !important;
16980
+ async submitTryOn(modelImage, garmentImage, fitInfo, category, context) {
16981
+ const body = {
16982
+ garmentImage,
16983
+ // Attribution fields backend logs these into the TryOnEvent collection
16984
+ // so non-technical operators can answer "which products are being tried on
16985
+ // and by how many customers". Safe fallbacks when absent.
16986
+ sessionId: getOrCreateSessionId(),
16987
+ deviceHint: getDeviceHint()
16988
+ };
16989
+ if (context?.modelImageId) body.modelImageId = context.modelImageId;
16990
+ if (modelImage) body.modelImage = modelImage;
16991
+ if (fitInfo && fitInfo.length > 0) body.fitInfo = fitInfo;
16992
+ if (category && category !== "apparel") body.category = category;
16993
+ if (context?.productId) body.productId = context.productId;
16994
+ if (context?.productTitle) body.productTitle = context.productTitle;
16995
+ if (context?.productDescription) body.productDescription = context.productDescription;
16996
+ if (context?.productMaterial) body.productMaterial = context.productMaterial;
16997
+ if (context?.silhouetteContext) body.silhouetteContext = context.silhouetteContext;
16998
+ if (context?.editFromPrevious) body.editFromPrevious = true;
16999
+ console.log("[ps-sdk:api] POST /api/v1/tryon payload", {
17000
+ modelImageBytes: modelImage.length,
17001
+ garmentImageBytes: garmentImage.length,
17002
+ category: category || "apparel",
17003
+ fitInfo: fitInfo?.map((f2) => ({
17004
+ area: f2.area,
17005
+ fit: f2.fit,
17006
+ userValue: f2.userValue,
17007
+ garmentRange: f2.garmentRange
17008
+ })) || null,
17009
+ silhouetteContext: context?.silhouetteContext || null
17010
+ });
17011
+ if (fitInfo && fitInfo.length > 0) {
17012
+ console.log("[ps-sdk:api] Body vs Garment (what Gemini will see):");
17013
+ for (const f2 of fitInfo) {
17014
+ const parts = (f2.garmentRange || "").replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
17015
+ let gap = "";
17016
+ if (parts.length && f2.userValue != null) {
17017
+ const mid = (Math.min(...parts) + Math.max(...parts)) / 2;
17018
+ const diff = Math.round((f2.userValue - mid) * 10) / 10;
17019
+ gap = diff > 0 ? `garment ${diff} smaller` : diff < 0 ? `garment ${Math.abs(diff)} larger` : "matched";
17020
+ }
17021
+ console.log(`[ps-sdk:api] ${f2.area.padEnd(10)} body=${f2.userValue ?? "?"} garment=${f2.garmentRange ?? "?"} → ${gap}`);
17022
+ }
17023
+ }
17024
+ const res = await fetch(`${this.baseUrl}/api/v1/tryon`, {
17025
+ method: "POST",
17026
+ headers: this.headers,
17027
+ body: JSON.stringify(body)
17028
+ });
17029
+ if (!res.ok) {
17030
+ const data = await res.json().catch(() => ({}));
17031
+ if (res.status === 402) {
17032
+ throw new PrimeStyleError(
17033
+ data.message || "Insufficient try-ons",
17034
+ "INSUFFICIENT_BALANCE"
17035
+ );
17036
+ }
17037
+ throw new PrimeStyleError(
17038
+ data.message || "Failed to submit try-on",
17039
+ "API_ERROR"
17040
+ );
17041
+ }
17042
+ return res.json();
17937
17043
  }
17938
- .ps-bp-option-icon {
17939
- width: 6vw; height: 6vw; flex-shrink: 0;
17940
- display: flex; align-items: center; justify-content: center;
17941
- border-radius: 0.4vw; overflow: hidden;
17044
+ async getStatus(jobId) {
17045
+ const res = await fetch(`${this.baseUrl}/api/v1/tryon/status/${jobId}`, {
17046
+ headers: this.headers
17047
+ });
17048
+ if (!res.ok) {
17049
+ const data = await res.json().catch(() => ({}));
17050
+ throw new PrimeStyleError(
17051
+ data.message || "Failed to get status",
17052
+ "API_ERROR"
17053
+ );
17054
+ }
17055
+ return res.json();
17942
17056
  }
17943
- .ps-bp-illust { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
17944
- .ps-bp-illust svg { width: 100%; height: 100%; transition: transform 0.25s ease; }
17945
- .ps-bp-option-label {
17946
- flex: 1; font-size: 0.73vw; font-weight: 500;
17947
- color: #555; text-align: center;
17057
+ getStreamUrl() {
17058
+ return `${this.baseUrl}/api/v1/tryon/stream?key=${encodeURIComponent(this.apiKey)}`;
17948
17059
  }
17949
- .ps-bp-option-selected .ps-bp-option-label { color: var(--ps-accent); font-weight: 600; }
17950
- .ps-bp-option::after {
17951
- content: "›"; font-size: 1.1vw; color: var(--ps-text-secondary); flex-shrink: 0;
17952
- transition: all 0.25s ease;
17060
+ }
17061
+ class PrimeStyleError extends Error {
17062
+ constructor(message, code) {
17063
+ super(message);
17064
+ this.name = "PrimeStyleError";
17065
+ this.code = code;
17953
17066
  }
17954
- .ps-bp-option:hover::after { color: var(--ps-accent); transform: translateX(2px); }
17955
- .ps-bp-option-selected::after { color: var(--ps-accent); }
17956
-
17957
- /* Single question step — 3 cards centered */
17958
- .ps-bp-single-q { display: flex; flex-direction: column; flex: 1; justify-content: center; align-items: center; }
17959
- .ps-bp-single-q .ps-bp-title { margin-bottom: 1vw; width: 100%; text-align: center; font-size: 1.05vw; font-weight: 700; letter-spacing: 0.01em; }
17960
- .ps-bp-shape-row-full { width: 100%; margin: 1.5vw auto 0; }
17961
- .ps-bp-shape-row-full .ps-bp-img-card { max-height: 18vw; padding: 0.8vw 0.6vw 0; }
17962
- .ps-bp-shape-row-full .ps-bp-img-card-thumb { height: 13vw; }
17963
-
17964
- /* Body shapes screen — fit all questions without scrolling */
17965
- .ps-bp-shapes-fit { display: flex; flex-direction: column; flex: 1; overflow: hidden; gap: 0.4vw; justify-content: flex-start !important; }
17966
-
17967
- /* Body shapes combined screen */
17968
- .ps-bp-shape-section {
17969
- margin-bottom: 0.4vw; flex: 1; display: flex; flex-direction: column; min-height: 0;
17067
+ }
17068
+ class SseClient {
17069
+ constructor(streamUrl) {
17070
+ this.eventSource = null;
17071
+ this.listeners = /* @__PURE__ */ new Map();
17072
+ this.reconnectTimer = null;
17073
+ this.reconnectAttempts = 0;
17074
+ this.maxReconnectAttempts = 5;
17075
+ this.streamUrl = streamUrl;
17970
17076
  }
17971
- .ps-bp-shape-label {
17972
- font-size: 0.55vw; font-weight: 500; color: rgba(255,255,255,0.7);
17973
- margin-bottom: 0.3vw; display: flex; align-items: center; gap: 0.6vw;
17974
- text-transform: uppercase; letter-spacing: 0.05em;
17077
+ connect() {
17078
+ if (this.eventSource) return;
17079
+ this.eventSource = new EventSource(this.streamUrl);
17080
+ this.eventSource.addEventListener("vto-update", (event) => {
17081
+ try {
17082
+ const data = JSON.parse(event.data);
17083
+ this.emit(data.galleryId, data);
17084
+ } catch {
17085
+ }
17086
+ });
17087
+ this.eventSource.onopen = () => {
17088
+ this.reconnectAttempts = 0;
17089
+ };
17090
+ this.eventSource.onerror = () => {
17091
+ this.eventSource?.close();
17092
+ this.eventSource = null;
17093
+ this.scheduleReconnect();
17094
+ };
17975
17095
  }
17976
- .ps-bp-shape-label::after {
17977
- content: ""; flex: 1; height: 1px;
17978
- background: linear-gradient(to right, rgba(0,0,0,0.08), transparent);
17096
+ scheduleReconnect() {
17097
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
17098
+ if (this.listeners.size === 0) return;
17099
+ const delay = Math.min(1e3 * 2 ** this.reconnectAttempts, 3e4);
17100
+ this.reconnectAttempts++;
17101
+ this.reconnectTimer = setTimeout(() => {
17102
+ this.connect();
17103
+ }, delay);
17979
17104
  }
17980
- .ps-bp-shape-row {
17981
- display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5vw;
17982
- flex: 1; min-height: 0;
17105
+ onJob(jobId, callback) {
17106
+ if (!this.listeners.has(jobId)) {
17107
+ this.listeners.set(jobId, /* @__PURE__ */ new Set());
17108
+ }
17109
+ this.listeners.get(jobId).add(callback);
17110
+ if (!this.eventSource) {
17111
+ this.connect();
17112
+ }
17113
+ return () => {
17114
+ const jobListeners = this.listeners.get(jobId);
17115
+ if (jobListeners) {
17116
+ jobListeners.delete(callback);
17117
+ if (jobListeners.size === 0) {
17118
+ this.listeners.delete(jobId);
17119
+ }
17120
+ }
17121
+ if (this.listeners.size === 0) {
17122
+ this.disconnect();
17123
+ }
17124
+ };
17983
17125
  }
17984
- .ps-bp-img-card {
17985
- display: flex; flex-direction: column; align-items: center;
17986
- background: transparent; border: 2px solid rgba(0,0,0,0.08);
17987
- border-radius: 0.5vw; overflow: hidden; cursor: pointer;
17988
- transition: all 0.2s ease; padding: 0; font-family: inherit;
17989
- position: relative;
17126
+ emit(jobId, update) {
17127
+ const callbacks = this.listeners.get(jobId);
17128
+ if (callbacks) {
17129
+ callbacks.forEach((cb2) => cb2(update));
17130
+ }
17990
17131
  }
17991
- .ps-bp-img-card:hover {
17992
- border-color: rgba(33,84,239,0.4);
17993
- transform: translateY(-0.1vw);
17132
+ disconnect() {
17133
+ if (this.reconnectTimer) {
17134
+ clearTimeout(this.reconnectTimer);
17135
+ this.reconnectTimer = null;
17136
+ }
17137
+ if (this.eventSource) {
17138
+ this.eventSource.close();
17139
+ this.eventSource = null;
17140
+ }
17141
+ this.listeners.clear();
17142
+ this.reconnectAttempts = 0;
17994
17143
  }
17995
- .ps-bp-img-card-selected {
17996
- border-color: var(--ps-accent) !important;
17997
- border-width: 2.5px !important;
17144
+ }
17145
+ const DEFAULT_MAX_DIMENSION = 512;
17146
+ const DEFAULT_JPEG_QUALITY = 0.65;
17147
+ function compressImage(file, opts = {}) {
17148
+ const maxDim = opts.maxDimension ?? DEFAULT_MAX_DIMENSION;
17149
+ const quality = opts.quality ?? DEFAULT_JPEG_QUALITY;
17150
+ return new Promise((resolve, reject) => {
17151
+ const reader = new FileReader();
17152
+ reader.onload = () => {
17153
+ const img = new Image();
17154
+ img.onload = () => {
17155
+ try {
17156
+ const canvas = document.createElement("canvas");
17157
+ let { width, height } = img;
17158
+ if (width > maxDim || height > maxDim) {
17159
+ if (width > height) {
17160
+ height = Math.round(height * maxDim / width);
17161
+ width = maxDim;
17162
+ } else {
17163
+ width = Math.round(width * maxDim / height);
17164
+ height = maxDim;
17165
+ }
17166
+ }
17167
+ canvas.width = width;
17168
+ canvas.height = height;
17169
+ const ctx = canvas.getContext("2d");
17170
+ if (!ctx) {
17171
+ reject(new Error("Canvas context not available"));
17172
+ return;
17173
+ }
17174
+ ctx.drawImage(img, 0, 0, width, height);
17175
+ const dataUrl = canvas.toDataURL("image/jpeg", quality);
17176
+ resolve(dataUrl);
17177
+ } catch (err) {
17178
+ reject(err);
17179
+ }
17180
+ };
17181
+ img.onerror = () => reject(new Error("Failed to load image"));
17182
+ img.src = reader.result;
17183
+ };
17184
+ reader.onerror = () => reject(new Error("Failed to read file"));
17185
+ reader.readAsDataURL(file);
17186
+ });
17187
+ }
17188
+ function isValidImageFile(file) {
17189
+ const accepted = ["image/jpeg", "image/png", "image/webp", "image/avif"];
17190
+ return accepted.includes(file.type);
17191
+ }
17192
+ async function checkAgeBeforeUpload(imageOrBase64, apiUrl, apiKey) {
17193
+ try {
17194
+ const base64 = typeof imageOrBase64 === "string" ? imageOrBase64 : await compressImage(imageOrBase64, { maxDimension: 384, quality: 0.6 });
17195
+ const resp = await fetch(`${apiUrl}/api/v1/sizing/age-check`, {
17196
+ method: "POST",
17197
+ headers: {
17198
+ "Content-Type": "application/json",
17199
+ Authorization: `Bearer ${apiKey}`
17200
+ },
17201
+ body: JSON.stringify({ bodyImage: base64 })
17202
+ });
17203
+ if (!resp.ok) {
17204
+ console.warn(`[PS-SDK:AgeCheck] HTTP ${resp.status} — failing open`);
17205
+ return { isAdult: true, confidence: "low" };
17206
+ }
17207
+ const data = await resp.json();
17208
+ return {
17209
+ isAdult: data?.isAdult !== false,
17210
+ // anything other than explicit false → allow
17211
+ confidence: data?.confidence === "high" ? "high" : "low",
17212
+ reasoning: typeof data?.reasoning === "string" ? data.reasoning : void 0
17213
+ };
17214
+ } catch (err) {
17215
+ console.warn(`[PS-SDK:AgeCheck] error — failing open:`, err);
17216
+ return { isAdult: true, confidence: "low" };
17998
17217
  }
17999
- .ps-bp-img-card-selected .ps-bp-img-card-label {
18000
- font-weight: 700; color: var(--ps-accent);
17218
+ }
17219
+ const DB_NAME = "primestyle-photos";
17220
+ const STORE = "photos";
17221
+ const VERSION = 1;
17222
+ let _dbPromise = null;
17223
+ function openDb() {
17224
+ if (_dbPromise) return _dbPromise;
17225
+ _dbPromise = new Promise((resolve) => {
17226
+ try {
17227
+ if (typeof indexedDB === "undefined") return resolve(null);
17228
+ const req = indexedDB.open(DB_NAME, VERSION);
17229
+ req.onupgradeneeded = () => {
17230
+ const db2 = req.result;
17231
+ if (!db2.objectStoreNames.contains(STORE)) {
17232
+ db2.createObjectStore(STORE);
17233
+ }
17234
+ };
17235
+ req.onsuccess = () => resolve(req.result);
17236
+ req.onerror = () => resolve(null);
17237
+ req.onblocked = () => resolve(null);
17238
+ } catch {
17239
+ resolve(null);
17240
+ }
17241
+ });
17242
+ return _dbPromise;
17243
+ }
17244
+ async function savePhoto(id2, blob) {
17245
+ const db2 = await openDb();
17246
+ if (!db2) return false;
17247
+ return new Promise((resolve) => {
17248
+ try {
17249
+ const tx = db2.transaction(STORE, "readwrite");
17250
+ tx.objectStore(STORE).put(blob, id2);
17251
+ tx.oncomplete = () => resolve(true);
17252
+ tx.onerror = () => resolve(false);
17253
+ tx.onabort = () => resolve(false);
17254
+ } catch {
17255
+ resolve(false);
17256
+ }
17257
+ });
17258
+ }
17259
+ async function getPhoto(id2) {
17260
+ const db2 = await openDb();
17261
+ if (!db2) return null;
17262
+ return new Promise((resolve) => {
17263
+ try {
17264
+ const tx = db2.transaction(STORE, "readonly");
17265
+ const req = tx.objectStore(STORE).get(id2);
17266
+ req.onsuccess = () => resolve(req.result instanceof Blob ? req.result : null);
17267
+ req.onerror = () => resolve(null);
17268
+ } catch {
17269
+ resolve(null);
17270
+ }
17271
+ });
17272
+ }
17273
+ async function pruneToIds(keepIds) {
17274
+ const db2 = await openDb();
17275
+ if (!db2) return;
17276
+ return new Promise((resolve) => {
17277
+ try {
17278
+ const tx = db2.transaction(STORE, "readwrite");
17279
+ const store = tx.objectStore(STORE);
17280
+ const cursorReq = store.openKeyCursor();
17281
+ cursorReq.onsuccess = () => {
17282
+ const cursor = cursorReq.result;
17283
+ if (!cursor) return;
17284
+ const key = String(cursor.key);
17285
+ if (!keepIds.has(key)) cursor.delete();
17286
+ cursor.continue();
17287
+ };
17288
+ tx.oncomplete = () => resolve();
17289
+ tx.onerror = () => resolve();
17290
+ } catch {
17291
+ resolve();
17292
+ }
17293
+ });
17294
+ }
17295
+ const LEFT_SHOULDER = 11;
17296
+ const RIGHT_SHOULDER = 12;
17297
+ const LEFT_ELBOW = 13;
17298
+ const RIGHT_ELBOW = 14;
17299
+ const LEFT_WRIST = 15;
17300
+ const RIGHT_WRIST = 16;
17301
+ const LEFT_HIP = 23;
17302
+ const RIGHT_HIP = 24;
17303
+ const LEFT_KNEE = 25;
17304
+ const RIGHT_KNEE = 26;
17305
+ const LEFT_ANKLE = 27;
17306
+ const RIGHT_ANKLE = 28;
17307
+ const NOSE = 0;
17308
+ let poseLandmarker = null;
17309
+ let loadingPromise$1 = null;
17310
+ async function loadMediaPipe() {
17311
+ if (poseLandmarker) return;
17312
+ if (loadingPromise$1) return loadingPromise$1;
17313
+ loadingPromise$1 = (async () => {
17314
+ const vision = await import(
17315
+ /* webpackIgnore: true */
17316
+ // @ts-ignore dynamic CDN import
17317
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
17318
+ );
17319
+ const { FilesetResolver, PoseLandmarker } = vision;
17320
+ const filesetResolver = await FilesetResolver.forVisionTasks(
17321
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
17322
+ );
17323
+ poseLandmarker = await PoseLandmarker.createFromOptions(filesetResolver, {
17324
+ baseOptions: {
17325
+ modelAssetPath: "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task",
17326
+ delegate: "GPU"
17327
+ },
17328
+ runningMode: "IMAGE",
17329
+ numPoses: 1
17330
+ });
17331
+ })();
17332
+ return loadingPromise$1;
17333
+ }
17334
+ async function detectMeasurementLines(imageSrc) {
17335
+ try {
17336
+ await loadMediaPipe();
17337
+ const img = await loadImage$1(imageSrc);
17338
+ const result = poseLandmarker.detect(img);
17339
+ if (!result?.landmarks?.length || result.landmarks[0].length < 25) {
17340
+ return null;
17341
+ }
17342
+ const landmarks = result.landmarks[0];
17343
+ const lShoulder = landmarks[LEFT_SHOULDER];
17344
+ const rShoulder = landmarks[RIGHT_SHOULDER];
17345
+ const lHip = landmarks[LEFT_HIP];
17346
+ const rHip = landmarks[RIGHT_HIP];
17347
+ const shoulderY = (lShoulder.y + rShoulder.y) / 2;
17348
+ const hipY = (lHip.y + rHip.y) / 2;
17349
+ const torsoLen = hipY - shoulderY;
17350
+ const chestY = shoulderY + torsoLen * 0.28;
17351
+ const shoulderWidth = Math.abs(rShoulder.x - lShoulder.x);
17352
+ const chestX1 = Math.min(lShoulder.x, rShoulder.x) - shoulderWidth * 0.05;
17353
+ const chestX2 = Math.max(lShoulder.x, rShoulder.x) + shoulderWidth * 0.05;
17354
+ const hipWidth = Math.abs(rHip.x - lHip.x);
17355
+ const hipX1 = Math.min(lHip.x, rHip.x) - hipWidth * 0.15;
17356
+ const hipX2 = Math.max(lHip.x, rHip.x) + hipWidth * 0.15;
17357
+ const waistY = chestY + (hipY - chestY) * 0.55;
17358
+ const waistWidth = shoulderWidth * 0.85;
17359
+ const waistCenterX = (lShoulder.x + rShoulder.x + lHip.x + rHip.x) / 4;
17360
+ const waistX1 = waistCenterX - waistWidth / 2;
17361
+ const waistX2 = waistCenterX + waistWidth / 2;
17362
+ return {
17363
+ chest: { y: chestY, x1: chestX1, x2: chestX2 },
17364
+ waist: { y: waistY, x1: waistX1, x2: waistX2 },
17365
+ hips: { y: hipY, x1: hipX1, x2: hipX2 }
17366
+ };
17367
+ } catch (err) {
17368
+ console.error("[PS-SDK] Pose detection failed:", err);
17369
+ return null;
18001
17370
  }
18002
- .ps-bp-img-card-check {
18003
- display: none; position: absolute; top: 0.3vw; right: 0.3vw;
18004
- width: 1.1vw; height: 1.1vw; border-radius: 50%;
18005
- background: var(--ps-accent); align-items: center; justify-content: center;
18006
- z-index: 1;
17371
+ }
17372
+ function loadImage$1(src) {
17373
+ return new Promise((resolve, reject) => {
17374
+ const img = new Image();
17375
+ img.crossOrigin = "anonymous";
17376
+ img.onload = () => resolve(img);
17377
+ img.onerror = () => {
17378
+ const img2 = new Image();
17379
+ img2.onload = () => resolve(img2);
17380
+ img2.onerror = () => reject(new Error("Failed to load image"));
17381
+ img2.src = src;
17382
+ };
17383
+ img.src = src;
17384
+ });
17385
+ }
17386
+ async function downsampleIfNeeded(img, maxLongEdge = 1024) {
17387
+ const w2 = img.naturalWidth || img.width;
17388
+ const h = img.naturalHeight || img.height;
17389
+ const longEdge = Math.max(w2, h);
17390
+ if (longEdge <= maxLongEdge) return img;
17391
+ const scale = maxLongEdge / longEdge;
17392
+ const newW = Math.round(w2 * scale);
17393
+ const newH = Math.round(h * scale);
17394
+ const canvas = document.createElement("canvas");
17395
+ canvas.width = newW;
17396
+ canvas.height = newH;
17397
+ const ctx = canvas.getContext("2d");
17398
+ if (!ctx) return img;
17399
+ ctx.imageSmoothingQuality = "high";
17400
+ ctx.drawImage(img, 0, 0, newW, newH);
17401
+ console.log(`[PS-SDK:Downsample] ${w2}×${h} → ${newW}×${newH} (max long edge ${maxLongEdge}px)`);
17402
+ return canvas;
17403
+ }
17404
+ async function detectBodyLandmarks(imageSrc) {
17405
+ try {
17406
+ await loadMediaPipe();
17407
+ let img;
17408
+ if (typeof imageSrc === "string") {
17409
+ img = await loadImage$1(imageSrc);
17410
+ } else {
17411
+ img = imageSrc;
17412
+ }
17413
+ const inputForDetect = await downsampleIfNeeded(img, 1024);
17414
+ const workingW = inputForDetect.width || img.naturalWidth || img.width;
17415
+ const workingH = inputForDetect.height || img.naturalHeight || img.height;
17416
+ const result = poseLandmarker.detect(inputForDetect);
17417
+ if (!result?.landmarks?.length || result.landmarks[0].length < 29) {
17418
+ console.warn("[PS-SDK:MediaPipe] detection returned no usable landmarks", {
17419
+ gotLandmarks: !!result?.landmarks?.length,
17420
+ jointCount: result?.landmarks?.[0]?.length ?? 0
17421
+ });
17422
+ return null;
17423
+ }
17424
+ const lm = result.landmarks[0];
17425
+ const wlm = result.worldLandmarks?.[0] ?? null;
17426
+ const pickPoint = (idx, useWorld = false) => {
17427
+ const src = useWorld && wlm ? wlm[idx] : lm[idx];
17428
+ const out2 = { x: src.x, y: src.y };
17429
+ if (typeof src.z === "number") out2.z = src.z;
17430
+ if (typeof src.visibility === "number") out2.visibility = src.visibility;
17431
+ return out2;
17432
+ };
17433
+ const out = {
17434
+ leftShoulder: pickPoint(LEFT_SHOULDER),
17435
+ rightShoulder: pickPoint(RIGHT_SHOULDER),
17436
+ leftHip: pickPoint(LEFT_HIP),
17437
+ rightHip: pickPoint(RIGHT_HIP),
17438
+ leftElbow: pickPoint(LEFT_ELBOW),
17439
+ rightElbow: pickPoint(RIGHT_ELBOW),
17440
+ leftWrist: pickPoint(LEFT_WRIST),
17441
+ rightWrist: pickPoint(RIGHT_WRIST),
17442
+ leftKnee: pickPoint(LEFT_KNEE),
17443
+ rightKnee: pickPoint(RIGHT_KNEE),
17444
+ leftAnkle: pickPoint(LEFT_ANKLE),
17445
+ rightAnkle: pickPoint(RIGHT_ANKLE),
17446
+ nose: pickPoint(NOSE),
17447
+ imageWidth: workingW,
17448
+ imageHeight: workingH
17449
+ };
17450
+ const isJointPoint = (v2) => !!v2 && typeof v2 === "object" && typeof v2.x === "number" && typeof v2.y === "number";
17451
+ const jointCount = Object.values(out).filter(isJointPoint).length;
17452
+ const hasZ = lm.some((p2) => typeof p2.z === "number");
17453
+ const hasWorld = !!wlm;
17454
+ console.groupCollapsed(`[PS-SDK:MediaPipe] ✓ ${jointCount} joints detected @ ${out.imageWidth}×${out.imageHeight}px (z=${hasZ ? "yes" : "no"}, worldLandmarks=${hasWorld ? "yes" : "no"})`);
17455
+ for (const [name, v2] of Object.entries(out)) {
17456
+ if (!isJointPoint(v2)) continue;
17457
+ const p2 = v2;
17458
+ const z2 = typeof p2.z === "number" ? `, z=${p2.z.toFixed(3)}` : "";
17459
+ const vis = typeof p2.visibility === "number" ? `, vis=${p2.visibility.toFixed(2)}` : "";
17460
+ console.log(` ${name.padEnd(15)} (${p2.x.toFixed(3)}, ${p2.y.toFixed(3)}${z2}${vis})`);
17461
+ }
17462
+ console.log(` imageWidth ${out.imageWidth}`);
17463
+ console.log(` imageHeight ${out.imageHeight}`);
17464
+ console.groupEnd();
17465
+ return out;
17466
+ } catch (err) {
17467
+ console.error("[PS-SDK:MediaPipe] Body landmark detection failed:", err);
17468
+ return null;
18007
17469
  }
18008
- .ps-bp-img-card-check svg { width: 0.6vw; height: 0.6vw; }
18009
- .ps-bp-img-card-selected .ps-bp-img-card-check { display: flex; }
18010
- .ps-bp-img-card .ps-bp-img-card-thumb { opacity: 1; }
18011
- .ps-bp-img-card-thumb {
18012
- width: 100%; flex: 1; min-height: 0; overflow: hidden;
18013
- display: flex; align-items: center; justify-content: center;
18014
- background: transparent;
17470
+ }
17471
+ const IDX = {
17472
+ noseTip: 1,
17473
+ noseBridge: 168,
17474
+ leftInnerEye: 133,
17475
+ rightInnerEye: 362,
17476
+ leftOuterEye: 33,
17477
+ rightOuterEye: 263,
17478
+ // Iris landmarks (478-point model with iris refinement)
17479
+ leftIrisCenter: 468,
17480
+ leftIrisRing: [469, 470, 471, 472],
17481
+ rightIrisCenter: 473,
17482
+ rightIrisRing: [474, 475, 476, 477],
17483
+ // Tragus (ear attach point) — best approximations in the face mesh
17484
+ leftTragus: 234,
17485
+ rightTragus: 454,
17486
+ forehead: 10,
17487
+ chin: 152,
17488
+ leftMouth: 61,
17489
+ rightMouth: 291
17490
+ };
17491
+ const IRIS_DIAMETER_MM = 11.7;
17492
+ let faceLandmarker = null;
17493
+ let loadingPromise = null;
17494
+ async function loadFaceLandmarker() {
17495
+ if (faceLandmarker) {
17496
+ console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — already loaded, reusing");
17497
+ return;
18015
17498
  }
18016
- .ps-bp-img-card-img {
18017
- width: 100%; height: 100%; object-fit: contain;
17499
+ if (loadingPromise) {
17500
+ console.log("[PS-SDK:FaceDetect] loadFaceLandmarker awaiting in-flight load");
17501
+ return loadingPromise;
18018
17502
  }
18019
- .ps-bp-img-card .ps-bp-illust { width: 80%; height: 80%; }
18020
- .ps-bp-img-card .ps-bp-illust svg { width: 100%; height: 100%; }
18021
- .ps-bp-img-card-label {
18022
- padding: 0.3vw 0.2vw; font-size: 0.6vw; font-weight: 600;
18023
- color: var(--ps-text-secondary); text-align: center; width: 100%;
18024
- transition: opacity 0.2s;
17503
+ console.log("[PS-SDK:FaceDetect] loadFaceLandmarker starting fresh init");
17504
+ loadingPromise = (async () => {
17505
+ try {
17506
+ console.log("[PS-SDK:FaceDetect] importing vision_bundle.mjs from CDN…");
17507
+ const t0 = performance.now();
17508
+ const vision = await import(
17509
+ /* webpackIgnore: true */
17510
+ // @ts-ignore dynamic CDN import
17511
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
17512
+ );
17513
+ console.log(`[PS-SDK:FaceDetect] vision bundle imported in ${Math.round(performance.now() - t0)}ms`);
17514
+ const { FilesetResolver, FaceLandmarker } = vision;
17515
+ if (!FilesetResolver || !FaceLandmarker) {
17516
+ console.error("[PS-SDK:FaceDetect] vision bundle missing expected exports", { keys: Object.keys(vision) });
17517
+ throw new Error("MediaPipe vision bundle returned no FilesetResolver/FaceLandmarker");
17518
+ }
17519
+ console.log("[PS-SDK:FaceDetect] resolving WASM filesets…");
17520
+ const t1 = performance.now();
17521
+ const filesetResolver = await FilesetResolver.forVisionTasks(
17522
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
17523
+ );
17524
+ console.log(`[PS-SDK:FaceDetect] WASM resolver ready in ${Math.round(performance.now() - t1)}ms`);
17525
+ console.log("[PS-SDK:FaceDetect] creating FaceLandmarker (GPU delegate, model=latest)…");
17526
+ const t2 = performance.now();
17527
+ faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
17528
+ baseOptions: {
17529
+ modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task",
17530
+ delegate: "GPU"
17531
+ },
17532
+ runningMode: "IMAGE",
17533
+ numFaces: 1,
17534
+ outputFaceBlendshapes: false,
17535
+ outputFacialTransformationMatrixes: false
17536
+ });
17537
+ console.log(`[PS-SDK:FaceDetect] FaceLandmarker ready in ${Math.round(performance.now() - t2)}ms`);
17538
+ } catch (err) {
17539
+ console.error("[PS-SDK:FaceDetect] loadFaceLandmarker FAILED:", err);
17540
+ loadingPromise = null;
17541
+ throw err;
17542
+ }
17543
+ })();
17544
+ return loadingPromise;
17545
+ }
17546
+ function loadImage(src) {
17547
+ return new Promise((resolve, reject) => {
17548
+ const img = new Image();
17549
+ img.crossOrigin = "anonymous";
17550
+ img.onload = () => {
17551
+ console.log("[PS-SDK:FaceDetect] loadImage — primary (crossOrigin) succeeded", { w: img.naturalWidth, h: img.naturalHeight });
17552
+ resolve(img);
17553
+ };
17554
+ img.onerror = () => {
17555
+ console.warn("[PS-SDK:FaceDetect] loadImage — primary (crossOrigin) failed, retrying without crossOrigin");
17556
+ const img2 = new Image();
17557
+ img2.onload = () => {
17558
+ console.log("[PS-SDK:FaceDetect] loadImage — fallback succeeded", { w: img2.naturalWidth, h: img2.naturalHeight });
17559
+ resolve(img2);
17560
+ };
17561
+ img2.onerror = () => {
17562
+ console.error("[PS-SDK:FaceDetect] loadImage — both attempts failed for src:", src.slice(0, 120));
17563
+ reject(new Error("Failed to load image"));
17564
+ };
17565
+ img2.src = src;
17566
+ };
17567
+ img.src = src;
17568
+ });
17569
+ }
17570
+ function irisDiameterPx(ring, imageWidth, imageHeight) {
17571
+ if (ring.length < 4) return 0;
17572
+ const toPx = (p4) => ({ x: p4.x * imageWidth, y: p4.y * imageHeight });
17573
+ const [p0, p1, p2, p3] = ring.map(toPx);
17574
+ const d1 = Math.hypot(p0.x - p2.x, p0.y - p2.y);
17575
+ const d2 = Math.hypot(p1.x - p3.x, p1.y - p3.y);
17576
+ return (d1 + d2) / 2;
17577
+ }
17578
+ function extractLandmarks(raw) {
17579
+ if (raw.length < 478) {
17580
+ console.warn(`[PS-SDK:FaceDetect] extractLandmarks REJECTED — need 478 landmarks (iris refinement), got ${raw.length}. The model is missing iris data — probably the CDN "latest" tag moved to a non-iris variant.`);
17581
+ return null;
18025
17582
  }
18026
- /* Hover hint text below cards */
18027
- .ps-bp-hover-hint {
18028
- text-align: center; font-size: 0.8vw; color: rgba(0,0,0,0.45);
18029
- margin: 0.15vw 0 0; min-height: 1em; transition: opacity 0.2s;
18030
- font-weight: 400; letter-spacing: 0.01em;
17583
+ const at = (i) => ({ x: raw[i].x, y: raw[i].y, z: raw[i].z });
17584
+ return {
17585
+ noseTip: at(IDX.noseTip),
17586
+ noseBridge: at(IDX.noseBridge),
17587
+ leftInnerEye: at(IDX.leftInnerEye),
17588
+ rightInnerEye: at(IDX.rightInnerEye),
17589
+ leftOuterEye: at(IDX.leftOuterEye),
17590
+ rightOuterEye: at(IDX.rightOuterEye),
17591
+ leftIrisCenter: at(IDX.leftIrisCenter),
17592
+ rightIrisCenter: at(IDX.rightIrisCenter),
17593
+ leftIrisRing: IDX.leftIrisRing.map(at),
17594
+ rightIrisRing: IDX.rightIrisRing.map(at),
17595
+ leftTragus: at(IDX.leftTragus),
17596
+ rightTragus: at(IDX.rightTragus),
17597
+ forehead: at(IDX.forehead),
17598
+ chin: at(IDX.chin),
17599
+ leftMouth: at(IDX.leftMouth),
17600
+ rightMouth: at(IDX.rightMouth)
17601
+ };
17602
+ }
17603
+ function computeMeasurements(lm, imageWidth, imageHeight) {
17604
+ const leftPx = irisDiameterPx(lm.leftIrisRing, imageWidth, imageHeight);
17605
+ const rightPx = irisDiameterPx(lm.rightIrisRing, imageWidth, imageHeight);
17606
+ const irisPx = (leftPx + rightPx) / 2;
17607
+ let irisConfidence = 1;
17608
+ if (irisPx < 8) irisConfidence = 0.3;
17609
+ else if (Math.abs(leftPx - rightPx) / irisPx > 0.3) irisConfidence = 0.5;
17610
+ else if (Math.abs(leftPx - rightPx) / irisPx > 0.15) irisConfidence = 0.8;
17611
+ const pxToMm = irisPx > 0 ? IRIS_DIAMETER_MM / irisPx : 0;
17612
+ const mmBetween = (a2, b2) => {
17613
+ const pxDist = Math.hypot(
17614
+ (a2.x - b2.x) * imageWidth,
17615
+ (a2.y - b2.y) * imageHeight
17616
+ );
17617
+ return pxDist * pxToMm;
17618
+ };
17619
+ const pd2 = mmBetween(lm.leftIrisCenter, lm.rightIrisCenter);
17620
+ const innerEyeGap = mmBetween(lm.leftInnerEye, lm.rightInnerEye);
17621
+ const bridgeWidth = innerEyeGap / 1.6;
17622
+ const faceWidth = mmBetween(lm.leftTragus, lm.rightTragus);
17623
+ const templeLengthLeft = mmBetween(lm.leftTragus, lm.leftOuterEye);
17624
+ const templeLengthRight = mmBetween(lm.rightTragus, lm.rightOuterEye);
17625
+ const templeLength = (templeLengthLeft + templeLengthRight) / 2;
17626
+ const headWidth = faceWidth * 1.07;
17627
+ const zDepthNorm = Math.abs((lm.forehead.z ?? 0) - (lm.chin.z ?? 0));
17628
+ const rawHeadDepthMm = zDepthNorm * imageWidth * pxToMm;
17629
+ const headDepth = Math.max(170, Math.min(210, rawHeadDepthMm || 190));
17630
+ const a = headWidth / 2;
17631
+ const b = headDepth / 2;
17632
+ const headCircumference = Math.PI * Math.sqrt(2 * (a * a + b * b));
17633
+ return {
17634
+ measurements: {
17635
+ irisDiameter: IRIS_DIAMETER_MM,
17636
+ pd: round1(pd2),
17637
+ bridgeWidth: round1(bridgeWidth),
17638
+ faceWidth: round1(faceWidth),
17639
+ templeLengthLeft: round1(templeLengthLeft),
17640
+ templeLengthRight: round1(templeLengthRight),
17641
+ templeLength: round1(templeLength),
17642
+ headWidth: round1(headWidth),
17643
+ headDepth: round1(headDepth),
17644
+ headCircumference: round1(headCircumference)
17645
+ },
17646
+ irisConfidence
17647
+ };
17648
+ }
17649
+ function round1(n2) {
17650
+ return Math.round(n2 * 10) / 10;
17651
+ }
17652
+ async function detectFaceMeasurements(imageSrc) {
17653
+ console.log("[PS-SDK:FaceDetect] detectFaceMeasurements called", {
17654
+ srcType: typeof imageSrc === "string" ? imageSrc.startsWith("blob:") ? "blob-url" : imageSrc.startsWith("data:") ? "data-url" : "other" : "HTMLImageElement",
17655
+ srcLength: typeof imageSrc === "string" ? imageSrc.length : "N/A"
17656
+ });
17657
+ const tStart = performance.now();
17658
+ try {
17659
+ console.log("[PS-SDK:FaceDetect] step 1/4 — loading FaceLandmarker");
17660
+ await loadFaceLandmarker();
17661
+ console.log("[PS-SDK:FaceDetect] step 2/4 — loading image");
17662
+ const img = typeof imageSrc === "string" ? await loadImage(imageSrc) : imageSrc;
17663
+ const imageWidth = img.naturalWidth || img.width;
17664
+ const imageHeight = img.naturalHeight || img.height;
17665
+ console.log("[PS-SDK:FaceDetect] step 3/4 — running faceLandmarker.detect()", { imageWidth, imageHeight });
17666
+ const tDetect = performance.now();
17667
+ const result = faceLandmarker.detect(img);
17668
+ const detectMs = Math.round(performance.now() - tDetect);
17669
+ const faceCount = result?.faceLandmarks?.length ?? 0;
17670
+ console.log(`[PS-SDK:FaceDetect] detect() returned in ${detectMs}ms — faces: ${faceCount}`);
17671
+ if (!faceCount) {
17672
+ console.warn("[PS-SDK:FaceDetect] NO FACES detected — returning null");
17673
+ return null;
17674
+ }
17675
+ const raw = result.faceLandmarks[0];
17676
+ console.log(`[PS-SDK:FaceDetect] first face has ${raw.length} landmarks`);
17677
+ const landmarks = extractLandmarks(raw);
17678
+ if (!landmarks) {
17679
+ return null;
17680
+ }
17681
+ console.log("[PS-SDK:FaceDetect] step 4/4 — computing measurements");
17682
+ const { measurements, irisConfidence } = computeMeasurements(landmarks, imageWidth, imageHeight);
17683
+ console.log(`[PS-SDK:FaceDetect] ✓ DONE in ${Math.round(performance.now() - tStart)}ms`, { irisConfidence, measurements });
17684
+ return { landmarks, measurementsMm: measurements, irisConfidence, imageWidth, imageHeight };
17685
+ } catch (err) {
17686
+ console.error("[PS-SDK:FaceDetect] detectFaceMeasurements FAILED:", err);
17687
+ return null;
18031
17688
  }
18032
-
18033
- /* Photo upload */
18034
- .ps-bp-photo-upload {
18035
- display: flex; align-items: center; gap: 0.6vw;
18036
- width: 100%; padding: 0.7vw 0.8vw;
18037
- background: transparent; border: 1.5px dashed var(--ps-border-color);
18038
- border-radius: 0.5vw; cursor: pointer;
18039
- transition: all 0.2s; color: var(--ps-text-muted);
18040
- font-family: inherit; text-align: left;
17689
+ }
17690
+ function parseRange(s) {
17691
+ const ns = s.replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
17692
+ return ns.length ? { min: Math.min(...ns), max: Math.max(...ns) } : { min: 0, max: 0 };
17693
+ }
17694
+ function parseNum(s) {
17695
+ const n2 = parseFloat(s.replace(/[^\d.]/g, ""));
17696
+ return isNaN(n2) ? 0 : n2;
17697
+ }
17698
+ function computeFit(userValue, chartRange, unit) {
17699
+ const { min: rMin, max: rMax } = parseRange(chartRange);
17700
+ if (rMin === 0 && rMax === 0) return "good";
17701
+ const range = rMax - rMin;
17702
+ const threshold = range > 0 ? range * 0.5 : rMin * 0.05 || 3;
17703
+ const perfectTol = unit === "cm" ? 2.54 : unit === "mm" ? 25.4 : 1;
17704
+ if (userValue > rMin - perfectTol && userValue < rMax + perfectTol) return "good";
17705
+ if (userValue < rMin) {
17706
+ const diff2 = rMin - userValue;
17707
+ if (diff2 > threshold * 2) return "too-loose";
17708
+ if (diff2 > threshold) return "loose";
17709
+ return "a-bit-loose";
18041
17710
  }
18042
- .ps-bp-photo-upload:hover {
18043
- border-color: var(--ps-accent); background: rgba(33,84,239,0.04);
18044
- color: var(--ps-text-secondary);
17711
+ const diff = userValue - rMax;
17712
+ if (diff > threshold * 2) return "too-tight";
17713
+ if (diff > threshold) return "tight";
17714
+ return "a-bit-tight";
17715
+ }
17716
+ const AREA_TO_POSE_KEY = {
17717
+ chest: "chest",
17718
+ bust: "chest",
17719
+ waist: "waist",
17720
+ hips: "hips",
17721
+ hip: "hips"
17722
+ };
17723
+ const SKIP_AREAS_FOR_FIT = /* @__PURE__ */ new Set([
17724
+ "height",
17725
+ "altezza",
17726
+ "estatura"
17727
+ ]);
17728
+ function buildFitInfo(matchDetails, poseLines, unit) {
17729
+ return matchDetails.filter((m2) => !SKIP_AREAS_FOR_FIT.has(m2.measurement.toLowerCase().replace(/\s*\(.*?\)\s*/g, "").trim())).map((m2) => {
17730
+ const userNum = parseNum(m2.userValue);
17731
+ const fit = computeFit(userNum, m2.chartRange, unit);
17732
+ const info = {
17733
+ area: m2.measurement,
17734
+ section: m2.section || void 0,
17735
+ fit,
17736
+ userValue: userNum || void 0,
17737
+ garmentRange: m2.chartRange || void 0
17738
+ };
17739
+ if (poseLines) {
17740
+ const poseKey = AREA_TO_POSE_KEY[m2.measurement.toLowerCase()];
17741
+ if (poseKey && poseLines[poseKey]) {
17742
+ const line = poseLines[poseKey];
17743
+ info.y = Math.round(line.y * 1e3) / 1e3;
17744
+ info.x1 = Math.round(line.x1 * 1e3) / 1e3;
17745
+ info.x2 = Math.round(line.x2 * 1e3) / 1e3;
17746
+ }
17747
+ }
17748
+ return info;
17749
+ });
17750
+ }
17751
+ function buildSilhouetteContext(sizingResult, sizeGuide, selectedSizeOverride) {
17752
+ if (!sizingResult && !sizeGuide) return void 0;
17753
+ const out = {};
17754
+ if (selectedSizeOverride) out.recommendedSize = selectedSizeOverride;
17755
+ else if (sizingResult?.recommendedSize) out.recommendedSize = sizingResult.recommendedSize;
17756
+ const seen = /* @__PURE__ */ new Set();
17757
+ const userLines = [];
17758
+ const push = (md2) => {
17759
+ if (!md2) return;
17760
+ for (const m2 of md2) {
17761
+ const k2 = m2.measurement.toLowerCase();
17762
+ if (seen.has(k2)) continue;
17763
+ seen.add(k2);
17764
+ if (m2.userValue) userLines.push(`${m2.measurement} ${m2.userValue}`);
17765
+ }
17766
+ };
17767
+ push(sizingResult?.matchDetails);
17768
+ if (sizingResult?.sections) {
17769
+ for (const sec of Object.values(sizingResult.sections)) push(sec.matchDetails);
18045
17770
  }
18046
- .ps-bp-photo-upload svg { flex-shrink: 0; opacity: 0.6; }
18047
- .ps-bp-photo-upload-text { font-size: 0.78vw; font-weight: 500; }
18048
- .ps-bp-photo-upload-hint { font-size: 0.62vw; color: var(--ps-text-muted); margin-left: auto; }
18049
-
18050
- .ps-bp-photo-preview {
18051
- display: flex; align-items: center; gap: 0.6vw;
18052
- padding: 0.5vw; border: 1.5px solid var(--ps-accent);
18053
- border-radius: 0.5vw; background: rgba(33,84,239,0.06);
17771
+ if (userLines.length) out.userMeasurementsText = userLines.join(", ");
17772
+ if (sizeGuide?.headers?.length && sizeGuide.rows?.length) {
17773
+ const hdrs = sizeGuide.headers;
17774
+ const rowStrings = sizeGuide.rows.slice(0, 20).map(
17775
+ (row) => hdrs.map((h, i) => `${h} ${row[i] ?? "—"}`).join(", ")
17776
+ );
17777
+ out.sizeChartSummary = rowStrings.join(" | ");
18054
17778
  }
18055
- .ps-bp-photo-thumb {
18056
- width: 2.8vw; height: 2.8vw; object-fit: cover;
18057
- border-radius: 0.4vw; flex-shrink: 0;
17779
+ return Object.keys(out).length ? out : void 0;
17780
+ }
17781
+ const FALLBACK_FIELDS_FEMALE = [
17782
+ { key: "bust", label: "Bust", required: true, unit: "cm", placeholder: "e.g. 88", category: "body" },
17783
+ { key: "waist", label: "Waist", required: true, unit: "cm", placeholder: "e.g. 70", category: "body" },
17784
+ { key: "hips", label: "Hips", required: true, unit: "cm", placeholder: "e.g. 96", category: "body" },
17785
+ { key: "shoulderWidth", label: "Shoulders", required: false, unit: "cm", placeholder: "e.g. 39", category: "body" },
17786
+ { key: "inseam", label: "Inseam", required: false, unit: "cm", placeholder: "e.g. 76", category: "body" },
17787
+ { key: "footLengthCm", label: "Foot length", required: false, unit: "cm", placeholder: "e.g. 24", category: "shoe" }
17788
+ ];
17789
+ const FALLBACK_FIELDS_MALE = [
17790
+ { key: "chest", label: "Chest", required: true, unit: "cm", placeholder: "e.g. 104", category: "body" },
17791
+ { key: "waist", label: "Waist", required: true, unit: "cm", placeholder: "e.g. 84", category: "body" },
17792
+ { key: "shoulderWidth", label: "Shoulders", required: false, unit: "cm", placeholder: "e.g. 46", category: "body" },
17793
+ { key: "sleeveLength", label: "Sleeve", required: false, unit: "cm", placeholder: "e.g. 64", category: "body" },
17794
+ { key: "inseam", label: "Inseam", required: false, unit: "cm", placeholder: "e.g. 81", category: "body" },
17795
+ { key: "footLengthCm", label: "Foot length", required: false, unit: "cm", placeholder: "e.g. 27", category: "shoe" }
17796
+ ];
17797
+ const TOTAL_STEPS = 3;
17798
+ const LS_PREFIX = "primestyle_";
17799
+ const PROFILES_KEY = "profiles";
17800
+ const ACTIVE_PROFILE_KEY = "active_profile_id";
17801
+ const PS_STORAGE_CHANGE_EVENT = "ps:storage-change";
17802
+ function emitStorageChange(reason) {
17803
+ if (typeof window === "undefined") return;
17804
+ try {
17805
+ window.dispatchEvent(new CustomEvent(PS_STORAGE_CHANGE_EVENT, { detail: { reason } }));
17806
+ } catch {
18058
17807
  }
18059
- .ps-bp-photo-info { display: flex; flex-direction: column; gap: 0.1vw; flex: 1; min-width: 0; }
18060
- .ps-bp-photo-status {
18061
- font-size: 0.73vw; font-weight: 600; color: var(--ps-text-primary);
18062
- display: flex; align-items: center; gap: 0.3vw;
17808
+ }
17809
+ function lsGet(key, fallback) {
17810
+ if (typeof window === "undefined") return fallback;
17811
+ try {
17812
+ const raw = localStorage.getItem(LS_PREFIX + key);
17813
+ return raw ? JSON.parse(raw) : fallback;
17814
+ } catch {
17815
+ return fallback;
18063
17816
  }
18064
- .ps-bp-photo-hint { font-size: 0.62vw; color: var(--ps-text-muted); }
18065
- .ps-bp-photo-remove {
18066
- width: 1.5vw; height: 1.5vw; display: flex; align-items: center; justify-content: center;
18067
- background: none; border: none; color: var(--ps-text-muted);
18068
- cursor: pointer; font-size: 1vw; line-height: 1; border-radius: 0.25vw;
18069
- transition: all 0.15s; flex-shrink: 0;
17817
+ }
17818
+ function lsSet(key, value) {
17819
+ try {
17820
+ localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
17821
+ } catch {
18070
17822
  }
18071
- .ps-bp-photo-remove:hover { background: rgba(0,0,0,0.06); color: var(--ps-text-primary); }
18072
-
18073
- /* Error */
18074
- .ps-bp-error {
18075
- color: var(--ps-error-color); font-size: 0.73vw; margin: 0;
17823
+ }
17824
+ function getProfiles() {
17825
+ return lsGet(PROFILES_KEY, []);
17826
+ }
17827
+ function saveProfiles(profiles) {
17828
+ lsSet(PROFILES_KEY, profiles);
17829
+ emitStorageChange("profiles");
17830
+ }
17831
+ function getActiveProfileId() {
17832
+ return lsGet(ACTIVE_PROFILE_KEY, null);
17833
+ }
17834
+ function setActiveProfileId(id2) {
17835
+ lsSet(ACTIVE_PROFILE_KEY, id2);
17836
+ emitStorageChange("active-profile");
17837
+ }
17838
+ function getActiveProfile() {
17839
+ const profiles = getProfiles();
17840
+ if (profiles.length === 0) return null;
17841
+ const activeId = getActiveProfileId();
17842
+ if (activeId) {
17843
+ const found = profiles.find((p2) => p2.id === activeId);
17844
+ if (found) return found;
18076
17845
  }
18077
-
18078
- /* Navigation back left, next right */
18079
- .ps-bp-nav {
18080
- display: flex; align-items: center; margin-top: auto;
18081
- justify-content: space-between;
17846
+ const sorted = [...profiles].sort(
17847
+ (a, b) => (b.lastUsedAt || b.createdAt || 0) - (a.lastUsedAt || a.createdAt || 0)
17848
+ );
17849
+ return sorted[0] || null;
17850
+ }
17851
+ function updateProfile(id2, patch) {
17852
+ const profiles = getProfiles();
17853
+ const idx = profiles.findIndex((p2) => p2.id === id2);
17854
+ if (idx < 0) return null;
17855
+ const updated = { ...profiles[idx], ...patch, lastEditedAt: Date.now() };
17856
+ profiles[idx] = updated;
17857
+ saveProfiles(profiles);
17858
+ return updated;
17859
+ }
17860
+ function updateProfileMeasurements(id2, measurements, unit = "cm") {
17861
+ return updateProfile(id2, { measurements, measurementsUnit: unit });
17862
+ }
17863
+ function addSizeToHistory(profileId, entry) {
17864
+ const profiles = getProfiles();
17865
+ const idx = profiles.findIndex((p2) => p2.id === profileId);
17866
+ if (idx < 0) return null;
17867
+ const profile = profiles[idx];
17868
+ const history = (profile.sizeHistory || []).filter(
17869
+ (h) => h.productId !== entry.productId
17870
+ );
17871
+ history.unshift(entry);
17872
+ const trimmed = history.slice(0, 50);
17873
+ profiles[idx] = {
17874
+ ...profile,
17875
+ sizeHistory: trimmed,
17876
+ lastUsedAt: Date.now()
17877
+ };
17878
+ saveProfiles(profiles);
17879
+ return profiles[idx];
17880
+ }
17881
+ function detectLocale() {
17882
+ if (typeof navigator === "undefined") return "US";
17883
+ const lang = navigator.language || "";
17884
+ const region = lang.split("-")[1]?.toUpperCase();
17885
+ if (region === "GB") return "UK";
17886
+ if (region) return region;
17887
+ const map = {
17888
+ en: "US",
17889
+ ja: "JP",
17890
+ ko: "KR",
17891
+ zh: "CN",
17892
+ fr: "FR",
17893
+ it: "IT",
17894
+ de: "DE",
17895
+ es: "ES",
17896
+ pt: "BR"
17897
+ };
17898
+ return map[lang.split("-")[0].toLowerCase()] || "US";
17899
+ }
17900
+ function getApiKey() {
17901
+ let key = "";
17902
+ try {
17903
+ key = "shopify-proxy";
17904
+ } catch {
18082
17905
  }
18083
- .ps-bp-back-btn {
18084
- background: none; border: none; padding: 0;
18085
- color: var(--ps-text-muted); cursor: pointer;
18086
- font-size: 0.83vw; font-weight: 500; font-family: inherit;
18087
- white-space: nowrap; display: flex; align-items: center; gap: 0.3vw;
18088
- transition: color 0.2s;
17906
+ if (!key) throw new PrimeStyleError("Missing NEXT_PUBLIC_PRIMESTYLE_API_KEY", "MISSING_API_KEY");
17907
+ return key;
17908
+ }
17909
+ function getApiUrl(override) {
17910
+ if (override) return override;
17911
+ let envUrl = "";
17912
+ try {
17913
+ envUrl = "";
17914
+ } catch {
18089
17915
  }
18090
- .ps-bp-back-btn:hover { color: var(--ps-text-primary); }
18091
- .ps-bp-back-arrow {
18092
- display: inline-block; font-size: 1.1vw; transition: transform 0.25s ease;
17916
+ return envUrl || "http://localhost:4000";
17917
+ }
17918
+ let cachedMP = null;
17919
+ const MP_CACHE_TTL_MS = 6e4;
17920
+ function setCachedMediaPipe(landmarks) {
17921
+ cachedMP = { landmarks, storedAt: Date.now() };
17922
+ }
17923
+ function getCachedMediaPipe() {
17924
+ if (!cachedMP) return null;
17925
+ if (Date.now() - cachedMP.storedAt > MP_CACHE_TTL_MS) {
17926
+ cachedMP = null;
17927
+ return null;
18093
17928
  }
18094
- .ps-bp-back-btn:hover .ps-bp-back-arrow { transform: translateX(-4px); }
18095
- .ps-bp-next-btn {
18096
- display: flex; align-items: center; gap: 0.5vw;
18097
- padding: 0.45vw 1.2vw;
18098
- background: var(--ps-accent); border: none; border-radius: 0.35vw;
18099
- color: #fff; font-size: 0.73vw; font-weight: 700;
18100
- cursor: pointer; transition: all 0.15s; font-family: inherit;
18101
- white-space: nowrap;
17929
+ return cachedMP;
17930
+ }
17931
+ async function recommendForProduct(input) {
17932
+ const log = (...args) => console.log("[ps-sdk:recommend]", ...args);
17933
+ const profile = input.profile ?? getActiveProfile();
17934
+ if (!profile) {
17935
+ log("no active profile returning null");
17936
+ return null;
18102
17937
  }
18103
- .ps-bp-next-btn svg { width: 0.75vw; height: 0.75vw; }
18104
- .ps-bp-next-btn:hover { background: var(--ps-accent-hover); }
18105
- .ps-bp-btn-disabled {
18106
- opacity: 0.35; cursor: not-allowed; pointer-events: none;
17938
+ log("start", {
17939
+ productId: input.productId,
17940
+ profileId: profile.id,
17941
+ profileName: profile.name,
17942
+ hasMeasurements: !!profile.measurements && Object.keys(profile.measurements || {}).length,
17943
+ measurementsCount: Object.keys(profile.measurements || {}).length
17944
+ });
17945
+ log("cache MISS — calling backend");
17946
+ let apiKey;
17947
+ try {
17948
+ apiKey = input.apiKey ?? getApiKey();
17949
+ } catch (e) {
17950
+ log("no api key — aborting", e);
17951
+ return null;
18107
17952
  }
18108
-
18109
- /* ── Mobile responsive ── */
18110
- @media (max-width: 768px) {
18111
- .ps-bp-layout { flex-direction: column; height: auto; gap: 0; overflow: visible; }
18112
- /* Desktop layout's product image — hidden on mobile because BasicsStepMobile
18113
- has its own minimal layout. The other steps (chest/midsection/hips) don't
18114
- need the giant left-column image on a phone either. */
18115
- .ps-bp-image { display: none !important; }
18116
- .ps-bp-wrapper { overflow-y: auto; -webkit-overflow-scrolling: touch; flex: 1; min-height: 0; display: flex; flex-direction: column; }
18117
- .ps-bp-layout { flex: 1; min-height: 0; }
18118
- /* Override desktop justify-content:center so BasicsStepMobile can
18119
- stretch to the bottom of the modal — its bottom action bar (Next +
18120
- tabs) needs to land at the bottom edge, not floating in the middle. */
18121
- .ps-bp-root {
18122
- justify-content: stretch !important;
18123
- height: auto !important;
18124
- max-height: none !important;
18125
- overflow: visible !important;
18126
- flex: 1 !important;
18127
- }
18128
- .ps-bp-upload-placeholder svg { width: 32px; height: 32px; }
18129
- .ps-bp-upload-text { font-size: 14px; }
18130
- .ps-bp-upload-hint { font-size: 11px; }
18131
- .ps-bp-root { padding: 16px; gap: 14px; overflow: visible; }
18132
- .ps-bp-dots { gap: 6px; }
18133
- .ps-bp-dot { width: 6px; height: 6px; }
18134
- .ps-bp-dot-active { width: 18px; border-radius: 4px; }
18135
- .ps-bp-title { font-size: 18px; }
18136
- .ps-bp-subtitle { font-size: 13px; margin-top: -4px; }
18137
- .ps-bp-system-toggle { gap: 20px; margin: 4px 0; }
18138
- .ps-bp-system-btn { font-size: 13px; }
18139
- .ps-bp-inline-row { padding: 14px 0; gap: 12px; }
18140
- .ps-bp-inline-label { flex: 0 0 85px; font-size: 11px; white-space: nowrap; }
18141
- .ps-bp-inline-input { font-size: 18px; }
18142
- .ps-bp-inline-unit { font-size: 12px; }
18143
- .ps-bp-photo-cta { font-size: 13px; gap: 6px; margin-top: 4px; }
18144
- .ps-bp-photo-cta svg { width: 16px; height: 16px; }
18145
- .ps-bp-options { gap: 6px; }
18146
- .ps-bp-option { border-radius: 10px; padding: 6px 12px 6px 6px; gap: 10px; }
18147
- .ps-bp-option-icon { width: 70px; height: 70px; }
18148
- .ps-bp-option-label { font-size: 14px; }
18149
- .ps-bp-option::after { font-size: 18px; }
18150
- .ps-bp-option { padding: 14px 8px; border-radius: 10px; gap: 8px; }
18151
- .ps-bp-option-icon { width: 56px; height: 56px; }
18152
- .ps-bp-option-label { font-size: 13px; }
18153
- .ps-bp-error { font-size: 12px; }
18154
- .ps-bp-shape-section { margin-bottom: 12px; }
18155
- .ps-bp-shape-label { font-size: 13px; margin-bottom: 6px; }
18156
- .ps-bp-shape-row { gap: 6px; }
18157
- .ps-bp-img-card { border-radius: 8px; border-width: 2px; }
18158
- .ps-bp-img-card-label { font-size: 11px; padding: 4px 2px; }
18159
- .ps-bp-hover-hint { font-size: 10px; margin-top: 6px; }
18160
- .ps-bp-single-q .ps-bp-title { font-size: 13px; font-weight: 400; }
18161
- .ps-bp-photo-upload { padding: 10px 12px; gap: 8px; border-radius: 8px; }
18162
- .ps-bp-photo-upload-text { font-size: 13px; }
18163
- .ps-bp-photo-upload-hint { font-size: 10px; }
18164
- .ps-bp-photo-preview { padding: 8px; gap: 8px; border-radius: 8px; }
18165
- .ps-bp-photo-thumb { width: 40px; height: 40px; border-radius: 6px; }
18166
- .ps-bp-photo-status { font-size: 12px; gap: 4px; }
18167
- .ps-bp-photo-hint { font-size: 10px; }
18168
- .ps-bp-photo-remove { width: 22px; height: 22px; font-size: 16px; }
18169
- .ps-bp-nav { padding-top: 6px; }
18170
- .ps-bp-back-btn { font-size: 14px; }
18171
- .ps-bp-back-arrow { font-size: 18px; }
18172
- .ps-bp-next-btn {
18173
- display: flex; align-items: center; gap: 0.5vw; padding: 10px 16px; font-size: 13px; border-radius: 6px; }
18174
- .ps-bp-root { padding: 12px; overflow: hidden; }
18175
- /* Accessory sizing inputs — mobile */
18176
- .ps-acc-input { padding: 3vw; font-size: 3.6vw; border-radius: 2vw; }
18177
- .ps-acc-label { font-size: 2.8vw; margin-bottom: 1.5vw; }
18178
- .ps-acc-field { margin: 3vw 0; }
18179
- .ps-acc-error { font-size: 2.8vw; }
18180
- .ps-acc-hint { font-size: 2.5vw; }
18181
- .ps-tryon-unit-toggle { border-radius: 2vw; }
18182
- .ps-tryon-unit-btn { padding: 2vw 4vw; font-size: 3vw; }
17953
+ const apiUrl = (input.apiUrl ?? getApiUrl()).replace(/\/+$/, "");
17954
+ if (!apiKey) {
17955
+ log("no api key — aborting");
17956
+ return null;
18183
17957
  }
18184
-
18185
- /* Upload hover overlay */
18186
- .ps-tryon-upload-hover:hover .ps-tryon-upload-hover-overlay { opacity: 1 !important; }
18187
-
18188
- /* ─────────── Unified PhotoUploadZone ─────────── */
18189
- .ps-photo-zone {
18190
- flex: 1; min-height: 220px;
18191
- display: flex; flex-direction: column; align-items: center; justify-content: center;
18192
- border: 2px dashed var(--ps-border-color);
18193
- border-radius: 0.5vw;
18194
- background: var(--ps-bg-secondary);
18195
- cursor: pointer; position: relative; overflow: hidden;
18196
- transition: border-color 0.18s, background 0.18s, transform 0.18s;
17958
+ let sizeGuide = null;
17959
+ if (input.sizeGuideData != null) {
17960
+ try {
17961
+ const sgRes = await fetch(`${apiUrl}/api/v1/sizing/sizeguide`, {
17962
+ method: "POST",
17963
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
17964
+ body: JSON.stringify({
17965
+ product: { title: input.productTitle, productId: input.productId },
17966
+ sizeGuideRaw: input.sizeGuideData
17967
+ })
17968
+ });
17969
+ if (sgRes.ok) {
17970
+ sizeGuide = await sgRes.json();
17971
+ log("sizeguide OK", { found: sizeGuide?.found, sectionCount: Object.keys(sizeGuide?.sections || {}).length });
17972
+ } else {
17973
+ log("sizeguide FAILED", sgRes.status, sgRes.statusText);
17974
+ }
17975
+ } catch (e) {
17976
+ log("sizeguide threw", e);
17977
+ }
17978
+ } else {
17979
+ log("no sizeGuideData provided");
18197
17980
  }
18198
- .ps-photo-zone:hover { border-color: var(--ps-accent); background: rgba(33,84,239,0.02); }
18199
- .ps-photo-zone.ps-photo-zone-drag {
18200
- border-color: var(--ps-accent); border-style: solid;
18201
- background: rgba(33,84,239,0.06);
18202
- transform: scale(1.005);
17981
+ const measurements = {
17982
+ gender: profile.gender,
17983
+ sizingUnit: profile.measurementsUnit || "cm"
17984
+ };
17985
+ if (profile.measurements) {
17986
+ for (const [key, value] of Object.entries(profile.measurements)) {
17987
+ if (value != null) measurements[key] = value;
17988
+ }
18203
17989
  }
18204
- .ps-photo-zone.ps-photo-zone-has { border: none; cursor: default; padding: 0; }
18205
- .ps-photo-zone.ps-photo-zone-inline { min-height: 100%; height: 100%; }
18206
-
18207
- .ps-photo-zone-empty {
18208
- display: flex; flex-direction: column; align-items: center; gap: 0.4vw;
18209
- padding: 1vw; text-align: center; pointer-events: none;
17990
+ if (profile.height != null) measurements.height = profile.height;
17991
+ if (profile.weight != null) measurements.weight = profile.weight;
17992
+ if (profile.heightUnit) measurements.heightUnit = profile.heightUnit;
17993
+ if (profile.weightUnit) measurements.weightUnit = profile.weightUnit;
17994
+ if (profile.age) measurements.age = profile.age;
17995
+ if (profile.chestProfile) measurements.chestProfile = profile.chestProfile;
17996
+ if (profile.midsectionProfile) measurements.midsectionProfile = profile.midsectionProfile;
17997
+ if (profile.hipProfile) measurements.hipProfile = profile.hipProfile;
17998
+ const payload = {
17999
+ method: "exact",
18000
+ locale: profile.country || "US",
18001
+ sizingUnit: profile.measurementsUnit || profile.sizingUnit || "cm",
18002
+ product: { title: input.productTitle, productId: input.productId, description: "", variants: [] },
18003
+ measurements
18004
+ };
18005
+ if (profile.gender === "female" && profile.bandSize && profile.cupSize) {
18006
+ const region = profile.braRegion ?? profile.braSizeRegion ?? "US";
18007
+ payload.braSize = {
18008
+ band: parseFloat(String(profile.bandSize)),
18009
+ cup: String(profile.cupSize),
18010
+ region
18011
+ };
18210
18012
  }
18211
- .ps-photo-zone-title { font-size: 0.85vw; font-weight: 600; color: var(--ps-text-primary); }
18212
- .ps-photo-zone-hint { font-size: 0.6vw; color: var(--ps-text-muted); line-height: 1.4; max-width: 24vw; }
18213
-
18214
- .ps-photo-zone-img {
18215
- width: 100%; height: 100%; object-fit: contain;
18216
- display: block; cursor: pointer;
18013
+ if (profile.knownMeasurements) {
18014
+ payload.knownMeasurements = profile.knownMeasurements;
18217
18015
  }
18218
- .ps-photo-zone-hover-overlay {
18219
- position: absolute; inset: 0;
18220
- display: flex; align-items: center; justify-content: center;
18221
- background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s;
18222
- color: #fff; font-size: 0.8vw; font-weight: 600;
18223
- border-radius: 0.5vw; cursor: pointer;
18016
+ if (sizeGuide && sizeGuide.found) {
18017
+ payload.sizeGuide = sizeGuide;
18224
18018
  }
18225
- .ps-photo-zone:hover .ps-photo-zone-hover-overlay { opacity: 1; }
18226
- .ps-photo-zone-remove {
18227
- position: absolute; top: 0.5vw; right: 0.5vw;
18228
- background: rgba(0,0,0,0.55); color: #fff; border: none; border-radius: 50%;
18229
- width: 24px; height: 24px; min-width: 24px;
18230
- display: flex; align-items: center; justify-content: center;
18231
- cursor: pointer; transition: background 0.15s;
18232
- z-index: 2;
18019
+ log("calling /sizing/recommend", { measurements: Object.keys(measurements), hasSizeGuide: !!payload.sizeGuide });
18020
+ let result = null;
18021
+ try {
18022
+ const res = await fetch(`${apiUrl}/api/v1/sizing/recommend`, {
18023
+ method: "POST",
18024
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
18025
+ body: JSON.stringify(payload)
18026
+ });
18027
+ if (!res.ok) {
18028
+ log("recommend FAILED", res.status, res.statusText);
18029
+ return null;
18030
+ }
18031
+ result = await res.json();
18032
+ log("recommend OK", {
18033
+ recommendedSize: result?.recommendedSize,
18034
+ sectionKeys: result?.sections ? Object.keys(result.sections) : null,
18035
+ sections: result?.sections
18036
+ });
18037
+ } catch (e) {
18038
+ log("recommend threw", e);
18039
+ return null;
18233
18040
  }
18234
- .ps-photo-zone-remove:hover { background: rgba(239,68,68,0.85); }
18235
-
18236
- .ps-photo-zone-processing {
18237
- display: flex; flex-direction: column; align-items: center; gap: 0.5vw;
18238
- padding: 1vw;
18041
+ if (!result || !result.recommendedSize) {
18042
+ log("recommend returned no recommendedSize — bailing");
18043
+ return null;
18239
18044
  }
18240
- .ps-photo-zone-spinner {
18241
- width: 28px; height: 28px;
18242
- border: 3px solid rgba(33,84,239,0.18);
18243
- border-top-color: var(--ps-accent);
18244
- border-radius: 50%;
18245
- animation: ps-spin 0.7s linear infinite;
18045
+ const sectionsMap = result.sections ? Object.fromEntries(
18046
+ Object.entries(result.sections).map(([name, sec]) => [name, sec.recommendedSize])
18047
+ ) : void 0;
18048
+ const sectionsFull = result.sections ? Object.fromEntries(
18049
+ Object.entries(result.sections).map(([name, sec]) => [
18050
+ name,
18051
+ {
18052
+ recommendedSize: sec.recommendedSize,
18053
+ // Backend may include these on merged Jacket/Pants entries
18054
+ size: sec.size,
18055
+ length: sec.length,
18056
+ availableSizes: sec.availableSizes,
18057
+ availableLengths: sec.availableLengths
18058
+ }
18059
+ ])
18060
+ ) : void 0;
18061
+ addSizeToHistory(profile.id, {
18062
+ productId: input.productId,
18063
+ productTitle: input.productTitle,
18064
+ productImage: input.productImage,
18065
+ recommendedSize: result.recommendedSize,
18066
+ confidence: result.confidence,
18067
+ sections: sectionsMap,
18068
+ sectionsFull,
18069
+ recommendedLength: result.recommendedLength || void 0,
18070
+ savedAt: Date.now()
18071
+ });
18072
+ return {
18073
+ recommendedSize: result.recommendedSize,
18074
+ confidence: result.confidence,
18075
+ sections: sectionsMap,
18076
+ profileId: profile.id,
18077
+ fromCache: false,
18078
+ raw: result,
18079
+ found: result.found
18080
+ };
18081
+ }
18082
+ async function estimateFullMeasurements(args) {
18083
+ let apiKey;
18084
+ try {
18085
+ apiKey = args.apiKey ?? getApiKey();
18086
+ } catch {
18087
+ return null;
18246
18088
  }
18247
- .ps-photo-zone-status { font-size: 0.7vw; color: var(--ps-text-secondary); }
18248
-
18249
- .ps-photo-zone-rejection {
18250
- display: flex; flex-direction: column; align-items: center; gap: 0.5vw;
18251
- padding: 1vw 1.2vw; max-width: 22vw; text-align: center;
18252
- cursor: default;
18089
+ const apiUrl = (args.apiUrl ?? getApiUrl()).replace(/\/+$/, "");
18090
+ if (!apiKey) return null;
18091
+ const requiredFields = [
18092
+ "chest",
18093
+ "bust",
18094
+ "waist",
18095
+ "hips",
18096
+ "shoulderWidth",
18097
+ "sleeveLength",
18098
+ "inseam",
18099
+ "neckCircumference",
18100
+ "thighCircumference",
18101
+ "footLengthCm"
18102
+ ];
18103
+ const payload = {
18104
+ height: args.height ?? 0,
18105
+ weight: args.weight ?? 0,
18106
+ heightUnit: args.heightUnit,
18107
+ weightUnit: args.weightUnit,
18108
+ gender: args.gender,
18109
+ requiredFields
18110
+ };
18111
+ if (args.age) payload.age = args.age;
18112
+ if (args.chestProfile) payload.chestProfile = args.chestProfile;
18113
+ if (args.midsectionProfile) payload.midsectionProfile = args.midsectionProfile;
18114
+ if (args.hipProfile) payload.hipProfile = args.hipProfile;
18115
+ if (args.bodyImage) payload.bodyImage = args.bodyImage;
18116
+ let landmarksForPayload = args.bodyLandmarks ?? null;
18117
+ if (!landmarksForPayload) {
18118
+ const cached = getCachedMediaPipe();
18119
+ if (cached) {
18120
+ landmarksForPayload = cached.landmarks;
18121
+ console.log("[PS-SDK:estimate] reusing cached MediaPipe result from recent /recommend run");
18122
+ }
18253
18123
  }
18254
- .ps-photo-zone-rejection-icon {
18255
- width: 36px; height: 36px; border-radius: 50%;
18256
- background: rgba(239,68,68,0.12); color: #dc2626;
18257
- display: flex; align-items: center; justify-content: center;
18258
- font-size: 18px; font-weight: 700;
18124
+ if (!landmarksForPayload && args.bodyImage) {
18125
+ console.log("[PS-SDK:estimate] bodyImage present but bodyLandmarks missing — running MediaPipe now");
18126
+ try {
18127
+ const detected = await detectBodyLandmarks(args.bodyImage);
18128
+ if (detected) {
18129
+ landmarksForPayload = detected;
18130
+ console.log("[PS-SDK:estimate] MediaPipe auto-detect ✓ anchors available for backend");
18131
+ } else {
18132
+ console.warn("[PS-SDK:estimate] MediaPipe auto-detect returned null — backend will fall back to low-confidence visual estimation");
18133
+ }
18134
+ } catch (e) {
18135
+ console.warn("[PS-SDK:estimate] MediaPipe auto-detect threw:", e);
18136
+ }
18259
18137
  }
18260
- .ps-photo-zone-rejection-title { font-size: 0.85vw; font-weight: 700; color: var(--ps-text-primary); }
18261
- .ps-photo-zone-rejection-msg { font-size: 0.65vw; color: var(--ps-text-secondary); line-height: 1.5; }
18262
- .ps-photo-zone-rejection-cta {
18263
- margin-top: 0.3vw; padding: 0.55vw 1vw;
18264
- background: var(--ps-accent); color: #fff; border: none;
18265
- border-radius: 0.4vw; font-family: inherit;
18266
- font-size: 0.7vw; font-weight: 600; cursor: pointer;
18267
- transition: opacity 0.15s;
18138
+ if (landmarksForPayload) payload.bodyLandmarks = landmarksForPayload;
18139
+ if (landmarksForPayload) {
18140
+ const lm = landmarksForPayload;
18141
+ const required = [
18142
+ ["leftShoulder", lm.leftShoulder?.visibility],
18143
+ ["rightShoulder", lm.rightShoulder?.visibility],
18144
+ ["leftHip", lm.leftHip?.visibility],
18145
+ ["rightHip", lm.rightHip?.visibility]
18146
+ ];
18147
+ const lowVis = required.filter(([, v2]) => typeof v2 === "number" && v2 < 0.5);
18148
+ if (lowVis.length > 0) {
18149
+ console.warn(`[PS-SDK:estimate] LOW joint visibility — ${lowVis.map(([k2, v2]) => `${k2}=${v2.toFixed(2)}`).join(", ")} — accuracy may suffer; ask user to retake with better framing/lighting`);
18150
+ }
18268
18151
  }
18269
- .ps-photo-zone-rejection-cta:hover { opacity: 0.9; }
18270
-
18271
- .ps-photo-zone-error {
18272
- position: absolute; bottom: 0.6vw; left: 0.6vw; right: 0.6vw;
18273
- background: rgba(239,68,68,0.08); color: #dc2626;
18274
- border: 1px solid rgba(239,68,68,0.2); border-radius: 0.4vw;
18275
- padding: 0.4vw 0.6vw; font-size: 0.6vw; line-height: 1.4;
18276
- text-align: center; pointer-events: none;
18152
+ try {
18153
+ console.groupCollapsed(`[PS-SDK:estimate] POST /api/v1/sizing/estimate`);
18154
+ console.log("url:", `${apiUrl}/api/v1/sizing/estimate`);
18155
+ console.log("payload (sans bodyImage):", {
18156
+ ...payload,
18157
+ bodyImage: payload.bodyImage ? `[base64 ~${Math.round(String(payload.bodyImage).length / 1024)}KB]` : void 0,
18158
+ bodyLandmarks: payload.bodyLandmarks ? {
18159
+ count: Object.keys(payload.bodyLandmarks).length,
18160
+ imageWidth: payload.bodyLandmarks.imageWidth,
18161
+ imageHeight: payload.bodyLandmarks.imageHeight
18162
+ } : void 0
18163
+ });
18164
+ console.groupEnd();
18165
+ {
18166
+ const lmObj = payload.bodyLandmarks;
18167
+ const jointCount = lmObj ? Object.values(lmObj).filter(
18168
+ (v2) => v2 && typeof v2 === "object" && typeof v2.x === "number" && typeof v2.y === "number"
18169
+ ).length : 0;
18170
+ console.log(`[ps-sdk:debug] /estimate payload → bodyLandmarks=${!!lmObj}(${jointCount} joints)`);
18171
+ }
18172
+ const res = await fetch(`${apiUrl}/api/v1/sizing/estimate`, {
18173
+ method: "POST",
18174
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
18175
+ body: JSON.stringify(payload)
18176
+ });
18177
+ if (!res.ok) {
18178
+ console.warn(`[PS-SDK:estimate] ✗ ${res.status} ${res.statusText}`);
18179
+ return null;
18180
+ }
18181
+ const data = await res.json();
18182
+ console.groupCollapsed(`[PS-SDK:estimate] ✓ response (confidence=${data?.confidence ?? "—"})`);
18183
+ console.log("estimates:", data.estimates);
18184
+ console.log("userEstimates:", data.userEstimates);
18185
+ console.log("method:", data.method, "unit:", data.unit);
18186
+ console.log("full response:", data);
18187
+ console.groupEnd();
18188
+ if (!data?.estimates) return null;
18189
+ return {
18190
+ estimates: data.estimates,
18191
+ unit: data.unit || "cm",
18192
+ userEstimates: data.userEstimates
18193
+ };
18194
+ } catch (err) {
18195
+ console.error("[PS-SDK:estimate] threw:", err);
18196
+ return null;
18277
18197
  }
18278
-
18279
- @media (max-width: 700px) {
18280
- .ps-photo-zone { min-height: 200px; border-radius: 12px; }
18281
- .ps-photo-zone-title { font-size: 14px; }
18282
- .ps-photo-zone-hint { font-size: 11px; max-width: 90%; }
18283
- .ps-photo-zone-hover-overlay { font-size: 13px; }
18284
- .ps-photo-zone-status { font-size: 12px; }
18285
- .ps-photo-zone-rejection { max-width: 90%; gap: 8px; padding: 14px; }
18286
- .ps-photo-zone-rejection-title { font-size: 14px; }
18287
- .ps-photo-zone-rejection-msg { font-size: 12px; }
18288
- .ps-photo-zone-rejection-cta { font-size: 13px; padding: 10px 16px; border-radius: 8px; }
18289
- .ps-photo-zone-error { font-size: 11px; padding: 8px 10px; border-radius: 8px; }
18198
+ }
18199
+ function isImperial(locale) {
18200
+ return ["US", "UK", "AU"].includes(locale);
18201
+ }
18202
+ function getUnitLabel(unit) {
18203
+ if (unit === "in" || unit === "inches" || unit === "lbs") return "Imperial";
18204
+ if (unit === "cm" || unit === "kg") return "Metric";
18205
+ if (unit === "mm") return "mm";
18206
+ return "";
18207
+ }
18208
+ const cache = /* @__PURE__ */ new Map();
18209
+ function scoreLandmarks(lm) {
18210
+ if (!lm) return 0;
18211
+ let joints = 0;
18212
+ for (const [k2, v2] of Object.entries(lm)) {
18213
+ if (k2 === "imageWidth" || k2 === "imageHeight") continue;
18214
+ if (v2 && typeof v2 === "object" && typeof v2.x === "number") joints++;
18290
18215
  }
18291
- `;
18216
+ let score = joints * 10;
18217
+ if (lm.nose) score += 50;
18218
+ if (lm.leftAnkle && lm.rightAnkle) score += 5;
18219
+ return score;
18220
+ }
18221
+ async function scoreImage(url) {
18222
+ try {
18223
+ const lm = await detectBodyLandmarks(url);
18224
+ return scoreLandmarks(lm);
18225
+ } catch {
18226
+ return 0;
18227
+ }
18228
+ }
18229
+ async function pickBestGarmentImage(images) {
18230
+ if (!images || !images.length) return null;
18231
+ if (images.length === 1) return images[0];
18232
+ const cacheKey = images.join("|");
18233
+ const cached = cache.get(cacheKey);
18234
+ if (cached) return cached;
18235
+ const t0 = Date.now();
18236
+ const scored = await Promise.all(images.map(async (url) => ({
18237
+ url,
18238
+ score: await scoreImage(url)
18239
+ })));
18240
+ scored.sort((a, b) => b.score - a.score);
18241
+ const best = (scored[0]?.score ?? 0) > 0 ? scored[0].url : images[0];
18242
+ cache.set(cacheKey, best);
18243
+ console.log(`[ps-sdk:garment-pick] ${Date.now() - t0}ms — chose ${images.indexOf(best)}/${images.length}`);
18244
+ for (const s of scored) console.log(`[ps-sdk:garment-pick] ${s.score.toString().padStart(4, " ")} ${s.url}`);
18245
+ return best;
18246
+ }
18247
+ function cx(base, override) {
18248
+ return override ? `${base} ${override}` : base;
18249
+ }
18292
18250
  function CameraIcon$1({ size = 18 }) {
18293
18251
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", children: [
18294
18252
  /* @__PURE__ */ jsxRuntimeExports.jsx("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" }),
@@ -27631,12 +27589,15 @@ function FaceSizeView(props) {
27631
27589
  );
27632
27590
  }
27633
27591
  if (typeof document !== "undefined") {
27634
- const id2 = "ps-tryon-styles";
27635
- if (!document.getElementById(id2)) {
27636
- const el2 = document.createElement("style");
27637
- el2.id = id2;
27638
- el2.textContent = STYLES$1;
27639
- document.head.appendChild(el2);
27592
+ const skipForShadow = !!globalThis.__PRIMESTYLE_SHADOW_ACTIVE__;
27593
+ if (!skipForShadow) {
27594
+ const id2 = "ps-tryon-styles";
27595
+ if (!document.getElementById(id2)) {
27596
+ const el2 = document.createElement("style");
27597
+ el2.id = id2;
27598
+ el2.textContent = STYLES$1;
27599
+ document.head.appendChild(el2);
27600
+ }
27640
27601
  }
27641
27602
  }
27642
27603
  function detectMeasurementType(title) {
@@ -29874,9 +29835,14 @@ const STYLES = `
29874
29835
  color: #1f2937;
29875
29836
  }
29876
29837
  `;
29838
+ const SIZE_GUIDE_STYLES = STYLES;
29877
29839
  let stylesInjected = false;
29878
29840
  function injectStyles() {
29879
29841
  if (stylesInjected) return;
29842
+ if (globalThis.__PRIMESTYLE_SHADOW_ACTIVE__) {
29843
+ stylesInjected = true;
29844
+ return;
29845
+ }
29880
29846
  const tag = document.createElement("style");
29881
29847
  tag.id = "ps-sg-styles";
29882
29848
  tag.textContent = STYLES;
@@ -30245,6 +30211,8 @@ function installProductViewTracking() {
30245
30211
  const roots = document.querySelectorAll(".primestyle-tryon-root");
30246
30212
  roots.forEach((r2) => maybeFireProductView(r2));
30247
30213
  }
30214
+ globalThis.__PRIMESTYLE_SHADOW_ACTIVE__ = true;
30215
+ const SDK_CSS = STYLES$1 + "\n\n" + SIZE_GUIDE_STYLES;
30248
30216
  const TAG = "[primestyle-tryon]";
30249
30217
  console.log(`${TAG} bundle loaded — version 5.7.x storefront entry`);
30250
30218
  installCartHook();
@@ -30333,24 +30301,30 @@ async function mount(el2) {
30333
30301
  props.sizeGuideData = fetched;
30334
30302
  }
30335
30303
  }
30336
- let shadow;
30304
+ let mountTarget = el2;
30337
30305
  try {
30338
- shadow = el2.shadowRoot ?? el2.attachShadow({ mode: "open" });
30339
- injectStylesIntoShadow(shadow);
30306
+ const shadow = el2.shadowRoot ?? el2.attachShadow({ mode: "open" });
30307
+ if (!shadow.querySelector("style[data-primestyle]")) {
30308
+ const style = document.createElement("style");
30309
+ style.setAttribute("data-primestyle", "sdk-styles");
30310
+ style.textContent = SDK_CSS;
30311
+ shadow.appendChild(style);
30312
+ }
30313
+ let inner = shadow.querySelector(
30314
+ "[data-primestyle-mount]"
30315
+ );
30316
+ if (!inner) {
30317
+ inner = document.createElement("div");
30318
+ inner.setAttribute("data-primestyle-mount", "");
30319
+ shadow.appendChild(inner);
30320
+ }
30321
+ mountTarget = inner;
30340
30322
  } catch (err) {
30341
- console.warn(`${TAG} shadow attach failed — falling back to direct mount`, err);
30342
- shadow = el2;
30343
- }
30344
- let mountTarget;
30345
- if (shadow instanceof ShadowRoot) {
30346
- mountTarget = shadow.querySelector("[data-primestyle-mount]") ?? (() => {
30347
- const c = document.createElement("div");
30348
- c.setAttribute("data-primestyle-mount", "");
30349
- shadow.appendChild(c);
30350
- return c;
30351
- })();
30352
- } else {
30353
- mountTarget = shadow;
30323
+ console.warn(
30324
+ `${TAG} shadow attach failed — falling back to direct mount`,
30325
+ err
30326
+ );
30327
+ mountTarget = el2;
30354
30328
  }
30355
30329
  if (props.sizeGuideData) {
30356
30330
  try {
@@ -30367,7 +30341,7 @@ async function mount(el2) {
30367
30341
  const root = createRoot(mountTarget);
30368
30342
  root.render(reactExports.createElement(PrimeStyleTryon, props));
30369
30343
  MOUNTED.set(el2, root);
30370
- console.log(`${TAG} ✓ mounted React component (shadow-isolated)`);
30344
+ console.log(`${TAG} ✓ mounted React component`);
30371
30345
  maybeFireProductView(el2);
30372
30346
  } catch (err) {
30373
30347
  console.error(`${TAG} ✗ React mount failed`, err);