@primestyleai/tryon 5.10.106 → 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.
@@ -9256,1564 +9256,190 @@ const locales = {
9256
9256
  for (const [code, translations] of Object.entries(locales)) {
9257
9257
  registerLocale(code, translations);
9258
9258
  }
9259
- var jsxRuntime = { exports: {} };
9260
- var reactJsxRuntime_production_min = {};
9261
- /**
9262
- * @license React
9263
- * react-jsx-runtime.production.min.js
9264
- *
9265
- * Copyright (c) Facebook, Inc. and its affiliates.
9266
- *
9267
- * This source code is licensed under the MIT license found in the
9268
- * LICENSE file in the root directory of this source tree.
9269
- */
9270
- 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 };
9271
- function q(c, a, g) {
9272
- var b, d = {}, e = null, h = null;
9273
- void 0 !== g && (e = "" + g);
9274
- void 0 !== a.key && (e = "" + a.key);
9275
- void 0 !== a.ref && (h = a.ref);
9276
- for (b in a) m.call(a, b) && !p.hasOwnProperty(b) && (d[b] = a[b]);
9277
- if (c && c.defaultProps) for (b in a = c.defaultProps, a) void 0 === d[b] && (d[b] = a[b]);
9278
- return { $$typeof: k, type: c, key: e, ref: h, props: d, _owner: n.current };
9279
- }
9280
- reactJsxRuntime_production_min.Fragment = l;
9281
- reactJsxRuntime_production_min.jsx = q;
9282
- reactJsxRuntime_production_min.jsxs = q;
9283
- {
9284
- jsxRuntime.exports = reactJsxRuntime_production_min;
9285
- }
9286
- var jsxRuntimeExports = jsxRuntime.exports;
9287
- const STORAGE_KEY = "ps_session";
9288
- const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
9289
- const FALLBACK_PREFIX = "ps_mem_";
9290
- let memoryFallback = null;
9291
- function hasLocalStorage() {
9292
- try {
9293
- return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
9294
- } catch {
9295
- 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;
9296
9286
  }
9297
- }
9298
- function uuid() {
9299
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
9300
- 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;
9301
9300
  }
9302
- const chars = "0123456789abcdef";
9303
- let out = FALLBACK_PREFIX;
9304
- for (let i = 0; i < 32; i++) out += chars[Math.floor(Math.random() * 16)];
9305
- return out;
9306
- }
9307
- function readRecord() {
9308
- if (!hasLocalStorage()) return memoryFallback;
9309
- try {
9310
- const raw = window.localStorage.getItem(STORAGE_KEY);
9311
- if (!raw) return null;
9312
- const parsed = JSON.parse(raw);
9313
- if (typeof parsed.id !== "string" || typeof parsed.issuedAt !== "number") return null;
9314
- return {
9315
- id: parsed.id,
9316
- issuedAt: parsed.issuedAt,
9317
- lastSeenAt: typeof parsed.lastSeenAt === "number" ? parsed.lastSeenAt : parsed.issuedAt
9318
- };
9319
- } catch {
9320
- 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;
9321
9318
  }
9322
- }
9323
- function writeRecord(record) {
9324
- memoryFallback = record;
9325
- if (!hasLocalStorage()) return;
9326
- try {
9327
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
9328
- } 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;
9329
9331
  }
9330
- }
9331
- function getOrCreateSessionId() {
9332
- const now = Date.now();
9333
- const existing = readRecord();
9334
- if (existing && now - existing.lastSeenAt < MAX_AGE_MS) {
9335
- if (now - existing.lastSeenAt > 5 * 60 * 1e3) {
9336
- writeRecord({ ...existing, lastSeenAt: now });
9337
- }
9338
- 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);
9339
9343
  }
9340
- const fresh = { id: uuid(), issuedAt: now, lastSeenAt: now };
9341
- writeRecord(fresh);
9342
- return fresh.id;
9343
- }
9344
- function getDeviceHint() {
9345
- if (typeof navigator === "undefined") return null;
9346
- const ua2 = (navigator.userAgent || "").toLowerCase();
9347
- if (/ipad|tablet|(android(?!.*mobile))/.test(ua2)) return "tablet";
9348
- if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/.test(ua2)) return "mobile";
9349
- return "desktop";
9350
- }
9351
- const DEFAULT_API_URL = "http://localhost:4000";
9352
- class ApiClient {
9353
- constructor(apiKey, apiUrl) {
9354
- this.apiKey = apiKey;
9355
- 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));
9356
9347
  }
9357
- get headers() {
9358
- return {
9359
- "Content-Type": "application/json",
9360
- Authorization: `Bearer ${this.apiKey}`
9361
- };
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;
9362
9359
  }
9363
- async submitTryOn(modelImage, garmentImage, fitInfo, category, context) {
9364
- const body = {
9365
- garmentImage,
9366
- // Attribution fields — backend logs these into the TryOnEvent collection
9367
- // so non-technical operators can answer "which products are being tried on
9368
- // and by how many customers". Safe fallbacks when absent.
9369
- sessionId: getOrCreateSessionId(),
9370
- deviceHint: getDeviceHint()
9371
- };
9372
- if (context?.modelImageId) body.modelImageId = context.modelImageId;
9373
- if (modelImage) body.modelImage = modelImage;
9374
- if (fitInfo && fitInfo.length > 0) body.fitInfo = fitInfo;
9375
- if (category && category !== "apparel") body.category = category;
9376
- if (context?.productId) body.productId = context.productId;
9377
- if (context?.productTitle) body.productTitle = context.productTitle;
9378
- if (context?.productDescription) body.productDescription = context.productDescription;
9379
- if (context?.productMaterial) body.productMaterial = context.productMaterial;
9380
- if (context?.silhouetteContext) body.silhouetteContext = context.silhouetteContext;
9381
- if (context?.editFromPrevious) body.editFromPrevious = true;
9382
- console.log("[ps-sdk:api] POST /api/v1/tryon payload", {
9383
- modelImageBytes: modelImage.length,
9384
- garmentImageBytes: garmentImage.length,
9385
- category: category || "apparel",
9386
- fitInfo: fitInfo?.map((f2) => ({
9387
- area: f2.area,
9388
- fit: f2.fit,
9389
- userValue: f2.userValue,
9390
- garmentRange: f2.garmentRange
9391
- })) || null,
9392
- silhouetteContext: context?.silhouetteContext || null
9393
- });
9394
- if (fitInfo && fitInfo.length > 0) {
9395
- console.log("[ps-sdk:api] Body vs Garment (what Gemini will see):");
9396
- for (const f2 of fitInfo) {
9397
- const parts = (f2.garmentRange || "").replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
9398
- let gap = "";
9399
- if (parts.length && f2.userValue != null) {
9400
- const mid = (Math.min(...parts) + Math.max(...parts)) / 2;
9401
- const diff = Math.round((f2.userValue - mid) * 10) / 10;
9402
- gap = diff > 0 ? `garment ${diff} smaller` : diff < 0 ? `garment ${Math.abs(diff)} larger` : "matched";
9403
- }
9404
- console.log(`[ps-sdk:api] ${f2.area.padEnd(10)} body=${f2.userValue ?? "?"} garment=${f2.garmentRange ?? "?"} → ${gap}`);
9405
- }
9406
- }
9407
- const res = await fetch(`${this.baseUrl}/api/v1/tryon`, {
9408
- method: "POST",
9409
- headers: this.headers,
9410
- body: JSON.stringify(body)
9411
- });
9412
- if (!res.ok) {
9413
- const data = await res.json().catch(() => ({}));
9414
- if (res.status === 402) {
9415
- throw new PrimeStyleError(
9416
- data.message || "Insufficient try-ons",
9417
- "INSUFFICIENT_BALANCE"
9418
- );
9419
- }
9420
- throw new PrimeStyleError(
9421
- data.message || "Failed to submit try-on",
9422
- "API_ERROR"
9423
- );
9424
- }
9425
- return res.json();
9360
+ .ps-tryon-header-minimal {
9361
+ justify-content: flex-end; gap: 0.4vw; padding: 0.4vw 0.8vw;
9426
9362
  }
9427
- async getStatus(jobId) {
9428
- const res = await fetch(`${this.baseUrl}/api/v1/tryon/status/${jobId}`, {
9429
- headers: this.headers
9430
- });
9431
- if (!res.ok) {
9432
- const data = await res.json().catch(() => ({}));
9433
- throw new PrimeStyleError(
9434
- data.message || "Failed to get status",
9435
- "API_ERROR"
9436
- );
9437
- }
9438
- 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;
9439
9368
  }
9440
- getStreamUrl() {
9441
- 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%; }
9442
9380
  }
9443
- }
9444
- class PrimeStyleError extends Error {
9445
- constructor(message, code) {
9446
- super(message);
9447
- this.name = "PrimeStyleError";
9448
- 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;
9449
9389
  }
9450
- }
9451
- class SseClient {
9452
- constructor(streamUrl) {
9453
- this.eventSource = null;
9454
- this.listeners = /* @__PURE__ */ new Map();
9455
- this.reconnectTimer = null;
9456
- this.reconnectAttempts = 0;
9457
- this.maxReconnectAttempts = 5;
9458
- 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;
9459
9400
  }
9460
- connect() {
9461
- if (this.eventSource) return;
9462
- this.eventSource = new EventSource(this.streamUrl);
9463
- this.eventSource.addEventListener("vto-update", (event) => {
9464
- try {
9465
- const data = JSON.parse(event.data);
9466
- this.emit(data.galleryId, data);
9467
- } catch {
9468
- }
9469
- });
9470
- this.eventSource.onopen = () => {
9471
- this.reconnectAttempts = 0;
9472
- };
9473
- this.eventSource.onerror = () => {
9474
- this.eventSource?.close();
9475
- this.eventSource = null;
9476
- this.scheduleReconnect();
9477
- };
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;
9478
9407
  }
9479
- scheduleReconnect() {
9480
- if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
9481
- if (this.listeners.size === 0) return;
9482
- const delay = Math.min(1e3 * 2 ** this.reconnectAttempts, 3e4);
9483
- this.reconnectAttempts++;
9484
- this.reconnectTimer = setTimeout(() => {
9485
- this.connect();
9486
- }, 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);
9487
9420
  }
9488
- onJob(jobId, callback) {
9489
- if (!this.listeners.has(jobId)) {
9490
- this.listeners.set(jobId, /* @__PURE__ */ new Set());
9491
- }
9492
- this.listeners.get(jobId).add(callback);
9493
- if (!this.eventSource) {
9494
- this.connect();
9495
- }
9496
- return () => {
9497
- const jobListeners = this.listeners.get(jobId);
9498
- if (jobListeners) {
9499
- jobListeners.delete(callback);
9500
- if (jobListeners.size === 0) {
9501
- this.listeners.delete(jobId);
9502
- }
9503
- }
9504
- if (this.listeners.size === 0) {
9505
- this.disconnect();
9506
- }
9507
- };
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);
9508
9423
  }
9509
- emit(jobId, update) {
9510
- const callbacks = this.listeners.get(jobId);
9511
- if (callbacks) {
9512
- callbacks.forEach((cb2) => cb2(update));
9513
- }
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;
9514
9427
  }
9515
- disconnect() {
9516
- if (this.reconnectTimer) {
9517
- clearTimeout(this.reconnectTimer);
9518
- this.reconnectTimer = null;
9519
- }
9520
- if (this.eventSource) {
9521
- this.eventSource.close();
9522
- this.eventSource = null;
9523
- }
9524
- this.listeners.clear();
9525
- this.reconnectAttempts = 0;
9428
+ .ps-tryon-lang-arrow {
9429
+ font-size: clamp(10px, 0.57vw, 12px); transition: transform 0.25s ease; display: inline-block;
9526
9430
  }
9527
- }
9528
- const DEFAULT_MAX_DIMENSION = 512;
9529
- const DEFAULT_JPEG_QUALITY = 0.65;
9530
- function compressImage(file, opts = {}) {
9531
- const maxDim = opts.maxDimension ?? DEFAULT_MAX_DIMENSION;
9532
- const quality = opts.quality ?? DEFAULT_JPEG_QUALITY;
9533
- return new Promise((resolve, reject) => {
9534
- const reader = new FileReader();
9535
- reader.onload = () => {
9536
- const img = new Image();
9537
- img.onload = () => {
9538
- try {
9539
- const canvas = document.createElement("canvas");
9540
- let { width, height } = img;
9541
- if (width > maxDim || height > maxDim) {
9542
- if (width > height) {
9543
- height = Math.round(height * maxDim / width);
9544
- width = maxDim;
9545
- } else {
9546
- width = Math.round(width * maxDim / height);
9547
- height = maxDim;
9548
- }
9549
- }
9550
- canvas.width = width;
9551
- canvas.height = height;
9552
- const ctx = canvas.getContext("2d");
9553
- if (!ctx) {
9554
- reject(new Error("Canvas context not available"));
9555
- return;
9556
- }
9557
- ctx.drawImage(img, 0, 0, width, height);
9558
- const dataUrl = canvas.toDataURL("image/jpeg", quality);
9559
- resolve(dataUrl);
9560
- } catch (err) {
9561
- reject(err);
9562
- }
9563
- };
9564
- img.onerror = () => reject(new Error("Failed to load image"));
9565
- img.src = reader.result;
9566
- };
9567
- reader.onerror = () => reject(new Error("Failed to read file"));
9568
- reader.readAsDataURL(file);
9569
- });
9570
- }
9571
- function isValidImageFile(file) {
9572
- const accepted = ["image/jpeg", "image/png", "image/webp", "image/avif"];
9573
- return accepted.includes(file.type);
9574
- }
9575
- async function checkAgeBeforeUpload(imageOrBase64, apiUrl, apiKey) {
9576
- try {
9577
- const base64 = typeof imageOrBase64 === "string" ? imageOrBase64 : await compressImage(imageOrBase64, { maxDimension: 384, quality: 0.6 });
9578
- const resp = await fetch(`${apiUrl}/api/v1/sizing/age-check`, {
9579
- method: "POST",
9580
- headers: {
9581
- "Content-Type": "application/json",
9582
- Authorization: `Bearer ${apiKey}`
9583
- },
9584
- body: JSON.stringify({ bodyImage: base64 })
9585
- });
9586
- if (!resp.ok) {
9587
- console.warn(`[PS-SDK:AgeCheck] HTTP ${resp.status} — failing open`);
9588
- return { isAdult: true, confidence: "low" };
9589
- }
9590
- const data = await resp.json();
9591
- return {
9592
- isAdult: data?.isAdult !== false,
9593
- // anything other than explicit false → allow
9594
- confidence: data?.confidence === "high" ? "high" : "low",
9595
- reasoning: typeof data?.reasoning === "string" ? data.reasoning : void 0
9596
- };
9597
- } catch (err) {
9598
- console.warn(`[PS-SDK:AgeCheck] error — failing open:`, err);
9599
- return { isAdult: true, confidence: "low" };
9600
- }
9601
- }
9602
- const DB_NAME = "primestyle-photos";
9603
- const STORE = "photos";
9604
- const VERSION = 1;
9605
- let _dbPromise = null;
9606
- function openDb() {
9607
- if (_dbPromise) return _dbPromise;
9608
- _dbPromise = new Promise((resolve) => {
9609
- try {
9610
- if (typeof indexedDB === "undefined") return resolve(null);
9611
- const req = indexedDB.open(DB_NAME, VERSION);
9612
- req.onupgradeneeded = () => {
9613
- const db2 = req.result;
9614
- if (!db2.objectStoreNames.contains(STORE)) {
9615
- db2.createObjectStore(STORE);
9616
- }
9617
- };
9618
- req.onsuccess = () => resolve(req.result);
9619
- req.onerror = () => resolve(null);
9620
- req.onblocked = () => resolve(null);
9621
- } catch {
9622
- resolve(null);
9623
- }
9624
- });
9625
- return _dbPromise;
9626
- }
9627
- async function savePhoto(id2, blob) {
9628
- const db2 = await openDb();
9629
- if (!db2) return false;
9630
- return new Promise((resolve) => {
9631
- try {
9632
- const tx = db2.transaction(STORE, "readwrite");
9633
- tx.objectStore(STORE).put(blob, id2);
9634
- tx.oncomplete = () => resolve(true);
9635
- tx.onerror = () => resolve(false);
9636
- tx.onabort = () => resolve(false);
9637
- } catch {
9638
- resolve(false);
9639
- }
9640
- });
9641
- }
9642
- async function getPhoto(id2) {
9643
- const db2 = await openDb();
9644
- if (!db2) return null;
9645
- return new Promise((resolve) => {
9646
- try {
9647
- const tx = db2.transaction(STORE, "readonly");
9648
- const req = tx.objectStore(STORE).get(id2);
9649
- req.onsuccess = () => resolve(req.result instanceof Blob ? req.result : null);
9650
- req.onerror = () => resolve(null);
9651
- } catch {
9652
- resolve(null);
9653
- }
9654
- });
9655
- }
9656
- async function pruneToIds(keepIds) {
9657
- const db2 = await openDb();
9658
- if (!db2) return;
9659
- return new Promise((resolve) => {
9660
- try {
9661
- const tx = db2.transaction(STORE, "readwrite");
9662
- const store = tx.objectStore(STORE);
9663
- const cursorReq = store.openKeyCursor();
9664
- cursorReq.onsuccess = () => {
9665
- const cursor = cursorReq.result;
9666
- if (!cursor) return;
9667
- const key = String(cursor.key);
9668
- if (!keepIds.has(key)) cursor.delete();
9669
- cursor.continue();
9670
- };
9671
- tx.oncomplete = () => resolve();
9672
- tx.onerror = () => resolve();
9673
- } catch {
9674
- resolve();
9675
- }
9676
- });
9677
- }
9678
- const LEFT_SHOULDER = 11;
9679
- const RIGHT_SHOULDER = 12;
9680
- const LEFT_ELBOW = 13;
9681
- const RIGHT_ELBOW = 14;
9682
- const LEFT_WRIST = 15;
9683
- const RIGHT_WRIST = 16;
9684
- const LEFT_HIP = 23;
9685
- const RIGHT_HIP = 24;
9686
- const LEFT_KNEE = 25;
9687
- const RIGHT_KNEE = 26;
9688
- const LEFT_ANKLE = 27;
9689
- const RIGHT_ANKLE = 28;
9690
- const NOSE = 0;
9691
- let poseLandmarker = null;
9692
- let loadingPromise$1 = null;
9693
- async function loadMediaPipe() {
9694
- if (poseLandmarker) return;
9695
- if (loadingPromise$1) return loadingPromise$1;
9696
- loadingPromise$1 = (async () => {
9697
- const vision = await import(
9698
- /* webpackIgnore: true */
9699
- // @ts-ignore dynamic CDN import
9700
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
9701
- );
9702
- const { FilesetResolver, PoseLandmarker } = vision;
9703
- const filesetResolver = await FilesetResolver.forVisionTasks(
9704
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
9705
- );
9706
- poseLandmarker = await PoseLandmarker.createFromOptions(filesetResolver, {
9707
- baseOptions: {
9708
- modelAssetPath: "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task",
9709
- delegate: "GPU"
9710
- },
9711
- runningMode: "IMAGE",
9712
- numPoses: 1
9713
- });
9714
- })();
9715
- return loadingPromise$1;
9716
- }
9717
- async function detectMeasurementLines(imageSrc) {
9718
- try {
9719
- await loadMediaPipe();
9720
- const img = await loadImage$1(imageSrc);
9721
- const result = poseLandmarker.detect(img);
9722
- if (!result?.landmarks?.length || result.landmarks[0].length < 25) {
9723
- return null;
9724
- }
9725
- const landmarks = result.landmarks[0];
9726
- const lShoulder = landmarks[LEFT_SHOULDER];
9727
- const rShoulder = landmarks[RIGHT_SHOULDER];
9728
- const lHip = landmarks[LEFT_HIP];
9729
- const rHip = landmarks[RIGHT_HIP];
9730
- const shoulderY = (lShoulder.y + rShoulder.y) / 2;
9731
- const hipY = (lHip.y + rHip.y) / 2;
9732
- const torsoLen = hipY - shoulderY;
9733
- const chestY = shoulderY + torsoLen * 0.28;
9734
- const shoulderWidth = Math.abs(rShoulder.x - lShoulder.x);
9735
- const chestX1 = Math.min(lShoulder.x, rShoulder.x) - shoulderWidth * 0.05;
9736
- const chestX2 = Math.max(lShoulder.x, rShoulder.x) + shoulderWidth * 0.05;
9737
- const hipWidth = Math.abs(rHip.x - lHip.x);
9738
- const hipX1 = Math.min(lHip.x, rHip.x) - hipWidth * 0.15;
9739
- const hipX2 = Math.max(lHip.x, rHip.x) + hipWidth * 0.15;
9740
- const waistY = chestY + (hipY - chestY) * 0.55;
9741
- const waistWidth = shoulderWidth * 0.85;
9742
- const waistCenterX = (lShoulder.x + rShoulder.x + lHip.x + rHip.x) / 4;
9743
- const waistX1 = waistCenterX - waistWidth / 2;
9744
- const waistX2 = waistCenterX + waistWidth / 2;
9745
- return {
9746
- chest: { y: chestY, x1: chestX1, x2: chestX2 },
9747
- waist: { y: waistY, x1: waistX1, x2: waistX2 },
9748
- hips: { y: hipY, x1: hipX1, x2: hipX2 }
9749
- };
9750
- } catch (err) {
9751
- console.error("[PS-SDK] Pose detection failed:", err);
9752
- return null;
9753
- }
9754
- }
9755
- function loadImage$1(src) {
9756
- return new Promise((resolve, reject) => {
9757
- const img = new Image();
9758
- img.crossOrigin = "anonymous";
9759
- img.onload = () => resolve(img);
9760
- img.onerror = () => {
9761
- const img2 = new Image();
9762
- img2.onload = () => resolve(img2);
9763
- img2.onerror = () => reject(new Error("Failed to load image"));
9764
- img2.src = src;
9765
- };
9766
- img.src = src;
9767
- });
9768
- }
9769
- async function downsampleIfNeeded(img, maxLongEdge = 1024) {
9770
- const w2 = img.naturalWidth || img.width;
9771
- const h = img.naturalHeight || img.height;
9772
- const longEdge = Math.max(w2, h);
9773
- if (longEdge <= maxLongEdge) return img;
9774
- const scale = maxLongEdge / longEdge;
9775
- const newW = Math.round(w2 * scale);
9776
- const newH = Math.round(h * scale);
9777
- const canvas = document.createElement("canvas");
9778
- canvas.width = newW;
9779
- canvas.height = newH;
9780
- const ctx = canvas.getContext("2d");
9781
- if (!ctx) return img;
9782
- ctx.imageSmoothingQuality = "high";
9783
- ctx.drawImage(img, 0, 0, newW, newH);
9784
- console.log(`[PS-SDK:Downsample] ${w2}×${h} → ${newW}×${newH} (max long edge ${maxLongEdge}px)`);
9785
- return canvas;
9786
- }
9787
- async function detectBodyLandmarks(imageSrc) {
9788
- try {
9789
- await loadMediaPipe();
9790
- let img;
9791
- if (typeof imageSrc === "string") {
9792
- img = await loadImage$1(imageSrc);
9793
- } else {
9794
- img = imageSrc;
9795
- }
9796
- const inputForDetect = await downsampleIfNeeded(img, 1024);
9797
- const workingW = inputForDetect.width || img.naturalWidth || img.width;
9798
- const workingH = inputForDetect.height || img.naturalHeight || img.height;
9799
- const result = poseLandmarker.detect(inputForDetect);
9800
- if (!result?.landmarks?.length || result.landmarks[0].length < 29) {
9801
- console.warn("[PS-SDK:MediaPipe] detection returned no usable landmarks", {
9802
- gotLandmarks: !!result?.landmarks?.length,
9803
- jointCount: result?.landmarks?.[0]?.length ?? 0
9804
- });
9805
- return null;
9806
- }
9807
- const lm = result.landmarks[0];
9808
- const wlm = result.worldLandmarks?.[0] ?? null;
9809
- const pickPoint = (idx, useWorld = false) => {
9810
- const src = useWorld && wlm ? wlm[idx] : lm[idx];
9811
- const out2 = { x: src.x, y: src.y };
9812
- if (typeof src.z === "number") out2.z = src.z;
9813
- if (typeof src.visibility === "number") out2.visibility = src.visibility;
9814
- return out2;
9815
- };
9816
- const out = {
9817
- leftShoulder: pickPoint(LEFT_SHOULDER),
9818
- rightShoulder: pickPoint(RIGHT_SHOULDER),
9819
- leftHip: pickPoint(LEFT_HIP),
9820
- rightHip: pickPoint(RIGHT_HIP),
9821
- leftElbow: pickPoint(LEFT_ELBOW),
9822
- rightElbow: pickPoint(RIGHT_ELBOW),
9823
- leftWrist: pickPoint(LEFT_WRIST),
9824
- rightWrist: pickPoint(RIGHT_WRIST),
9825
- leftKnee: pickPoint(LEFT_KNEE),
9826
- rightKnee: pickPoint(RIGHT_KNEE),
9827
- leftAnkle: pickPoint(LEFT_ANKLE),
9828
- rightAnkle: pickPoint(RIGHT_ANKLE),
9829
- nose: pickPoint(NOSE),
9830
- imageWidth: workingW,
9831
- imageHeight: workingH
9832
- };
9833
- const isJointPoint = (v2) => !!v2 && typeof v2 === "object" && typeof v2.x === "number" && typeof v2.y === "number";
9834
- const jointCount = Object.values(out).filter(isJointPoint).length;
9835
- const hasZ = lm.some((p2) => typeof p2.z === "number");
9836
- const hasWorld = !!wlm;
9837
- console.groupCollapsed(`[PS-SDK:MediaPipe] ✓ ${jointCount} joints detected @ ${out.imageWidth}×${out.imageHeight}px (z=${hasZ ? "yes" : "no"}, worldLandmarks=${hasWorld ? "yes" : "no"})`);
9838
- for (const [name, v2] of Object.entries(out)) {
9839
- if (!isJointPoint(v2)) continue;
9840
- const p2 = v2;
9841
- const z2 = typeof p2.z === "number" ? `, z=${p2.z.toFixed(3)}` : "";
9842
- const vis = typeof p2.visibility === "number" ? `, vis=${p2.visibility.toFixed(2)}` : "";
9843
- console.log(` ${name.padEnd(15)} (${p2.x.toFixed(3)}, ${p2.y.toFixed(3)}${z2}${vis})`);
9844
- }
9845
- console.log(` imageWidth ${out.imageWidth}`);
9846
- console.log(` imageHeight ${out.imageHeight}`);
9847
- console.groupEnd();
9848
- return out;
9849
- } catch (err) {
9850
- console.error("[PS-SDK:MediaPipe] Body landmark detection failed:", err);
9851
- return null;
9852
- }
9853
- }
9854
- const IDX = {
9855
- noseTip: 1,
9856
- noseBridge: 168,
9857
- leftInnerEye: 133,
9858
- rightInnerEye: 362,
9859
- leftOuterEye: 33,
9860
- rightOuterEye: 263,
9861
- // Iris landmarks (478-point model with iris refinement)
9862
- leftIrisCenter: 468,
9863
- leftIrisRing: [469, 470, 471, 472],
9864
- rightIrisCenter: 473,
9865
- rightIrisRing: [474, 475, 476, 477],
9866
- // Tragus (ear attach point) — best approximations in the face mesh
9867
- leftTragus: 234,
9868
- rightTragus: 454,
9869
- forehead: 10,
9870
- chin: 152,
9871
- leftMouth: 61,
9872
- rightMouth: 291
9873
- };
9874
- const IRIS_DIAMETER_MM = 11.7;
9875
- let faceLandmarker = null;
9876
- let loadingPromise = null;
9877
- async function loadFaceLandmarker() {
9878
- if (faceLandmarker) {
9879
- console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — already loaded, reusing");
9880
- return;
9881
- }
9882
- if (loadingPromise) {
9883
- console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — awaiting in-flight load");
9884
- return loadingPromise;
9885
- }
9886
- console.log("[PS-SDK:FaceDetect] loadFaceLandmarker — starting fresh init");
9887
- loadingPromise = (async () => {
9888
- try {
9889
- console.log("[PS-SDK:FaceDetect] importing vision_bundle.mjs from CDN…");
9890
- const t0 = performance.now();
9891
- const vision = await import(
9892
- /* webpackIgnore: true */
9893
- // @ts-ignore dynamic CDN import
9894
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/vision_bundle.mjs"
9895
- );
9896
- console.log(`[PS-SDK:FaceDetect] vision bundle imported in ${Math.round(performance.now() - t0)}ms`);
9897
- const { FilesetResolver, FaceLandmarker } = vision;
9898
- if (!FilesetResolver || !FaceLandmarker) {
9899
- console.error("[PS-SDK:FaceDetect] vision bundle missing expected exports", { keys: Object.keys(vision) });
9900
- throw new Error("MediaPipe vision bundle returned no FilesetResolver/FaceLandmarker");
9901
- }
9902
- console.log("[PS-SDK:FaceDetect] resolving WASM filesets…");
9903
- const t1 = performance.now();
9904
- const filesetResolver = await FilesetResolver.forVisionTasks(
9905
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.33/wasm"
9906
- );
9907
- console.log(`[PS-SDK:FaceDetect] WASM resolver ready in ${Math.round(performance.now() - t1)}ms`);
9908
- console.log("[PS-SDK:FaceDetect] creating FaceLandmarker (GPU delegate, model=latest)…");
9909
- const t2 = performance.now();
9910
- faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
9911
- baseOptions: {
9912
- modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task",
9913
- delegate: "GPU"
9914
- },
9915
- runningMode: "IMAGE",
9916
- numFaces: 1,
9917
- outputFaceBlendshapes: false,
9918
- outputFacialTransformationMatrixes: false
9919
- });
9920
- console.log(`[PS-SDK:FaceDetect] FaceLandmarker ready in ${Math.round(performance.now() - t2)}ms`);
9921
- } catch (err) {
9922
- console.error("[PS-SDK:FaceDetect] loadFaceLandmarker FAILED:", err);
9923
- loadingPromise = null;
9924
- throw err;
9925
- }
9926
- })();
9927
- return loadingPromise;
9928
- }
9929
- function loadImage(src) {
9930
- return new Promise((resolve, reject) => {
9931
- const img = new Image();
9932
- img.crossOrigin = "anonymous";
9933
- img.onload = () => {
9934
- console.log("[PS-SDK:FaceDetect] loadImage — primary (crossOrigin) succeeded", { w: img.naturalWidth, h: img.naturalHeight });
9935
- resolve(img);
9936
- };
9937
- img.onerror = () => {
9938
- console.warn("[PS-SDK:FaceDetect] loadImage — primary (crossOrigin) failed, retrying without crossOrigin");
9939
- const img2 = new Image();
9940
- img2.onload = () => {
9941
- console.log("[PS-SDK:FaceDetect] loadImage — fallback succeeded", { w: img2.naturalWidth, h: img2.naturalHeight });
9942
- resolve(img2);
9943
- };
9944
- img2.onerror = () => {
9945
- console.error("[PS-SDK:FaceDetect] loadImage — both attempts failed for src:", src.slice(0, 120));
9946
- reject(new Error("Failed to load image"));
9947
- };
9948
- img2.src = src;
9949
- };
9950
- img.src = src;
9951
- });
9952
- }
9953
- function irisDiameterPx(ring, imageWidth, imageHeight) {
9954
- if (ring.length < 4) return 0;
9955
- const toPx = (p4) => ({ x: p4.x * imageWidth, y: p4.y * imageHeight });
9956
- const [p0, p1, p2, p3] = ring.map(toPx);
9957
- const d1 = Math.hypot(p0.x - p2.x, p0.y - p2.y);
9958
- const d2 = Math.hypot(p1.x - p3.x, p1.y - p3.y);
9959
- return (d1 + d2) / 2;
9960
- }
9961
- function extractLandmarks(raw) {
9962
- if (raw.length < 478) {
9963
- 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.`);
9964
- return null;
9965
- }
9966
- const at = (i) => ({ x: raw[i].x, y: raw[i].y, z: raw[i].z });
9967
- return {
9968
- noseTip: at(IDX.noseTip),
9969
- noseBridge: at(IDX.noseBridge),
9970
- leftInnerEye: at(IDX.leftInnerEye),
9971
- rightInnerEye: at(IDX.rightInnerEye),
9972
- leftOuterEye: at(IDX.leftOuterEye),
9973
- rightOuterEye: at(IDX.rightOuterEye),
9974
- leftIrisCenter: at(IDX.leftIrisCenter),
9975
- rightIrisCenter: at(IDX.rightIrisCenter),
9976
- leftIrisRing: IDX.leftIrisRing.map(at),
9977
- rightIrisRing: IDX.rightIrisRing.map(at),
9978
- leftTragus: at(IDX.leftTragus),
9979
- rightTragus: at(IDX.rightTragus),
9980
- forehead: at(IDX.forehead),
9981
- chin: at(IDX.chin),
9982
- leftMouth: at(IDX.leftMouth),
9983
- rightMouth: at(IDX.rightMouth)
9984
- };
9985
- }
9986
- function computeMeasurements(lm, imageWidth, imageHeight) {
9987
- const leftPx = irisDiameterPx(lm.leftIrisRing, imageWidth, imageHeight);
9988
- const rightPx = irisDiameterPx(lm.rightIrisRing, imageWidth, imageHeight);
9989
- const irisPx = (leftPx + rightPx) / 2;
9990
- let irisConfidence = 1;
9991
- if (irisPx < 8) irisConfidence = 0.3;
9992
- else if (Math.abs(leftPx - rightPx) / irisPx > 0.3) irisConfidence = 0.5;
9993
- else if (Math.abs(leftPx - rightPx) / irisPx > 0.15) irisConfidence = 0.8;
9994
- const pxToMm = irisPx > 0 ? IRIS_DIAMETER_MM / irisPx : 0;
9995
- const mmBetween = (a2, b2) => {
9996
- const pxDist = Math.hypot(
9997
- (a2.x - b2.x) * imageWidth,
9998
- (a2.y - b2.y) * imageHeight
9999
- );
10000
- return pxDist * pxToMm;
10001
- };
10002
- const pd2 = mmBetween(lm.leftIrisCenter, lm.rightIrisCenter);
10003
- const innerEyeGap = mmBetween(lm.leftInnerEye, lm.rightInnerEye);
10004
- const bridgeWidth = innerEyeGap / 1.6;
10005
- const faceWidth = mmBetween(lm.leftTragus, lm.rightTragus);
10006
- const templeLengthLeft = mmBetween(lm.leftTragus, lm.leftOuterEye);
10007
- const templeLengthRight = mmBetween(lm.rightTragus, lm.rightOuterEye);
10008
- const templeLength = (templeLengthLeft + templeLengthRight) / 2;
10009
- const headWidth = faceWidth * 1.07;
10010
- const zDepthNorm = Math.abs((lm.forehead.z ?? 0) - (lm.chin.z ?? 0));
10011
- const rawHeadDepthMm = zDepthNorm * imageWidth * pxToMm;
10012
- const headDepth = Math.max(170, Math.min(210, rawHeadDepthMm || 190));
10013
- const a = headWidth / 2;
10014
- const b = headDepth / 2;
10015
- const headCircumference = Math.PI * Math.sqrt(2 * (a * a + b * b));
10016
- return {
10017
- measurements: {
10018
- irisDiameter: IRIS_DIAMETER_MM,
10019
- pd: round1(pd2),
10020
- bridgeWidth: round1(bridgeWidth),
10021
- faceWidth: round1(faceWidth),
10022
- templeLengthLeft: round1(templeLengthLeft),
10023
- templeLengthRight: round1(templeLengthRight),
10024
- templeLength: round1(templeLength),
10025
- headWidth: round1(headWidth),
10026
- headDepth: round1(headDepth),
10027
- headCircumference: round1(headCircumference)
10028
- },
10029
- irisConfidence
10030
- };
10031
- }
10032
- function round1(n2) {
10033
- return Math.round(n2 * 10) / 10;
10034
- }
10035
- async function detectFaceMeasurements(imageSrc) {
10036
- console.log("[PS-SDK:FaceDetect] detectFaceMeasurements called", {
10037
- srcType: typeof imageSrc === "string" ? imageSrc.startsWith("blob:") ? "blob-url" : imageSrc.startsWith("data:") ? "data-url" : "other" : "HTMLImageElement",
10038
- srcLength: typeof imageSrc === "string" ? imageSrc.length : "N/A"
10039
- });
10040
- const tStart = performance.now();
10041
- try {
10042
- console.log("[PS-SDK:FaceDetect] step 1/4 — loading FaceLandmarker");
10043
- await loadFaceLandmarker();
10044
- console.log("[PS-SDK:FaceDetect] step 2/4 — loading image");
10045
- const img = typeof imageSrc === "string" ? await loadImage(imageSrc) : imageSrc;
10046
- const imageWidth = img.naturalWidth || img.width;
10047
- const imageHeight = img.naturalHeight || img.height;
10048
- console.log("[PS-SDK:FaceDetect] step 3/4 — running faceLandmarker.detect()", { imageWidth, imageHeight });
10049
- const tDetect = performance.now();
10050
- const result = faceLandmarker.detect(img);
10051
- const detectMs = Math.round(performance.now() - tDetect);
10052
- const faceCount = result?.faceLandmarks?.length ?? 0;
10053
- console.log(`[PS-SDK:FaceDetect] detect() returned in ${detectMs}ms — faces: ${faceCount}`);
10054
- if (!faceCount) {
10055
- console.warn("[PS-SDK:FaceDetect] NO FACES detected — returning null");
10056
- return null;
10057
- }
10058
- const raw = result.faceLandmarks[0];
10059
- console.log(`[PS-SDK:FaceDetect] first face has ${raw.length} landmarks`);
10060
- const landmarks = extractLandmarks(raw);
10061
- if (!landmarks) {
10062
- return null;
10063
- }
10064
- console.log("[PS-SDK:FaceDetect] step 4/4 — computing measurements");
10065
- const { measurements, irisConfidence } = computeMeasurements(landmarks, imageWidth, imageHeight);
10066
- console.log(`[PS-SDK:FaceDetect] ✓ DONE in ${Math.round(performance.now() - tStart)}ms`, { irisConfidence, measurements });
10067
- return { landmarks, measurementsMm: measurements, irisConfidence, imageWidth, imageHeight };
10068
- } catch (err) {
10069
- console.error("[PS-SDK:FaceDetect] detectFaceMeasurements FAILED:", err);
10070
- return null;
10071
- }
10072
- }
10073
- function parseRange(s) {
10074
- const ns = s.replace(/[^\d.\-–]/g, " ").trim().split(/[\s\-–]+/).filter(Boolean).map(Number).filter((n2) => !isNaN(n2));
10075
- return ns.length ? { min: Math.min(...ns), max: Math.max(...ns) } : { min: 0, max: 0 };
10076
- }
10077
- function parseNum(s) {
10078
- const n2 = parseFloat(s.replace(/[^\d.]/g, ""));
10079
- return isNaN(n2) ? 0 : n2;
10080
- }
10081
- function computeFit(userValue, chartRange, unit) {
10082
- const { min: rMin, max: rMax } = parseRange(chartRange);
10083
- if (rMin === 0 && rMax === 0) return "good";
10084
- const range = rMax - rMin;
10085
- const threshold = range > 0 ? range * 0.5 : rMin * 0.05 || 3;
10086
- const perfectTol = unit === "cm" ? 2.54 : unit === "mm" ? 25.4 : 1;
10087
- if (userValue > rMin - perfectTol && userValue < rMax + perfectTol) return "good";
10088
- if (userValue < rMin) {
10089
- const diff2 = rMin - userValue;
10090
- if (diff2 > threshold * 2) return "too-loose";
10091
- if (diff2 > threshold) return "loose";
10092
- return "a-bit-loose";
10093
- }
10094
- const diff = userValue - rMax;
10095
- if (diff > threshold * 2) return "too-tight";
10096
- if (diff > threshold) return "tight";
10097
- return "a-bit-tight";
10098
- }
10099
- const AREA_TO_POSE_KEY = {
10100
- chest: "chest",
10101
- bust: "chest",
10102
- waist: "waist",
10103
- hips: "hips",
10104
- hip: "hips"
10105
- };
10106
- const SKIP_AREAS_FOR_FIT = /* @__PURE__ */ new Set([
10107
- "height",
10108
- "altezza",
10109
- "estatura"
10110
- ]);
10111
- function buildFitInfo(matchDetails, poseLines, unit) {
10112
- return matchDetails.filter((m2) => !SKIP_AREAS_FOR_FIT.has(m2.measurement.toLowerCase().replace(/\s*\(.*?\)\s*/g, "").trim())).map((m2) => {
10113
- const userNum = parseNum(m2.userValue);
10114
- const fit = computeFit(userNum, m2.chartRange, unit);
10115
- const info = {
10116
- area: m2.measurement,
10117
- section: m2.section || void 0,
10118
- fit,
10119
- userValue: userNum || void 0,
10120
- garmentRange: m2.chartRange || void 0
10121
- };
10122
- if (poseLines) {
10123
- const poseKey = AREA_TO_POSE_KEY[m2.measurement.toLowerCase()];
10124
- if (poseKey && poseLines[poseKey]) {
10125
- const line = poseLines[poseKey];
10126
- info.y = Math.round(line.y * 1e3) / 1e3;
10127
- info.x1 = Math.round(line.x1 * 1e3) / 1e3;
10128
- info.x2 = Math.round(line.x2 * 1e3) / 1e3;
10129
- }
10130
- }
10131
- return info;
10132
- });
10133
- }
10134
- function buildSilhouetteContext(sizingResult, sizeGuide, selectedSizeOverride) {
10135
- if (!sizingResult && !sizeGuide) return void 0;
10136
- const out = {};
10137
- if (selectedSizeOverride) out.recommendedSize = selectedSizeOverride;
10138
- else if (sizingResult?.recommendedSize) out.recommendedSize = sizingResult.recommendedSize;
10139
- const seen = /* @__PURE__ */ new Set();
10140
- const userLines = [];
10141
- const push = (md2) => {
10142
- if (!md2) return;
10143
- for (const m2 of md2) {
10144
- const k2 = m2.measurement.toLowerCase();
10145
- if (seen.has(k2)) continue;
10146
- seen.add(k2);
10147
- if (m2.userValue) userLines.push(`${m2.measurement} ${m2.userValue}`);
10148
- }
10149
- };
10150
- push(sizingResult?.matchDetails);
10151
- if (sizingResult?.sections) {
10152
- for (const sec of Object.values(sizingResult.sections)) push(sec.matchDetails);
10153
- }
10154
- if (userLines.length) out.userMeasurementsText = userLines.join(", ");
10155
- if (sizeGuide?.headers?.length && sizeGuide.rows?.length) {
10156
- const hdrs = sizeGuide.headers;
10157
- const rowStrings = sizeGuide.rows.slice(0, 20).map(
10158
- (row) => hdrs.map((h, i) => `${h} ${row[i] ?? "—"}`).join(", ")
10159
- );
10160
- out.sizeChartSummary = rowStrings.join(" | ");
10161
- }
10162
- return Object.keys(out).length ? out : void 0;
10163
- }
10164
- const FALLBACK_FIELDS_FEMALE = [
10165
- { key: "bust", label: "Bust", required: true, unit: "cm", placeholder: "e.g. 88", category: "body" },
10166
- { key: "waist", label: "Waist", required: true, unit: "cm", placeholder: "e.g. 70", category: "body" },
10167
- { key: "hips", label: "Hips", required: true, unit: "cm", placeholder: "e.g. 96", category: "body" },
10168
- { key: "shoulderWidth", label: "Shoulders", required: false, unit: "cm", placeholder: "e.g. 39", category: "body" },
10169
- { key: "inseam", label: "Inseam", required: false, unit: "cm", placeholder: "e.g. 76", category: "body" },
10170
- { key: "footLengthCm", label: "Foot length", required: false, unit: "cm", placeholder: "e.g. 24", category: "shoe" }
10171
- ];
10172
- const FALLBACK_FIELDS_MALE = [
10173
- { key: "chest", label: "Chest", required: true, unit: "cm", placeholder: "e.g. 104", category: "body" },
10174
- { key: "waist", label: "Waist", required: true, unit: "cm", placeholder: "e.g. 84", category: "body" },
10175
- { key: "shoulderWidth", label: "Shoulders", required: false, unit: "cm", placeholder: "e.g. 46", category: "body" },
10176
- { key: "sleeveLength", label: "Sleeve", required: false, unit: "cm", placeholder: "e.g. 64", category: "body" },
10177
- { key: "inseam", label: "Inseam", required: false, unit: "cm", placeholder: "e.g. 81", category: "body" },
10178
- { key: "footLengthCm", label: "Foot length", required: false, unit: "cm", placeholder: "e.g. 27", category: "shoe" }
10179
- ];
10180
- const TOTAL_STEPS = 3;
10181
- const LS_PREFIX = "primestyle_";
10182
- const PROFILES_KEY = "profiles";
10183
- const ACTIVE_PROFILE_KEY = "active_profile_id";
10184
- const PS_STORAGE_CHANGE_EVENT = "ps:storage-change";
10185
- function emitStorageChange(reason) {
10186
- if (typeof window === "undefined") return;
10187
- try {
10188
- window.dispatchEvent(new CustomEvent(PS_STORAGE_CHANGE_EVENT, { detail: { reason } }));
10189
- } catch {
10190
- }
10191
- }
10192
- function lsGet(key, fallback) {
10193
- if (typeof window === "undefined") return fallback;
10194
- try {
10195
- const raw = localStorage.getItem(LS_PREFIX + key);
10196
- return raw ? JSON.parse(raw) : fallback;
10197
- } catch {
10198
- return fallback;
10199
- }
10200
- }
10201
- function lsSet(key, value) {
10202
- try {
10203
- localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
10204
- } catch {
10205
- }
10206
- }
10207
- function getProfiles() {
10208
- return lsGet(PROFILES_KEY, []);
10209
- }
10210
- function saveProfiles(profiles) {
10211
- lsSet(PROFILES_KEY, profiles);
10212
- emitStorageChange("profiles");
10213
- }
10214
- function getActiveProfileId() {
10215
- return lsGet(ACTIVE_PROFILE_KEY, null);
10216
- }
10217
- function setActiveProfileId(id2) {
10218
- lsSet(ACTIVE_PROFILE_KEY, id2);
10219
- emitStorageChange("active-profile");
10220
- }
10221
- function getActiveProfile() {
10222
- const profiles = getProfiles();
10223
- if (profiles.length === 0) return null;
10224
- const activeId = getActiveProfileId();
10225
- if (activeId) {
10226
- const found = profiles.find((p2) => p2.id === activeId);
10227
- if (found) return found;
10228
- }
10229
- const sorted = [...profiles].sort(
10230
- (a, b) => (b.lastUsedAt || b.createdAt || 0) - (a.lastUsedAt || a.createdAt || 0)
10231
- );
10232
- return sorted[0] || null;
10233
- }
10234
- function updateProfile(id2, patch) {
10235
- const profiles = getProfiles();
10236
- const idx = profiles.findIndex((p2) => p2.id === id2);
10237
- if (idx < 0) return null;
10238
- const updated = { ...profiles[idx], ...patch, lastEditedAt: Date.now() };
10239
- profiles[idx] = updated;
10240
- saveProfiles(profiles);
10241
- return updated;
10242
- }
10243
- function updateProfileMeasurements(id2, measurements, unit = "cm") {
10244
- return updateProfile(id2, { measurements, measurementsUnit: unit });
10245
- }
10246
- function addSizeToHistory(profileId, entry) {
10247
- const profiles = getProfiles();
10248
- const idx = profiles.findIndex((p2) => p2.id === profileId);
10249
- if (idx < 0) return null;
10250
- const profile = profiles[idx];
10251
- const history = (profile.sizeHistory || []).filter(
10252
- (h) => h.productId !== entry.productId
10253
- );
10254
- history.unshift(entry);
10255
- const trimmed = history.slice(0, 50);
10256
- profiles[idx] = {
10257
- ...profile,
10258
- sizeHistory: trimmed,
10259
- lastUsedAt: Date.now()
10260
- };
10261
- saveProfiles(profiles);
10262
- return profiles[idx];
10263
- }
10264
- function detectLocale() {
10265
- if (typeof navigator === "undefined") return "US";
10266
- const lang = navigator.language || "";
10267
- const region = lang.split("-")[1]?.toUpperCase();
10268
- if (region === "GB") return "UK";
10269
- if (region) return region;
10270
- const map = {
10271
- en: "US",
10272
- ja: "JP",
10273
- ko: "KR",
10274
- zh: "CN",
10275
- fr: "FR",
10276
- it: "IT",
10277
- de: "DE",
10278
- es: "ES",
10279
- pt: "BR"
10280
- };
10281
- return map[lang.split("-")[0].toLowerCase()] || "US";
10282
- }
10283
- function getApiKey() {
10284
- let key = "";
10285
- try {
10286
- key = "shopify-proxy";
10287
- } catch {
10288
- }
10289
- if (!key) throw new PrimeStyleError("Missing NEXT_PUBLIC_PRIMESTYLE_API_KEY", "MISSING_API_KEY");
10290
- return key;
10291
- }
10292
- function getApiUrl(override) {
10293
- if (override) return override;
10294
- let envUrl = "";
10295
- try {
10296
- envUrl = "";
10297
- } catch {
10298
- }
10299
- return envUrl || "http://localhost:4000";
10300
- }
10301
- let cachedMP = null;
10302
- const MP_CACHE_TTL_MS = 6e4;
10303
- function setCachedMediaPipe(landmarks) {
10304
- cachedMP = { landmarks, storedAt: Date.now() };
10305
- }
10306
- function getCachedMediaPipe() {
10307
- if (!cachedMP) return null;
10308
- if (Date.now() - cachedMP.storedAt > MP_CACHE_TTL_MS) {
10309
- cachedMP = null;
10310
- return null;
10311
- }
10312
- return cachedMP;
10313
- }
10314
- async function recommendForProduct(input) {
10315
- const log = (...args) => console.log("[ps-sdk:recommend]", ...args);
10316
- const profile = input.profile ?? getActiveProfile();
10317
- if (!profile) {
10318
- log("no active profile — returning null");
10319
- return null;
10320
- }
10321
- log("start", {
10322
- productId: input.productId,
10323
- profileId: profile.id,
10324
- profileName: profile.name,
10325
- hasMeasurements: !!profile.measurements && Object.keys(profile.measurements || {}).length,
10326
- measurementsCount: Object.keys(profile.measurements || {}).length
10327
- });
10328
- log("cache MISS — calling backend");
10329
- let apiKey;
10330
- try {
10331
- apiKey = input.apiKey ?? getApiKey();
10332
- } catch (e) {
10333
- log("no api key — aborting", e);
10334
- return null;
10335
- }
10336
- const apiUrl = (input.apiUrl ?? getApiUrl()).replace(/\/+$/, "");
10337
- if (!apiKey) {
10338
- log("no api key — aborting");
10339
- return null;
10340
- }
10341
- let sizeGuide = null;
10342
- if (input.sizeGuideData != null) {
10343
- try {
10344
- const sgRes = await fetch(`${apiUrl}/api/v1/sizing/sizeguide`, {
10345
- method: "POST",
10346
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
10347
- body: JSON.stringify({
10348
- product: { title: input.productTitle, productId: input.productId },
10349
- sizeGuideRaw: input.sizeGuideData
10350
- })
10351
- });
10352
- if (sgRes.ok) {
10353
- sizeGuide = await sgRes.json();
10354
- log("sizeguide OK", { found: sizeGuide?.found, sectionCount: Object.keys(sizeGuide?.sections || {}).length });
10355
- } else {
10356
- log("sizeguide FAILED", sgRes.status, sgRes.statusText);
10357
- }
10358
- } catch (e) {
10359
- log("sizeguide threw", e);
10360
- }
10361
- } else {
10362
- log("no sizeGuideData provided");
10363
- }
10364
- const measurements = {
10365
- gender: profile.gender,
10366
- sizingUnit: profile.measurementsUnit || "cm"
10367
- };
10368
- if (profile.measurements) {
10369
- for (const [key, value] of Object.entries(profile.measurements)) {
10370
- if (value != null) measurements[key] = value;
10371
- }
10372
- }
10373
- if (profile.height != null) measurements.height = profile.height;
10374
- if (profile.weight != null) measurements.weight = profile.weight;
10375
- if (profile.heightUnit) measurements.heightUnit = profile.heightUnit;
10376
- if (profile.weightUnit) measurements.weightUnit = profile.weightUnit;
10377
- if (profile.age) measurements.age = profile.age;
10378
- if (profile.chestProfile) measurements.chestProfile = profile.chestProfile;
10379
- if (profile.midsectionProfile) measurements.midsectionProfile = profile.midsectionProfile;
10380
- if (profile.hipProfile) measurements.hipProfile = profile.hipProfile;
10381
- const payload = {
10382
- method: "exact",
10383
- locale: profile.country || "US",
10384
- sizingUnit: profile.measurementsUnit || profile.sizingUnit || "cm",
10385
- product: { title: input.productTitle, productId: input.productId, description: "", variants: [] },
10386
- measurements
10387
- };
10388
- if (profile.gender === "female" && profile.bandSize && profile.cupSize) {
10389
- const region = profile.braRegion ?? profile.braSizeRegion ?? "US";
10390
- payload.braSize = {
10391
- band: parseFloat(String(profile.bandSize)),
10392
- cup: String(profile.cupSize),
10393
- region
10394
- };
10395
- }
10396
- if (profile.knownMeasurements) {
10397
- payload.knownMeasurements = profile.knownMeasurements;
10398
- }
10399
- if (sizeGuide && sizeGuide.found) {
10400
- payload.sizeGuide = sizeGuide;
10401
- }
10402
- log("calling /sizing/recommend", { measurements: Object.keys(measurements), hasSizeGuide: !!payload.sizeGuide });
10403
- let result = null;
10404
- try {
10405
- const res = await fetch(`${apiUrl}/api/v1/sizing/recommend`, {
10406
- method: "POST",
10407
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
10408
- body: JSON.stringify(payload)
10409
- });
10410
- if (!res.ok) {
10411
- log("recommend FAILED", res.status, res.statusText);
10412
- return null;
10413
- }
10414
- result = await res.json();
10415
- log("recommend OK", {
10416
- recommendedSize: result?.recommendedSize,
10417
- sectionKeys: result?.sections ? Object.keys(result.sections) : null,
10418
- sections: result?.sections
10419
- });
10420
- } catch (e) {
10421
- log("recommend threw", e);
10422
- return null;
10423
- }
10424
- if (!result || !result.recommendedSize) {
10425
- log("recommend returned no recommendedSize — bailing");
10426
- return null;
10427
- }
10428
- const sectionsMap = result.sections ? Object.fromEntries(
10429
- Object.entries(result.sections).map(([name, sec]) => [name, sec.recommendedSize])
10430
- ) : void 0;
10431
- const sectionsFull = result.sections ? Object.fromEntries(
10432
- Object.entries(result.sections).map(([name, sec]) => [
10433
- name,
10434
- {
10435
- recommendedSize: sec.recommendedSize,
10436
- // Backend may include these on merged Jacket/Pants entries
10437
- size: sec.size,
10438
- length: sec.length,
10439
- availableSizes: sec.availableSizes,
10440
- availableLengths: sec.availableLengths
10441
- }
10442
- ])
10443
- ) : void 0;
10444
- addSizeToHistory(profile.id, {
10445
- productId: input.productId,
10446
- productTitle: input.productTitle,
10447
- productImage: input.productImage,
10448
- recommendedSize: result.recommendedSize,
10449
- confidence: result.confidence,
10450
- sections: sectionsMap,
10451
- sectionsFull,
10452
- recommendedLength: result.recommendedLength || void 0,
10453
- savedAt: Date.now()
10454
- });
10455
- return {
10456
- recommendedSize: result.recommendedSize,
10457
- confidence: result.confidence,
10458
- sections: sectionsMap,
10459
- profileId: profile.id,
10460
- fromCache: false,
10461
- raw: result,
10462
- found: result.found
10463
- };
10464
- }
10465
- async function estimateFullMeasurements(args) {
10466
- let apiKey;
10467
- try {
10468
- apiKey = args.apiKey ?? getApiKey();
10469
- } catch {
10470
- return null;
10471
- }
10472
- const apiUrl = (args.apiUrl ?? getApiUrl()).replace(/\/+$/, "");
10473
- if (!apiKey) return null;
10474
- const requiredFields = [
10475
- "chest",
10476
- "bust",
10477
- "waist",
10478
- "hips",
10479
- "shoulderWidth",
10480
- "sleeveLength",
10481
- "inseam",
10482
- "neckCircumference",
10483
- "thighCircumference",
10484
- "footLengthCm"
10485
- ];
10486
- const payload = {
10487
- height: args.height ?? 0,
10488
- weight: args.weight ?? 0,
10489
- heightUnit: args.heightUnit,
10490
- weightUnit: args.weightUnit,
10491
- gender: args.gender,
10492
- requiredFields
10493
- };
10494
- if (args.age) payload.age = args.age;
10495
- if (args.chestProfile) payload.chestProfile = args.chestProfile;
10496
- if (args.midsectionProfile) payload.midsectionProfile = args.midsectionProfile;
10497
- if (args.hipProfile) payload.hipProfile = args.hipProfile;
10498
- if (args.bodyImage) payload.bodyImage = args.bodyImage;
10499
- let landmarksForPayload = args.bodyLandmarks ?? null;
10500
- if (!landmarksForPayload) {
10501
- const cached = getCachedMediaPipe();
10502
- if (cached) {
10503
- landmarksForPayload = cached.landmarks;
10504
- console.log("[PS-SDK:estimate] reusing cached MediaPipe result from recent /recommend run");
10505
- }
10506
- }
10507
- if (!landmarksForPayload && args.bodyImage) {
10508
- console.log("[PS-SDK:estimate] bodyImage present but bodyLandmarks missing — running MediaPipe now");
10509
- try {
10510
- const detected = await detectBodyLandmarks(args.bodyImage);
10511
- if (detected) {
10512
- landmarksForPayload = detected;
10513
- console.log("[PS-SDK:estimate] MediaPipe auto-detect ✓ anchors available for backend");
10514
- } else {
10515
- console.warn("[PS-SDK:estimate] MediaPipe auto-detect returned null — backend will fall back to low-confidence visual estimation");
10516
- }
10517
- } catch (e) {
10518
- console.warn("[PS-SDK:estimate] MediaPipe auto-detect threw:", e);
10519
- }
10520
- }
10521
- if (landmarksForPayload) payload.bodyLandmarks = landmarksForPayload;
10522
- if (landmarksForPayload) {
10523
- const lm = landmarksForPayload;
10524
- const required = [
10525
- ["leftShoulder", lm.leftShoulder?.visibility],
10526
- ["rightShoulder", lm.rightShoulder?.visibility],
10527
- ["leftHip", lm.leftHip?.visibility],
10528
- ["rightHip", lm.rightHip?.visibility]
10529
- ];
10530
- const lowVis = required.filter(([, v2]) => typeof v2 === "number" && v2 < 0.5);
10531
- if (lowVis.length > 0) {
10532
- 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`);
10533
- }
10534
- }
10535
- try {
10536
- console.groupCollapsed(`[PS-SDK:estimate] POST /api/v1/sizing/estimate`);
10537
- console.log("url:", `${apiUrl}/api/v1/sizing/estimate`);
10538
- console.log("payload (sans bodyImage):", {
10539
- ...payload,
10540
- bodyImage: payload.bodyImage ? `[base64 ~${Math.round(String(payload.bodyImage).length / 1024)}KB]` : void 0,
10541
- bodyLandmarks: payload.bodyLandmarks ? {
10542
- count: Object.keys(payload.bodyLandmarks).length,
10543
- imageWidth: payload.bodyLandmarks.imageWidth,
10544
- imageHeight: payload.bodyLandmarks.imageHeight
10545
- } : void 0
10546
- });
10547
- console.groupEnd();
10548
- {
10549
- const lmObj = payload.bodyLandmarks;
10550
- const jointCount = lmObj ? Object.values(lmObj).filter(
10551
- (v2) => v2 && typeof v2 === "object" && typeof v2.x === "number" && typeof v2.y === "number"
10552
- ).length : 0;
10553
- console.log(`[ps-sdk:debug] /estimate payload → bodyLandmarks=${!!lmObj}(${jointCount} joints)`);
10554
- }
10555
- const res = await fetch(`${apiUrl}/api/v1/sizing/estimate`, {
10556
- method: "POST",
10557
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
10558
- body: JSON.stringify(payload)
10559
- });
10560
- if (!res.ok) {
10561
- console.warn(`[PS-SDK:estimate] ✗ ${res.status} ${res.statusText}`);
10562
- return null;
10563
- }
10564
- const data = await res.json();
10565
- console.groupCollapsed(`[PS-SDK:estimate] ✓ response (confidence=${data?.confidence ?? "—"})`);
10566
- console.log("estimates:", data.estimates);
10567
- console.log("userEstimates:", data.userEstimates);
10568
- console.log("method:", data.method, "unit:", data.unit);
10569
- console.log("full response:", data);
10570
- console.groupEnd();
10571
- if (!data?.estimates) return null;
10572
- return {
10573
- estimates: data.estimates,
10574
- unit: data.unit || "cm",
10575
- userEstimates: data.userEstimates
10576
- };
10577
- } catch (err) {
10578
- console.error("[PS-SDK:estimate] threw:", err);
10579
- return null;
10580
- }
10581
- }
10582
- function isImperial(locale) {
10583
- return ["US", "UK", "AU"].includes(locale);
10584
- }
10585
- function getUnitLabel(unit) {
10586
- if (unit === "in" || unit === "inches" || unit === "lbs") return "Imperial";
10587
- if (unit === "cm" || unit === "kg") return "Metric";
10588
- if (unit === "mm") return "mm";
10589
- return "";
10590
- }
10591
- const cache = /* @__PURE__ */ new Map();
10592
- function scoreLandmarks(lm) {
10593
- if (!lm) return 0;
10594
- let joints = 0;
10595
- for (const [k2, v2] of Object.entries(lm)) {
10596
- if (k2 === "imageWidth" || k2 === "imageHeight") continue;
10597
- if (v2 && typeof v2 === "object" && typeof v2.x === "number") joints++;
10598
- }
10599
- let score = joints * 10;
10600
- if (lm.nose) score += 50;
10601
- if (lm.leftAnkle && lm.rightAnkle) score += 5;
10602
- return score;
10603
- }
10604
- async function scoreImage(url) {
10605
- try {
10606
- const lm = await detectBodyLandmarks(url);
10607
- return scoreLandmarks(lm);
10608
- } catch {
10609
- return 0;
10610
- }
10611
- }
10612
- async function pickBestGarmentImage(images) {
10613
- if (!images || !images.length) return null;
10614
- if (images.length === 1) return images[0];
10615
- const cacheKey = images.join("|");
10616
- const cached = cache.get(cacheKey);
10617
- if (cached) return cached;
10618
- const t0 = Date.now();
10619
- const scored = await Promise.all(images.map(async (url) => ({
10620
- url,
10621
- score: await scoreImage(url)
10622
- })));
10623
- scored.sort((a, b) => b.score - a.score);
10624
- const best = (scored[0]?.score ?? 0) > 0 ? scored[0].url : images[0];
10625
- cache.set(cacheKey, best);
10626
- console.log(`[ps-sdk:garment-pick] ${Date.now() - t0}ms — chose ${images.indexOf(best)}/${images.length}`);
10627
- for (const s of scored) console.log(`[ps-sdk:garment-pick] ${s.score.toString().padStart(4, " ")} ${s.url}`);
10628
- return best;
10629
- }
10630
- function cx(base, override) {
10631
- return override ? `${base} ${override}` : base;
10632
- }
10633
- const STYLES$1 = `
10634
- /* Variable defaults must live on BOTH the root (for the trigger button)
10635
- and the overlay (which is React-portaled to <body> and therefore not
10636
- a descendant of .ps-tryon-root, so the cascade is broken). Without
10637
- this duplication the slider track gradient and the thumb border —
10638
- both var(--ps-accent) — render as unset and become invisible. */
10639
- .ps-tryon-root,
10640
- .ps-tryon-overlay {
10641
- --ps-accent: #3B82F6;
10642
- --ps-accent-hover: #2563EB;
10643
- --ps-accent-light: #BFDBFE;
10644
- --ps-text-primary: #1C1D1E;
10645
- --ps-text-secondary: #454545;
10646
- --ps-text-muted: #84898C;
10647
- --ps-text-dim: #696E71;
10648
- --ps-border-color: #E7E7E7;
10649
- --ps-border-subtle: #F0F0F0;
10650
- --ps-bg-primary: #FFFFFF;
10651
- --ps-bg-secondary: #F8FAFF;
10652
- --ps-bg-tertiary: #F0F0F0;
10653
- --ps-input-bg: #FFFFFF;
10654
- --ps-input-border: #E7E7E7;
10655
- --ps-input-focus-border: var(--ps-accent);
10656
- --ps-error-color: #E7000B;
10657
- --ps-success-color: #1C9D4C;
10658
- --ps-warning-color: #DF8400;
10659
- --ps-logo-height: 2.8vw;
10660
- }
10661
- .ps-tryon-root { display: inline-block; visibility: visible !important; }
10662
-
10663
- .ps-tryon-btn {
10664
- display: inline-flex; align-items: center; gap: max(6px, 0.42vw);
10665
- padding: var(--ps-btn-padding, max(10px, 0.63vw) max(16px, 1.25vw));
10666
- background: var(--ps-btn-bg, var(--ps-accent)); color: var(--ps-btn-color, #FFFFFF);
10667
- font-family: var(--ps-btn-font, system-ui, -apple-system, sans-serif);
10668
- font-size: var(--ps-btn-font-size, max(13px, 0.73vw)); font-weight: var(--ps-btn-font-weight, 600);
10669
- border: var(--ps-btn-border, none); border-radius: var(--ps-btn-radius, max(6px, 0.42vw));
10670
- cursor: pointer; transition: all 0.2s ease;
10671
- width: var(--ps-btn-width, auto); height: var(--ps-btn-height, auto);
10672
- box-shadow: var(--ps-btn-shadow, none); line-height: 1; white-space: nowrap;
10673
- box-sizing: border-box;
10674
- }
10675
- .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; }
10676
- .ps-tryon-btn:hover { background: var(--ps-btn-hover-bg, var(--ps-accent-hover)); transform: translateY(-1px); }
10677
- .ps-tryon-btn:active { transform: translateY(0); }
10678
-
10679
- .ps-tryon-overlay {
10680
- position: fixed; inset: 0; background: var(--ps-modal-overlay, rgba(0,0,0,0.6));
10681
- display: flex; align-items: center; justify-content: center;
10682
- z-index: 2147483647;
10683
- isolation: isolate;
10684
- contain: layout style;
10685
- padding: 0.83vw;
10686
- 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));
10687
- animation: ps-fade-in 0.2s ease;
10688
- overflow-y: auto; -webkit-overflow-scrolling: touch;
10689
- font-family: system-ui, -apple-system, sans-serif;
10690
- -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
10691
- box-sizing: border-box;
10692
- }
10693
- .ps-tryon-overlay, .ps-tryon-overlay *, .ps-tryon-overlay *::before, .ps-tryon-overlay *::after { box-sizing: border-box; }
10694
- @keyframes ps-fade-in { from { opacity: 0; } to { opacity: 1; } }
10695
-
10696
- .ps-tryon-modal {
10697
- background: var(--ps-modal-bg, #FFFFFF); color: var(--ps-modal-color, #1C1D1E);
10698
- border-radius: clamp(8px, 0.83vw, 16px); width: var(--ps-modal-width, 100%);
10699
- max-width: var(--ps-modal-max-width, 36vw); max-height: 92dvh; overflow-y: auto;
10700
- font-family: var(--ps-modal-font, system-ui, -apple-system, sans-serif);
10701
- box-shadow: 0 8px 40px rgba(0,0,0,0.12); animation: ps-slide-up 0.3s ease;
10702
- scrollbar-width: thin; scrollbar-color: var(--ps-border-color) transparent;
10703
- flex-shrink: 0;
10704
- container-type: inline-size;
10705
- }
10706
- .ps-tryon-modal-wide {
10707
- /* Use the SMALLER of 55vw or 92dvh×(16/10) for width so the modal
10708
- never gets wider than the height can support. Height bumped from
10709
- 70dvh → 82dvh to fit the longer accessory photo step (guidelines +
10710
- legal notice + nav) without pushing content under the Analyze CTA. */
10711
- width: min(55vw, calc(92dvh * 1.6));
10712
- max-width: min(55vw, calc(92dvh * 1.6));
10713
- height: min(82dvh, calc(55vw / 1.35));
10714
- max-height: min(92dvh, calc(55vw / 1.35));
10715
- display: flex; flex-direction: column; overflow: hidden;
10716
- transition: height 0.45s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.45s cubic-bezier(0.32, 0.72, 0, 1);
10717
- }
10718
- .ps-tryon-modal-tall.ps-tryon-modal-wide {
10719
- height: min(92dvh, calc(55vw / 1.2));
10720
- max-height: min(95dvh, calc(55vw / 1.2));
10721
- }
10722
- .ps-tryon-modal:has(.ps-tryon-drawer-open) { overflow: hidden; }
10723
- @keyframes ps-slide-up { from { transform: translateY(12px) scale(0.97); opacity: 0; filter: blur(4px); } to { transform: none; opacity: 1; filter: none; } }
10724
-
10725
- /* Header */
10726
- .ps-tryon-header {
10727
- display: flex; align-items: center; justify-content: space-between;
10728
- padding: max(8px, 0.7vw) max(12px, 1.25vw);
10729
- background: var(--ps-modal-header-bg, rgba(255,255,255,0.95));
10730
- border-bottom: 1px solid var(--ps-border-color);
10731
- border-radius: max(8px, 0.83vw) max(8px, 0.83vw) 0 0;
10732
- backdrop-filter: blur(12px); flex-shrink: 0;
10733
- }
10734
- .ps-tryon-header-minimal {
10735
- justify-content: flex-end; gap: 0.4vw; padding: 0.4vw 0.8vw;
10736
- }
10737
- .ps-tryon-back-btn {
10738
- display: flex; align-items: center; gap: 0.35vw;
10739
- background: none; border: none; color: var(--ps-text-secondary); cursor: pointer;
10740
- font-size: 0.73vw; padding: 0; margin-bottom: 0.63vw;
10741
- transition: color 0.2s;
10742
- }
10743
- .ps-tryon-back-btn:hover { color: var(--ps-text-primary); }
10744
- .ps-tryon-back-btn svg { width: 1vw; height: 0.63vw; stroke: currentColor; flex-shrink: 0; }
10745
-
10746
- /* Regen progress bar — absolutely-positioned fill div inside the
10747
- "Generating new try-on…" button. Animation runs at the browser's
10748
- refresh rate (smooth) instead of via React state ticks. The button
10749
- itself sets position: relative + overflow: hidden so the fill is
10750
- clipped to the button's rounded shape. */
10751
- @keyframes ps-tryon-regen-fill {
10752
- from { width: 0%; }
10753
- to { width: 100%; }
10754
- }
10755
- .ps-tryon-regen-fill {
10756
- position: absolute;
10757
- left: 0; top: 0; bottom: 0;
10758
- width: 0%;
10759
- background: rgba(255, 255, 255, 0.28);
10760
- animation: ps-tryon-regen-fill 22s linear forwards;
10761
- pointer-events: none;
10762
- z-index: 0;
10763
- }
10764
- .ps-tryon-title { display: flex; align-items: center; }
10765
- .ps-tryon-logo-img { height: var(--ps-logo-height); width: auto; }
10766
- .ps-tryon-header-actions { display: flex; align-items: center; gap: 0.42vw; }
10767
- .ps-tryon-header-icon {
10768
- /* Pure vw sizing collapsed to ~8 px on 375 px mobile. Clamp so the icon
10769
- stays finger-tappable (min 30 px) while scaling up on large screens. */
10770
- width: clamp(30px, 2.2vw, 34px); height: clamp(30px, 2.2vw, 34px);
10771
- display: flex; align-items: center; justify-content: center;
10772
- border: 1.5px solid var(--ps-border-color); border-radius: clamp(6px, 0.52vw, 10px); background: transparent;
10773
- cursor: pointer; color: var(--ps-text-secondary); transition: all 0.2s;
10774
- }
10775
- .ps-tryon-header-icon:hover { border-color: var(--ps-accent); color: var(--ps-accent); }
10776
- .ps-tryon-header-icon svg { stroke: currentColor; fill: none; width: clamp(14px, 0.9vw, 16px); height: clamp(14px, 0.9vw, 16px); }
10777
- .ps-tryon-close {
10778
- width: 2.2vw; height: 2.2vw; display: flex; align-items: center; justify-content: center;
10779
- background: none; border: none; color: var(--ps-modal-close-color, #999);
10780
- cursor: pointer; border-radius: 0.31vw; transition: background 0.15s;
10781
- }
10782
- .ps-tryon-close:hover { background: rgba(0,0,0,0.06); }
10783
- .ps-tryon-close svg { width: 1vw; height: 1vw; }
10784
-
10785
- /* Language switcher */
10786
- .ps-tryon-lang-wrap { position: relative; z-index: 99999; }
10787
- .ps-tryon-lang-trigger {
10788
- display: flex; align-items: center; gap: clamp(5px, 0.36vw, 7px);
10789
- padding: clamp(6px, 0.36vw, 8px) clamp(10px, 0.73vw, 14px);
10790
- border: 1.5px solid var(--ps-border-color); border-radius: clamp(8px, 0.57vw, 10px);
10791
- background: transparent; cursor: pointer; color: var(--ps-text-secondary);
10792
- transition: all 0.25s ease; font-family: inherit; white-space: nowrap;
10793
- min-height: clamp(28px, 2.2vw, 38px);
10794
- }
10795
- .ps-tryon-lang-trigger:hover, .ps-tryon-lang-trigger.ps-active {
10796
- border-color: var(--ps-accent); color: var(--ps-accent); background: rgba(33,84,239,0.06);
10797
- }
10798
- .ps-tryon-lang-trigger svg { stroke: currentColor; fill: none; flex-shrink: 0; width: clamp(13px, 0.8vw, 16px); height: clamp(13px, 0.8vw, 16px); }
10799
- .ps-tryon-lang-current {
10800
- font-size: clamp(12px, 0.68vw, 14px); font-weight: 500; letter-spacing: 0.01em;
10801
- }
10802
- .ps-tryon-lang-arrow {
10803
- font-size: clamp(10px, 0.57vw, 12px); transition: transform 0.25s ease; display: inline-block;
10804
- }
10805
- .ps-tryon-lang-arrow.ps-open { transform: rotate(180deg); }
10806
-
10807
- /* Dropdown is portaled to document.body — uses hardcoded colors (no CSS vars available) */
10808
- .ps-tryon-lang-dropdown {
10809
- min-width: min(13vw, 180px);
10810
- background: #FFFFFF; border: 1.5px solid rgba(0,0,0,0.1);
10811
- border-radius: clamp(8px, 0.73vw, 12px);
10812
- box-shadow: 0 clamp(6px, 0.5vw, 10px) clamp(16px, 2vw, 32px) rgba(0,0,0,0.15),
10813
- 0 0 0 1px rgba(0,0,0,0.03);
10814
- overflow: hidden;
10815
- animation: ps-lang-open 0.2s ease both;
10816
- 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;
10817
9443
  }
10818
9444
  @keyframes ps-lang-open {
10819
9445
  from { opacity: 0; transform: translateY(-0.42vw) scale(0.96); }
@@ -17403,850 +16029,2224 @@ const STYLES$1 = `
17403
16029
  border: 1.5px solid rgba(255, 255, 255, 0.35);
17404
16030
  border-top-color: #FFFFFF;
17405
16031
  border-radius: 50%;
17406
- 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;
17407
16297
  }
17408
-
17409
- /* Big product / try-on image */
17410
- .ps-msd-image {
17411
- width: 100%; height: 420px;
17412
- border-radius: 12px; overflow: hidden;
17413
- background: var(--ps-bg-secondary);
17414
- display: flex; align-items: center; justify-content: center;
17415
- flex-shrink: 0;
16298
+ @keyframes ps-bp-spin {
16299
+ to { transform: rotate(360deg); }
17416
16300
  }
17417
- .ps-msd-image-img {
17418
- width: 100%; height: 100%;
17419
- 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;
17420
16306
  }
17421
16307
 
17422
- /* RECOMMENDED SIZE / FIT ACCURACY card */
17423
- .ps-msd-card {
17424
- 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;
17425
16314
  border: 1px solid var(--ps-border-subtle);
17426
- border-radius: 12px;
17427
- padding: 22px 22px 18px;
17428
- display: flex; flex-direction: column; gap: 8px;
17429
- box-shadow: 0 1px 3px rgba(0,0,0,0.04);
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;
17430
16320
  }
17431
- .ps-msd-card-eyebrow {
17432
- font-size: 10px; font-weight: 700;
17433
- letter-spacing: 0.16em; text-transform: uppercase;
17434
- color: var(--ps-text-muted);
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;
17435
16328
  }
17436
- .ps-msd-card-size-row {
17437
- display: flex; align-items: baseline; gap: 12px;
17438
- margin-top: 2px;
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;
16338
+ color: var(--ps-text-primary);
17439
16339
  }
17440
- .ps-msd-card-size {
17441
- font-size: 56px; font-weight: 700;
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%;
16347
+ }
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;
17442
16359
  color: var(--ps-text-primary);
17443
- line-height: 1; letter-spacing: -0.02em;
17444
- font-feature-settings: "tnum" 1;
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;
17445
16365
  }
17446
- .ps-msd-card-size-meta {
17447
- font-size: 12px; font-weight: 600;
17448
- letter-spacing: 0.05em;
17449
- color: var(--ps-text-muted);
16366
+ .ps-bp-mini-select-trigger:hover {
16367
+ border-color: var(--ps-accent);
17450
16368
  }
17451
- .ps-msd-card-divider {
17452
- height: 1px; background: var(--ps-border-subtle);
17453
- margin: 6px 0;
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);
17454
16372
  }
17455
- .ps-msd-card-pct-row {
17456
- display: flex; align-items: center; gap: 12px;
17457
- margin-top: 2px;
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;
17458
16378
  }
17459
- .ps-msd-card-pct {
17460
- font-size: 30px; font-weight: 700;
16379
+ .ps-bp-mini-select-arrow {
16380
+ font-size: 0.55vw;
16381
+ color: var(--ps-text-secondary);
16382
+ transition: transform 0.22s ease;
16383
+ }
16384
+ .ps-bp-mini-select-arrow.ps-open {
16385
+ transform: rotate(180deg);
17461
16386
  color: var(--ps-accent);
17462
- line-height: 1; font-feature-settings: "tnum" 1;
17463
16387
  }
17464
- .ps-msd-card-bar {
17465
- flex: 1; height: 2px;
17466
- background: var(--ps-border-color); border-radius: 1px;
17467
- overflow: hidden;
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;
17468
16402
  }
17469
- .ps-msd-card-bar-fill {
17470
- height: 100%; background: var(--ps-accent);
17471
- border-radius: 1px;
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;
17472
16407
  }
17473
- .ps-msd-card-note {
17474
- font-size: 11px; color: var(--ps-text-muted);
17475
- line-height: 1.5; margin: 2px 0 0;
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;
17476
16412
  }
17477
-
17478
- /* MEASUREMENT BLUEPRINT rich rows with number, description, USER/SIZE
17479
- labeled values, status badge with check icon */
17480
- .ps-msd-blueprint {
17481
- display: flex; flex-direction: column; gap: 6px;
17482
- padding-top: 6px;
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); }
17483
16416
  }
17484
- .ps-msd-blueprint-title {
17485
- font-size: 13px; font-weight: 800;
17486
- letter-spacing: 0.12em; text-transform: uppercase;
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); }
16420
+ }
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);
17487
16438
  color: var(--ps-text-primary);
17488
- margin-bottom: 0;
17489
16439
  }
17490
- .ps-msd-blueprint-underline {
17491
- width: 36px; height: 2px;
17492
- background: var(--ps-text-primary);
17493
- margin-bottom: 8px;
16440
+ .ps-bp-mini-select-item.ps-selected {
16441
+ background: rgba(33, 84, 239, 0.12);
16442
+ color: var(--ps-accent);
17494
16443
  }
17495
- .ps-msd-rows {
17496
- display: flex; flex-direction: column;
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;
17497
16452
  }
17498
- .ps-msd-row {
17499
- display: flex; flex-direction: column;
17500
- padding: 22px 0 24px;
17501
- border-bottom: 1px solid var(--ps-border-subtle);
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;
17502
16456
  }
17503
- .ps-msd-row:last-child { border-bottom: none; }
17504
- .ps-msd-row-num {
17505
- font-size: 11px; font-weight: 700;
17506
- letter-spacing: 0.14em; text-transform: uppercase;
17507
- color: var(--ps-text-primary);
17508
- margin-bottom: 4px;
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;
17509
16465
  }
17510
- .ps-msd-row-desc {
17511
- font-size: 12px; font-weight: 400;
17512
- color: var(--ps-text-muted);
17513
- margin: 0 0 14px;
17514
- line-height: 1.5;
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;
17515
16472
  }
17516
- .ps-msd-row-cells {
17517
- display: flex; justify-content: space-between; align-items: flex-end;
17518
- gap: 16px; margin-bottom: 14px;
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;
16477
+ }
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;
17519
16484
  }
17520
- .ps-msd-row-cell {
17521
- display: flex; flex-direction: column; gap: 4px;
17522
- min-width: 0;
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;
17523
16489
  }
17524
- .ps-msd-row-cell.ps-right { text-align: right; }
17525
- .ps-msd-cell-label {
17526
- font-size: 9px; font-weight: 600;
17527
- letter-spacing: 0.14em; text-transform: uppercase;
17528
- color: var(--ps-text-muted);
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;
17529
16494
  }
17530
- .ps-msd-cell-value {
17531
- font-size: 18px; font-weight: 700;
17532
- color: var(--ps-text-primary);
17533
- font-feature-settings: "tnum" 1;
17534
- line-height: 1.1;
16495
+ .ps-bp-bra-inline-result strong {
16496
+ font-weight: 700;
17535
16497
  }
17536
- .ps-msd-row-badge {
17537
- align-self: flex-end;
17538
- display: inline-flex; align-items: center; gap: 5px;
17539
- font-size: 10px; font-weight: 700;
17540
- letter-spacing: 0.12em; text-transform: uppercase;
17541
- white-space: nowrap;
16498
+
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;
17542
16503
  }
17543
- .ps-msd-row-badge svg { flex-shrink: 0; }
17544
- .ps-msd-row-badge.ps-good {
17545
- color: var(--ps-accent);
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);
17546
16511
  }
17547
- .ps-msd-row-badge.ps-tight {
17548
- color: #dc2626;
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);
17549
16516
  }
17550
- .ps-msd-row-badge.ps-loose {
17551
- color: #d97706;
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;
17552
16521
  }
17553
-
17554
- /* TRY ANOTHER SIZE picker */
17555
- .ps-msd-sizes {
17556
- display: flex; flex-direction: column; gap: 10px;
17557
- padding-top: 18px;
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;
17558
16526
  }
17559
- .ps-msd-sizes-label {
17560
- font-size: 11px; font-weight: 700;
17561
- letter-spacing: 0.14em; text-transform: uppercase;
17562
- color: var(--ps-text-muted);
17563
- text-align: center;
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;
17564
16532
  }
17565
- .ps-msd-sizes-pills {
17566
- display: flex; flex-wrap: wrap; gap: 8px;
17567
- justify-content: center;
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;
17568
16537
  }
17569
- .ps-msd-size-pill {
17570
- min-width: 48px;
17571
- padding: 10px 16px;
17572
- background: transparent;
17573
- border: 1.5px solid var(--ps-border-color);
17574
- border-radius: 8px;
17575
- font-family: inherit; font-size: 13px; font-weight: 600;
17576
- color: var(--ps-text-primary);
17577
- cursor: pointer;
17578
- transition: all 0.15s;
16538
+ .ps-bp-option:hover::after { color: var(--ps-accent); transform: translateX(2px); }
16539
+ .ps-bp-option-selected::after { color: var(--ps-accent); }
16540
+
16541
+ /* Single question step — 3 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;
17579
16554
  }
17580
- .ps-msd-size-pill:hover {
17581
- border-color: var(--ps-accent);
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;
17582
16559
  }
17583
- .ps-msd-size-pill.ps-active {
17584
- background: var(--ps-accent); color: #FFFFFF;
17585
- border-color: var(--ps-accent);
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);
17586
16563
  }
17587
-
17588
- /* Bottom action area Next button + tab bar grouped together,
17589
- pushed to the bottom of the modal body by .ps-bpm-spacer above. */
17590
- .ps-bpm-bottom {
17591
- display: flex; flex-direction: column;
17592
- margin: 0 -16px;
17593
- border-top: 1px solid var(--ps-border-subtle);
17594
- background: var(--ps-bg-primary);
17595
- flex-shrink: 0;
17596
- padding-bottom: env(safe-area-inset-bottom);
16564
+ .ps-bp-shape-row {
16565
+ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5vw;
16566
+ flex: 1; min-height: 0;
17597
16567
  }
17598
- .ps-bpm-next-btn {
17599
- margin: max(14px, 3.5vw) max(16px, 4.1vw) max(12px, 3vw);
17600
- padding: max(14px, 3.5vw) max(18px, 4.6vw);
17601
- background: var(--ps-accent); color: #FFFFFF;
17602
- border: none; border-radius: max(10px, 2.5vw);
17603
- font-family: inherit; font-size: max(14px, 3.6vw); font-weight: 700;
17604
- cursor: pointer; transition: opacity 0.15s, transform 0.15s;
17605
- letter-spacing: 0.02em;
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;
17606
16574
  }
17607
- .ps-bpm-next-btn:active { transform: scale(0.98); }
17608
- .ps-bpm-next-btn:disabled { opacity: 0.4; cursor: not-allowed; }
17609
- .ps-bpm-bottom-tabs {
17610
- display: flex; align-items: stretch;
17611
- border-top: 1px solid var(--ps-border-subtle);
17612
- background: var(--ps-bg-primary);
16575
+ .ps-bp-img-card:hover {
16576
+ border-color: rgba(33,84,239,0.4);
16577
+ transform: translateY(-0.1vw);
17613
16578
  }
17614
- .ps-bpm-bottom-tab {
17615
- flex: 1; background: none; border: none;
17616
- padding: max(14px, 3.5vw) max(8px, 2vw) max(12px, 3vw);
17617
- font-family: inherit; font-size: max(10px, 2.5vw); font-weight: 700;
17618
- letter-spacing: 0.12em; text-transform: uppercase;
17619
- color: var(--ps-text-muted); cursor: pointer;
17620
- border-bottom: 2px solid transparent;
17621
- transition: color 0.15s, border-color 0.15s;
17622
- display: flex; align-items: center; justify-content: center; gap: max(6px, 1.5vw);
16579
+ .ps-bp-img-card-selected {
16580
+ border-color: var(--ps-accent) !important;
16581
+ border-width: 2.5px !important;
17623
16582
  }
17624
- .ps-bpm-bottom-tab.ps-active {
17625
- color: var(--ps-accent);
17626
- border-bottom-color: var(--ps-accent);
16583
+ .ps-bp-img-card-selected .ps-bp-img-card-label {
16584
+ font-weight: 700; color: var(--ps-accent);
17627
16585
  }
17628
- .ps-bpm-bottom-tab svg { width: max(13px, 3.3vw); height: max(13px, 3.3vw); flex-shrink: 0; }
17629
-
17630
- .ps-bp-optional { font-weight: 400; color: var(--ps-text-muted); }
17631
-
17632
- /* Bra size step — vertically centered */
17633
- .ps-bp-bra-step { display: flex; flex-direction: column; justify-content: center; flex: 1; }
17634
- .ps-bp-bra-step .ps-bp-title { text-align: center; margin-bottom: 1.5vw; }
17635
-
17636
- /* Bra size selector */
17637
- .ps-bp-bra-section { margin-bottom: 1.2vw; }
17638
- .ps-bp-bra-header {
17639
- display: flex; align-items: center; justify-content: space-between;
17640
- margin-bottom: 0.5vw;
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;
17641
16591
  }
17642
- .ps-bp-bra-label {
17643
- font-size: 0.6vw; font-weight: 600; color: rgba(255,255,255,0.45);
17644
- letter-spacing: 0.08em; text-transform: uppercase;
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;
17645
16599
  }
17646
- /* Region dropdown */
17647
- /* ── Photo upload overlay (shown during compress + age-check) ── */
17648
- .ps-bp-photo-overlay {
17649
- position: absolute;
17650
- inset: 0;
17651
- background: rgba(255, 255, 255, 0.92);
17652
- display: flex;
17653
- flex-direction: column;
17654
- align-items: center;
17655
- justify-content: center;
17656
- gap: 0.9vw;
17657
- z-index: 10;
17658
- border-radius: inherit;
17659
- backdrop-filter: blur(2px);
17660
- animation: ps-bp-fade 0.18s ease both;
16600
+ .ps-bp-img-card-img {
16601
+ width: 100%; height: 100%; object-fit: contain;
17661
16602
  }
17662
- .ps-bp-photo-overlay-spinner {
17663
- width: 2.2vw;
17664
- height: 2.2vw;
17665
- min-width: 28px;
17666
- min-height: 28px;
17667
- border-radius: 50%;
17668
- border: 2.5px solid rgba(33, 84, 239, 0.18);
17669
- border-top-color: var(--ps-accent);
17670
- animation: ps-bp-spin 0.7s linear infinite;
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;
17671
16609
  }
17672
- @keyframes ps-bp-spin {
17673
- to { transform: rotate(360deg); }
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;
17674
16615
  }
17675
- .ps-bp-photo-overlay-label {
17676
- font-size: max(12px, 0.85vw);
17677
- font-weight: 600;
17678
- color: var(--ps-text-primary);
17679
- letter-spacing: 0.01em;
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);
17680
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; }
17681
16633
 
17682
- /* ── Photo-help popover (Do / Don't / Tip) — desktop default ── */
17683
- .ps-bp-photo-help-backdrop { display: none; }
17684
- .ps-bp-photo-help {
17685
- position: absolute; top: 2.2vw; right: 0;
17686
- width: min(360px, 22vw);
17687
- background: #FFFFFF;
17688
- border: 1px solid var(--ps-border-subtle);
17689
- border-radius: 0.8vw;
17690
- box-shadow: 0 20px 40px -12px rgba(17,24,39,0.25), 0 8px 16px -8px rgba(17,24,39,0.15);
17691
- padding: 0.8vw 1vw;
17692
- display: flex; flex-direction: column; gap: 0.6vw;
17693
- 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);
17694
16638
  }
17695
- .ps-bp-photo-help-handle { display: none; }
17696
- .ps-bp-photo-help-header { display: flex; align-items: center; justify-content: space-between; }
17697
- .ps-bp-photo-help-title { font-size: 0.8vw; font-weight: 700; color: var(--ps-text-primary); }
17698
- .ps-bp-photo-help-close {
17699
- width: 1.4vw; height: 1.4vw; border-radius: 50%;
17700
- background: transparent; border: none; color: var(--ps-text-muted);
17701
- 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;
17702
16642
  }
17703
- .ps-bp-photo-help-section { border-radius: 0.5vw; padding: 0.55vw 0.75vw; }
17704
- .ps-bp-photo-help-section-label { font-size: 0.7vw; font-weight: 700; margin-bottom: 0.3vw; }
17705
- .ps-bp-photo-help-section-body { font-size: 0.6vw; color: var(--ps-text-primary); line-height: 1.6; }
17706
- .ps-bp-photo-help-do { background: #ddfbe7; }
17707
- .ps-bp-photo-help-do .ps-bp-photo-help-section-label { color: #1c9d4c; }
17708
- .ps-bp-photo-help-dont { background: #ffe2e2; }
17709
- .ps-bp-photo-help-dont .ps-bp-photo-help-section-label { color: #e7000b; }
17710
- .ps-bp-photo-help-tip {
17711
- background: #c6e1f7; font-size: 0.6vw; line-height: 1.55;
17712
- 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;
17713
16647
  }
17714
- .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); }
17715
16656
 
17716
- /* ── MiniSelect: compact animated dropdown for inline bra inputs ── */
17717
- .ps-bp-mini-select {
17718
- position: relative;
17719
- display: inline-flex;
17720
- width: 100%;
16657
+ /* Error */
16658
+ .ps-bp-error {
16659
+ color: var(--ps-error-color); font-size: 0.73vw; margin: 0;
17721
16660
  }
17722
- .ps-bp-mini-select-trigger {
17723
- display: flex;
17724
- align-items: center;
16661
+
16662
+ /* Navigation — back left, next right */
16663
+ .ps-bp-nav {
16664
+ display: flex; align-items: center; margin-top: auto;
17725
16665
  justify-content: space-between;
17726
- gap: 0.4vw;
17727
- width: 100%;
17728
- min-width: 4vw;
17729
- padding: 0.45vw 0.7vw;
17730
- background: var(--ps-bg-secondary, #fff);
17731
- border: 1.5px solid rgba(0, 0, 0, 0.12);
17732
- border-radius: 0.4vw;
17733
- color: var(--ps-text-primary);
17734
- font-size: max(11px, 0.78vw);
17735
- font-weight: 600;
17736
- font-family: inherit;
17737
- cursor: pointer;
17738
- transition: border-color 0.18s, background 0.18s, transform 0.12s;
17739
16666
  }
17740
- .ps-bp-mini-select-trigger:hover {
17741
- 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;
17742
16673
  }
17743
- .ps-bp-mini-select[data-open="true"] .ps-bp-mini-select-trigger {
17744
- border-color: var(--ps-accent);
17745
- 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;
17746
16677
  }
17747
- .ps-bp-mini-select-value { display: inline-flex; align-items: center; }
17748
- .ps-bp-mini-select-placeholder {
17749
- color: var(--ps-text-secondary);
17750
- opacity: 0.7;
17751
- 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;
17752
16686
  }
17753
- .ps-bp-mini-select-arrow {
17754
- font-size: 0.55vw;
17755
- color: var(--ps-text-secondary);
17756
- 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;
17757
16691
  }
17758
- .ps-bp-mini-select-arrow.ps-open {
17759
- transform: rotate(180deg);
17760
- 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; }
17761
16767
  }
17762
- .ps-bp-mini-select-panel {
17763
- position: absolute;
17764
- left: 0;
17765
- right: 0;
17766
- max-height: 9vw;
17767
- overflow-y: auto;
17768
- background: var(--ps-bg-secondary, #fff);
17769
- border: 1.5px solid rgba(0, 0, 0, 0.08);
17770
- border-radius: 0.45vw;
17771
- box-shadow:
17772
- 0 0.25vw 0.55vw rgba(15, 23, 42, 0.08),
17773
- 0 0.85vw 2.0vw rgba(15, 23, 42, 0.14);
17774
- padding: 0.25vw;
17775
- 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;
17776
16781
  }
17777
- .ps-bp-mini-select[data-dir="down"] .ps-bp-mini-select-panel {
17778
- top: calc(100% + 0.3vw);
17779
- transform-origin: top center;
17780
- 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);
17781
16787
  }
17782
- .ps-bp-mini-select[data-dir="up"] .ps-bp-mini-select-panel {
17783
- bottom: calc(100% + 0.3vw);
17784
- transform-origin: bottom center;
17785
- 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;
17786
16794
  }
17787
- @keyframes ps-bp-mini-slide-down {
17788
- from { opacity: 0; transform: translateY(-0.35vw) scale(0.96); }
17789
- 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;
17790
16801
  }
17791
- @keyframes ps-bp-mini-slide-up {
17792
- from { opacity: 0; transform: translateY(0.35vw) scale(0.96); }
17793
- 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;
17794
16808
  }
17795
- .ps-bp-mini-select-item {
17796
- display: block;
17797
- width: 100%;
17798
- padding: 0.45vw 0.7vw;
17799
- background: transparent;
17800
- border: none;
17801
- border-radius: 0.35vw;
17802
- color: var(--ps-text-secondary);
17803
- font-size: max(11px, 0.78vw);
17804
- font-weight: 600;
17805
- font-family: inherit;
17806
- text-align: left;
17807
- cursor: pointer;
17808
- 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;
17809
16817
  }
17810
- .ps-bp-mini-select-item:hover {
17811
- background: rgba(33, 84, 239, 0.06);
17812
- 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;
17813
16823
  }
17814
- .ps-bp-mini-select-item.ps-selected {
17815
- background: rgba(33, 84, 239, 0.12);
17816
- 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;
17817
16830
  }
16831
+ .ps-photo-zone-status { font-size: 0.7vw; color: var(--ps-text-secondary); }
17818
16832
 
17819
- .ps-bp-bra-region-wrap { position: relative; z-index: 100; }
17820
- .ps-bp-bra-region-trigger {
17821
- display: flex; align-items: center; gap: 0.35vw;
17822
- padding: 0.45vw 0.9vw; border: 1.5px solid rgba(0,0,0,0.1);
17823
- border-radius: 0.4vw; background: transparent;
17824
- color: var(--ps-text-secondary); font-size: 0.8vw; font-weight: 600;
17825
- 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;
17826
16837
  }
17827
- .ps-bp-bra-region-trigger:hover { border-color: var(--ps-accent); color: var(--ps-accent); }
17828
- .ps-bp-bra-region-arrow {
17829
- 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;
17830
16843
  }
17831
- .ps-bp-bra-region-arrow.ps-open { transform: rotate(180deg); }
17832
- .ps-bp-bra-region-dropdown {
17833
- position: absolute; top: calc(100% + 0.3vw); right: 0;
17834
- min-width: 5vw; background: var(--ps-bg-secondary);
17835
- border: 1.5px solid rgba(0,0,0,0.08); border-radius: 0.4vw;
17836
- box-shadow: 0 0.5vw 1.5vw rgba(0,0,0,0.5);
17837
- overflow: hidden; animation: ps-bp-fade 0.2s ease both;
17838
- 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;
17839
16852
  }
17840
- .ps-bp-bra-region-item {
17841
- display: block; width: 100%; padding: 0.4vw 0.7vw;
17842
- background: transparent; border: none; border-radius: 0.3vw;
17843
- color: var(--ps-text-secondary); font-size: 0.75vw; font-weight: 600;
17844
- cursor: pointer; font-family: inherit; text-align: left;
17845
- 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;
17846
16861
  }
17847
- .ps-bp-bra-region-item:hover { background: rgba(0,0,0,0.04); color: var(--ps-text-primary); }
17848
- .ps-bp-bra-region-item.ps-selected { color: var(--ps-accent); background: rgba(33,84,239,0.1); }
17849
- .ps-bp-bra-grid {
17850
- 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; }
17851
16874
  }
17852
- .ps-bp-bra-btn {
17853
- padding: 0.4vw 0; text-align: center;
17854
- background: transparent; border: 1px solid rgba(0,0,0,0.08);
17855
- border-radius: 0.35vw; color: var(--ps-text-secondary);
17856
- font-size: 0.65vw; font-weight: 500; cursor: pointer;
17857
- 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;
17858
16913
  }
17859
- .ps-bp-bra-btn:hover { border-color: rgba(0,0,0,0.3); color: var(--ps-text-primary); }
17860
- .ps-bp-bra-btn-selected {
17861
- background: var(--ps-accent) !important; border-color: var(--ps-accent) !important;
17862
- color: #fff !important; font-weight: 700;
16914
+ }
16915
+ function uuid() {
16916
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
16917
+ return crypto.randomUUID();
17863
16918
  }
17864
- /* Bra inline result — shown in header next to region switcher */
17865
- .ps-bp-bra-inline-result {
17866
- margin-left: auto; font-size: max(11px, 0.75vw); color: var(--ps-accent);
17867
- 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;
17868
16938
  }
17869
- .ps-bp-bra-inline-result strong {
17870
- 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 {
17871
16946
  }
17872
-
17873
- /* Profile option rows — WAIR style stacked list */
17874
- .ps-bp-options {
17875
- display: flex; flex-direction: column; gap: 0.5vw;
17876
- 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;
17877
16956
  }
17878
- .ps-bp-option {
17879
- display: flex; align-items: center; gap: 0.8vw;
17880
- padding: 0.35vw 1vw 0.35vw 0.35vw;
17881
- background: #fff; border: 1.5px solid rgba(0,0,0,0.06);
17882
- border-radius: 0.6vw; cursor: pointer;
17883
- transition: all 0.25s ease; font-family: inherit;
17884
- 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(/\/+$/, "");
17885
16973
  }
17886
- .ps-bp-option:hover {
17887
- border-color: rgba(33,84,239,0.5);
17888
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
17889
- transform: translateY(-1px);
16974
+ get headers() {
16975
+ return {
16976
+ "Content-Type": "application/json",
16977
+ Authorization: `Bearer ${this.apiKey}`
16978
+ };
17890
16979
  }
17891
- .ps-bp-option:hover .ps-bp-illust svg { transform: scale(1.05); }
17892
- .ps-bp-option-selected {
17893
- border-color: var(--ps-accent) !important;
17894
- 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();
17895
17043
  }
17896
- .ps-bp-option-icon {
17897
- width: 6vw; height: 6vw; flex-shrink: 0;
17898
- display: flex; align-items: center; justify-content: center;
17899
- 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();
17900
17056
  }
17901
- .ps-bp-illust { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
17902
- .ps-bp-illust svg { width: 100%; height: 100%; transition: transform 0.25s ease; }
17903
- .ps-bp-option-label {
17904
- flex: 1; font-size: 0.73vw; font-weight: 500;
17905
- color: #555; text-align: center;
17057
+ getStreamUrl() {
17058
+ return `${this.baseUrl}/api/v1/tryon/stream?key=${encodeURIComponent(this.apiKey)}`;
17906
17059
  }
17907
- .ps-bp-option-selected .ps-bp-option-label { color: var(--ps-accent); font-weight: 600; }
17908
- .ps-bp-option::after {
17909
- content: "›"; font-size: 1.1vw; color: var(--ps-text-secondary); flex-shrink: 0;
17910
- 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;
17911
17066
  }
17912
- .ps-bp-option:hover::after { color: var(--ps-accent); transform: translateX(2px); }
17913
- .ps-bp-option-selected::after { color: var(--ps-accent); }
17914
-
17915
- /* Single question step — 3 cards centered */
17916
- .ps-bp-single-q { display: flex; flex-direction: column; flex: 1; justify-content: center; align-items: center; }
17917
- .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; }
17918
- .ps-bp-shape-row-full { width: 100%; margin: 1.5vw auto 0; }
17919
- .ps-bp-shape-row-full .ps-bp-img-card { max-height: 18vw; padding: 0.8vw 0.6vw 0; }
17920
- .ps-bp-shape-row-full .ps-bp-img-card-thumb { height: 13vw; }
17921
-
17922
- /* Body shapes screen — fit all questions without scrolling */
17923
- .ps-bp-shapes-fit { display: flex; flex-direction: column; flex: 1; overflow: hidden; gap: 0.4vw; justify-content: flex-start !important; }
17924
-
17925
- /* Body shapes combined screen */
17926
- .ps-bp-shape-section {
17927
- 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;
17928
17076
  }
17929
- .ps-bp-shape-label {
17930
- font-size: 0.55vw; font-weight: 500; color: rgba(255,255,255,0.7);
17931
- margin-bottom: 0.3vw; display: flex; align-items: center; gap: 0.6vw;
17932
- 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
+ };
17933
17095
  }
17934
- .ps-bp-shape-label::after {
17935
- content: ""; flex: 1; height: 1px;
17936
- 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);
17937
17104
  }
17938
- .ps-bp-shape-row {
17939
- display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5vw;
17940
- 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
+ };
17941
17125
  }
17942
- .ps-bp-img-card {
17943
- display: flex; flex-direction: column; align-items: center;
17944
- background: transparent; border: 2px solid rgba(0,0,0,0.08);
17945
- border-radius: 0.5vw; overflow: hidden; cursor: pointer;
17946
- transition: all 0.2s ease; padding: 0; font-family: inherit;
17947
- position: relative;
17126
+ emit(jobId, update) {
17127
+ const callbacks = this.listeners.get(jobId);
17128
+ if (callbacks) {
17129
+ callbacks.forEach((cb2) => cb2(update));
17130
+ }
17948
17131
  }
17949
- .ps-bp-img-card:hover {
17950
- border-color: rgba(33,84,239,0.4);
17951
- 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;
17952
17143
  }
17953
- .ps-bp-img-card-selected {
17954
- border-color: var(--ps-accent) !important;
17955
- 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" };
17956
17217
  }
17957
- .ps-bp-img-card-selected .ps-bp-img-card-label {
17958
- 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;
17959
17370
  }
17960
- .ps-bp-img-card-check {
17961
- display: none; position: absolute; top: 0.3vw; right: 0.3vw;
17962
- width: 1.1vw; height: 1.1vw; border-radius: 50%;
17963
- background: var(--ps-accent); align-items: center; justify-content: center;
17964
- 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;
17965
17469
  }
17966
- .ps-bp-img-card-check svg { width: 0.6vw; height: 0.6vw; }
17967
- .ps-bp-img-card-selected .ps-bp-img-card-check { display: flex; }
17968
- .ps-bp-img-card .ps-bp-img-card-thumb { opacity: 1; }
17969
- .ps-bp-img-card-thumb {
17970
- width: 100%; flex: 1; min-height: 0; overflow: hidden;
17971
- display: flex; align-items: center; justify-content: center;
17972
- 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;
17973
17498
  }
17974
- .ps-bp-img-card-img {
17975
- width: 100%; height: 100%; object-fit: contain;
17499
+ if (loadingPromise) {
17500
+ console.log("[PS-SDK:FaceDetect] loadFaceLandmarker awaiting in-flight load");
17501
+ return loadingPromise;
17976
17502
  }
17977
- .ps-bp-img-card .ps-bp-illust { width: 80%; height: 80%; }
17978
- .ps-bp-img-card .ps-bp-illust svg { width: 100%; height: 100%; }
17979
- .ps-bp-img-card-label {
17980
- padding: 0.3vw 0.2vw; font-size: 0.6vw; font-weight: 600;
17981
- color: var(--ps-text-secondary); text-align: center; width: 100%;
17982
- 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;
17983
17582
  }
17984
- /* Hover hint text below cards */
17985
- .ps-bp-hover-hint {
17986
- text-align: center; font-size: 0.8vw; color: rgba(0,0,0,0.45);
17987
- margin: 0.15vw 0 0; min-height: 1em; transition: opacity 0.2s;
17988
- 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;
17989
17688
  }
17990
-
17991
- /* Photo upload */
17992
- .ps-bp-photo-upload {
17993
- display: flex; align-items: center; gap: 0.6vw;
17994
- width: 100%; padding: 0.7vw 0.8vw;
17995
- background: transparent; border: 1.5px dashed var(--ps-border-color);
17996
- border-radius: 0.5vw; cursor: pointer;
17997
- transition: all 0.2s; color: var(--ps-text-muted);
17998
- 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";
17999
17710
  }
18000
- .ps-bp-photo-upload:hover {
18001
- border-color: var(--ps-accent); background: rgba(33,84,239,0.04);
18002
- 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);
18003
17770
  }
18004
- .ps-bp-photo-upload svg { flex-shrink: 0; opacity: 0.6; }
18005
- .ps-bp-photo-upload-text { font-size: 0.78vw; font-weight: 500; }
18006
- .ps-bp-photo-upload-hint { font-size: 0.62vw; color: var(--ps-text-muted); margin-left: auto; }
18007
-
18008
- .ps-bp-photo-preview {
18009
- display: flex; align-items: center; gap: 0.6vw;
18010
- padding: 0.5vw; border: 1.5px solid var(--ps-accent);
18011
- 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(" | ");
18012
17778
  }
18013
- .ps-bp-photo-thumb {
18014
- width: 2.8vw; height: 2.8vw; object-fit: cover;
18015
- 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 {
18016
17807
  }
18017
- .ps-bp-photo-info { display: flex; flex-direction: column; gap: 0.1vw; flex: 1; min-width: 0; }
18018
- .ps-bp-photo-status {
18019
- font-size: 0.73vw; font-weight: 600; color: var(--ps-text-primary);
18020
- 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;
18021
17816
  }
18022
- .ps-bp-photo-hint { font-size: 0.62vw; color: var(--ps-text-muted); }
18023
- .ps-bp-photo-remove {
18024
- width: 1.5vw; height: 1.5vw; display: flex; align-items: center; justify-content: center;
18025
- background: none; border: none; color: var(--ps-text-muted);
18026
- cursor: pointer; font-size: 1vw; line-height: 1; border-radius: 0.25vw;
18027
- 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 {
18028
17822
  }
18029
- .ps-bp-photo-remove:hover { background: rgba(0,0,0,0.06); color: var(--ps-text-primary); }
18030
-
18031
- /* Error */
18032
- .ps-bp-error {
18033
- 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;
18034
17845
  }
18035
-
18036
- /* Navigation back left, next right */
18037
- .ps-bp-nav {
18038
- display: flex; align-items: center; margin-top: auto;
18039
- 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 {
18040
17905
  }
18041
- .ps-bp-back-btn {
18042
- background: none; border: none; padding: 0;
18043
- color: var(--ps-text-muted); cursor: pointer;
18044
- font-size: 0.83vw; font-weight: 500; font-family: inherit;
18045
- white-space: nowrap; display: flex; align-items: center; gap: 0.3vw;
18046
- 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 {
18047
17915
  }
18048
- .ps-bp-back-btn:hover { color: var(--ps-text-primary); }
18049
- .ps-bp-back-arrow {
18050
- 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;
18051
17928
  }
18052
- .ps-bp-back-btn:hover .ps-bp-back-arrow { transform: translateX(-4px); }
18053
- .ps-bp-next-btn {
18054
- display: flex; align-items: center; gap: 0.5vw;
18055
- padding: 0.45vw 1.2vw;
18056
- background: var(--ps-accent); border: none; border-radius: 0.35vw;
18057
- color: #fff; font-size: 0.73vw; font-weight: 700;
18058
- cursor: pointer; transition: all 0.15s; font-family: inherit;
18059
- 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;
18060
17937
  }
18061
- .ps-bp-next-btn svg { width: 0.75vw; height: 0.75vw; }
18062
- .ps-bp-next-btn:hover { background: var(--ps-accent-hover); }
18063
- .ps-bp-btn-disabled {
18064
- 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;
18065
17952
  }
18066
-
18067
- /* ── Mobile responsive ── */
18068
- @media (max-width: 768px) {
18069
- .ps-bp-layout { flex-direction: column; height: auto; gap: 0; overflow: visible; }
18070
- /* Desktop layout's product image — hidden on mobile because BasicsStepMobile
18071
- has its own minimal layout. The other steps (chest/midsection/hips) don't
18072
- need the giant left-column image on a phone either. */
18073
- .ps-bp-image { display: none !important; }
18074
- .ps-bp-wrapper { overflow-y: auto; -webkit-overflow-scrolling: touch; flex: 1; min-height: 0; display: flex; flex-direction: column; }
18075
- .ps-bp-layout { flex: 1; min-height: 0; }
18076
- /* Override desktop justify-content:center so BasicsStepMobile can
18077
- stretch to the bottom of the modal — its bottom action bar (Next +
18078
- tabs) needs to land at the bottom edge, not floating in the middle. */
18079
- .ps-bp-root {
18080
- justify-content: stretch !important;
18081
- height: auto !important;
18082
- max-height: none !important;
18083
- overflow: visible !important;
18084
- flex: 1 !important;
18085
- }
18086
- .ps-bp-upload-placeholder svg { width: 32px; height: 32px; }
18087
- .ps-bp-upload-text { font-size: 14px; }
18088
- .ps-bp-upload-hint { font-size: 11px; }
18089
- .ps-bp-root { padding: 16px; gap: 14px; overflow: visible; }
18090
- .ps-bp-dots { gap: 6px; }
18091
- .ps-bp-dot { width: 6px; height: 6px; }
18092
- .ps-bp-dot-active { width: 18px; border-radius: 4px; }
18093
- .ps-bp-title { font-size: 18px; }
18094
- .ps-bp-subtitle { font-size: 13px; margin-top: -4px; }
18095
- .ps-bp-system-toggle { gap: 20px; margin: 4px 0; }
18096
- .ps-bp-system-btn { font-size: 13px; }
18097
- .ps-bp-inline-row { padding: 14px 0; gap: 12px; }
18098
- .ps-bp-inline-label { flex: 0 0 85px; font-size: 11px; white-space: nowrap; }
18099
- .ps-bp-inline-input { font-size: 18px; }
18100
- .ps-bp-inline-unit { font-size: 12px; }
18101
- .ps-bp-photo-cta { font-size: 13px; gap: 6px; margin-top: 4px; }
18102
- .ps-bp-photo-cta svg { width: 16px; height: 16px; }
18103
- .ps-bp-options { gap: 6px; }
18104
- .ps-bp-option { border-radius: 10px; padding: 6px 12px 6px 6px; gap: 10px; }
18105
- .ps-bp-option-icon { width: 70px; height: 70px; }
18106
- .ps-bp-option-label { font-size: 14px; }
18107
- .ps-bp-option::after { font-size: 18px; }
18108
- .ps-bp-option { padding: 14px 8px; border-radius: 10px; gap: 8px; }
18109
- .ps-bp-option-icon { width: 56px; height: 56px; }
18110
- .ps-bp-option-label { font-size: 13px; }
18111
- .ps-bp-error { font-size: 12px; }
18112
- .ps-bp-shape-section { margin-bottom: 12px; }
18113
- .ps-bp-shape-label { font-size: 13px; margin-bottom: 6px; }
18114
- .ps-bp-shape-row { gap: 6px; }
18115
- .ps-bp-img-card { border-radius: 8px; border-width: 2px; }
18116
- .ps-bp-img-card-label { font-size: 11px; padding: 4px 2px; }
18117
- .ps-bp-hover-hint { font-size: 10px; margin-top: 6px; }
18118
- .ps-bp-single-q .ps-bp-title { font-size: 13px; font-weight: 400; }
18119
- .ps-bp-photo-upload { padding: 10px 12px; gap: 8px; border-radius: 8px; }
18120
- .ps-bp-photo-upload-text { font-size: 13px; }
18121
- .ps-bp-photo-upload-hint { font-size: 10px; }
18122
- .ps-bp-photo-preview { padding: 8px; gap: 8px; border-radius: 8px; }
18123
- .ps-bp-photo-thumb { width: 40px; height: 40px; border-radius: 6px; }
18124
- .ps-bp-photo-status { font-size: 12px; gap: 4px; }
18125
- .ps-bp-photo-hint { font-size: 10px; }
18126
- .ps-bp-photo-remove { width: 22px; height: 22px; font-size: 16px; }
18127
- .ps-bp-nav { padding-top: 6px; }
18128
- .ps-bp-back-btn { font-size: 14px; }
18129
- .ps-bp-back-arrow { font-size: 18px; }
18130
- .ps-bp-next-btn {
18131
- display: flex; align-items: center; gap: 0.5vw; padding: 10px 16px; font-size: 13px; border-radius: 6px; }
18132
- .ps-bp-root { padding: 12px; overflow: hidden; }
18133
- /* Accessory sizing inputs — mobile */
18134
- .ps-acc-input { padding: 3vw; font-size: 3.6vw; border-radius: 2vw; }
18135
- .ps-acc-label { font-size: 2.8vw; margin-bottom: 1.5vw; }
18136
- .ps-acc-field { margin: 3vw 0; }
18137
- .ps-acc-error { font-size: 2.8vw; }
18138
- .ps-acc-hint { font-size: 2.5vw; }
18139
- .ps-tryon-unit-toggle { border-radius: 2vw; }
18140
- .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;
18141
17957
  }
18142
-
18143
- /* Upload hover overlay */
18144
- .ps-tryon-upload-hover:hover .ps-tryon-upload-hover-overlay { opacity: 1 !important; }
18145
-
18146
- /* ─────────── Unified PhotoUploadZone ─────────── */
18147
- .ps-photo-zone {
18148
- flex: 1; min-height: 220px;
18149
- display: flex; flex-direction: column; align-items: center; justify-content: center;
18150
- border: 2px dashed var(--ps-border-color);
18151
- border-radius: 0.5vw;
18152
- background: var(--ps-bg-secondary);
18153
- cursor: pointer; position: relative; overflow: hidden;
18154
- 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");
18155
17980
  }
18156
- .ps-photo-zone:hover { border-color: var(--ps-accent); background: rgba(33,84,239,0.02); }
18157
- .ps-photo-zone.ps-photo-zone-drag {
18158
- border-color: var(--ps-accent); border-style: solid;
18159
- background: rgba(33,84,239,0.06);
18160
- 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
+ }
18161
17989
  }
18162
- .ps-photo-zone.ps-photo-zone-has { border: none; cursor: default; padding: 0; }
18163
- .ps-photo-zone.ps-photo-zone-inline { min-height: 100%; height: 100%; }
18164
-
18165
- .ps-photo-zone-empty {
18166
- display: flex; flex-direction: column; align-items: center; gap: 0.4vw;
18167
- 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
+ };
18168
18012
  }
18169
- .ps-photo-zone-title { font-size: 0.85vw; font-weight: 600; color: var(--ps-text-primary); }
18170
- .ps-photo-zone-hint { font-size: 0.6vw; color: var(--ps-text-muted); line-height: 1.4; max-width: 24vw; }
18171
-
18172
- .ps-photo-zone-img {
18173
- width: 100%; height: 100%; object-fit: contain;
18174
- display: block; cursor: pointer;
18013
+ if (profile.knownMeasurements) {
18014
+ payload.knownMeasurements = profile.knownMeasurements;
18175
18015
  }
18176
- .ps-photo-zone-hover-overlay {
18177
- position: absolute; inset: 0;
18178
- display: flex; align-items: center; justify-content: center;
18179
- background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s;
18180
- color: #fff; font-size: 0.8vw; font-weight: 600;
18181
- border-radius: 0.5vw; cursor: pointer;
18016
+ if (sizeGuide && sizeGuide.found) {
18017
+ payload.sizeGuide = sizeGuide;
18182
18018
  }
18183
- .ps-photo-zone:hover .ps-photo-zone-hover-overlay { opacity: 1; }
18184
- .ps-photo-zone-remove {
18185
- position: absolute; top: 0.5vw; right: 0.5vw;
18186
- background: rgba(0,0,0,0.55); color: #fff; border: none; border-radius: 50%;
18187
- width: 24px; height: 24px; min-width: 24px;
18188
- display: flex; align-items: center; justify-content: center;
18189
- cursor: pointer; transition: background 0.15s;
18190
- 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;
18191
18040
  }
18192
- .ps-photo-zone-remove:hover { background: rgba(239,68,68,0.85); }
18193
-
18194
- .ps-photo-zone-processing {
18195
- display: flex; flex-direction: column; align-items: center; gap: 0.5vw;
18196
- padding: 1vw;
18041
+ if (!result || !result.recommendedSize) {
18042
+ log("recommend returned no recommendedSize — bailing");
18043
+ return null;
18197
18044
  }
18198
- .ps-photo-zone-spinner {
18199
- width: 28px; height: 28px;
18200
- border: 3px solid rgba(33,84,239,0.18);
18201
- border-top-color: var(--ps-accent);
18202
- border-radius: 50%;
18203
- 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;
18204
18088
  }
18205
- .ps-photo-zone-status { font-size: 0.7vw; color: var(--ps-text-secondary); }
18206
-
18207
- .ps-photo-zone-rejection {
18208
- display: flex; flex-direction: column; align-items: center; gap: 0.5vw;
18209
- padding: 1vw 1.2vw; max-width: 22vw; text-align: center;
18210
- 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
+ }
18211
18123
  }
18212
- .ps-photo-zone-rejection-icon {
18213
- width: 36px; height: 36px; border-radius: 50%;
18214
- background: rgba(239,68,68,0.12); color: #dc2626;
18215
- display: flex; align-items: center; justify-content: center;
18216
- 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
+ }
18217
18137
  }
18218
- .ps-photo-zone-rejection-title { font-size: 0.85vw; font-weight: 700; color: var(--ps-text-primary); }
18219
- .ps-photo-zone-rejection-msg { font-size: 0.65vw; color: var(--ps-text-secondary); line-height: 1.5; }
18220
- .ps-photo-zone-rejection-cta {
18221
- margin-top: 0.3vw; padding: 0.55vw 1vw;
18222
- background: var(--ps-accent); color: #fff; border: none;
18223
- border-radius: 0.4vw; font-family: inherit;
18224
- font-size: 0.7vw; font-weight: 600; cursor: pointer;
18225
- 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
+ }
18226
18151
  }
18227
- .ps-photo-zone-rejection-cta:hover { opacity: 0.9; }
18228
-
18229
- .ps-photo-zone-error {
18230
- position: absolute; bottom: 0.6vw; left: 0.6vw; right: 0.6vw;
18231
- background: rgba(239,68,68,0.08); color: #dc2626;
18232
- border: 1px solid rgba(239,68,68,0.2); border-radius: 0.4vw;
18233
- padding: 0.4vw 0.6vw; font-size: 0.6vw; line-height: 1.4;
18234
- 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;
18235
18197
  }
18236
-
18237
- @media (max-width: 700px) {
18238
- .ps-photo-zone { min-height: 200px; border-radius: 12px; }
18239
- .ps-photo-zone-title { font-size: 14px; }
18240
- .ps-photo-zone-hint { font-size: 11px; max-width: 90%; }
18241
- .ps-photo-zone-hover-overlay { font-size: 13px; }
18242
- .ps-photo-zone-status { font-size: 12px; }
18243
- .ps-photo-zone-rejection { max-width: 90%; gap: 8px; padding: 14px; }
18244
- .ps-photo-zone-rejection-title { font-size: 14px; }
18245
- .ps-photo-zone-rejection-msg { font-size: 12px; }
18246
- .ps-photo-zone-rejection-cta { font-size: 13px; padding: 10px 16px; border-radius: 8px; }
18247
- .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++;
18248
18215
  }
18249
- `;
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
+ }
18250
18250
  function CameraIcon$1({ size = 18 }) {
18251
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: [
18252
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" }),
@@ -27589,12 +27589,15 @@ function FaceSizeView(props) {
27589
27589
  );
27590
27590
  }
27591
27591
  if (typeof document !== "undefined") {
27592
- const id2 = "ps-tryon-styles";
27593
- if (!document.getElementById(id2)) {
27594
- const el2 = document.createElement("style");
27595
- el2.id = id2;
27596
- el2.textContent = STYLES$1;
27597
- 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
+ }
27598
27601
  }
27599
27602
  }
27600
27603
  function detectMeasurementType(title) {
@@ -29832,9 +29835,14 @@ const STYLES = `
29832
29835
  color: #1f2937;
29833
29836
  }
29834
29837
  `;
29838
+ const SIZE_GUIDE_STYLES = STYLES;
29835
29839
  let stylesInjected = false;
29836
29840
  function injectStyles() {
29837
29841
  if (stylesInjected) return;
29842
+ if (globalThis.__PRIMESTYLE_SHADOW_ACTIVE__) {
29843
+ stylesInjected = true;
29844
+ return;
29845
+ }
29838
29846
  const tag = document.createElement("style");
29839
29847
  tag.id = "ps-sg-styles";
29840
29848
  tag.textContent = STYLES;
@@ -30203,6 +30211,8 @@ function installProductViewTracking() {
30203
30211
  const roots = document.querySelectorAll(".primestyle-tryon-root");
30204
30212
  roots.forEach((r2) => maybeFireProductView(r2));
30205
30213
  }
30214
+ globalThis.__PRIMESTYLE_SHADOW_ACTIVE__ = true;
30215
+ const SDK_CSS = STYLES$1 + "\n\n" + SIZE_GUIDE_STYLES;
30206
30216
  const TAG = "[primestyle-tryon]";
30207
30217
  console.log(`${TAG} bundle loaded — version 5.7.x storefront entry`);
30208
30218
  installCartHook();
@@ -30291,10 +30301,35 @@ async function mount(el2) {
30291
30301
  props.sizeGuideData = fetched;
30292
30302
  }
30293
30303
  }
30304
+ let mountTarget = el2;
30305
+ try {
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;
30322
+ } catch (err) {
30323
+ console.warn(
30324
+ `${TAG} shadow attach failed — falling back to direct mount`,
30325
+ err
30326
+ );
30327
+ mountTarget = el2;
30328
+ }
30294
30329
  if (props.sizeGuideData) {
30295
30330
  try {
30296
30331
  const buttonStyles = props.buttonStyles;
30297
- createSizeGuideButton(el2, props.sizeGuideData, {
30332
+ createSizeGuideButton(mountTarget, props.sizeGuideData, {
30298
30333
  accentColor: buttonStyles?.backgroundColor
30299
30334
  });
30300
30335
  console.log(`${TAG} ✓ size guide button mounted`);
@@ -30303,7 +30338,7 @@ async function mount(el2) {
30303
30338
  }
30304
30339
  }
30305
30340
  try {
30306
- const root = createRoot(el2);
30341
+ const root = createRoot(mountTarget);
30307
30342
  root.render(reactExports.createElement(PrimeStyleTryon, props));
30308
30343
  MOUNTED.set(el2, root);
30309
30344
  console.log(`${TAG} ✓ mounted React component`);