@thefittingroom/shop-ui 5.0.24 → 5.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/dist/index.js +337 -71
  3. package/package.json +8 -3
package/README.md CHANGED
@@ -18,13 +18,30 @@ npm ci
18
18
  | Script | What it does |
19
19
  |---|---|
20
20
  | `npm run build` | Clean + production build into `dist/` |
21
- | `npm run check` | Type-check (`tsc --noEmit`) |
21
+ | `npm run check` | Type-check (`tsc --noEmit`) + ESLint + Prettier |
22
22
  | `npm run watch` | Vite build in watch mode |
23
23
  | `npm run serve` | Static-serve the repo on `:5173` with CORS |
24
24
  | `npm run watch-serve` | Both `watch` and `serve` together; Ctrl+C stops both |
25
+ | `npm run test` / `npm run test:e2e` | Playwright e2e suite (Chromium) against the built bundle |
26
+ | `npm run test:e2e:ui` | Playwright UI runner for local debugging |
25
27
  | `npm run gen-types` | Regenerate `src/api/gen/*.ts` from `tfr-backend` Go types |
26
28
  | `npm run promote-latest [version]` | Move npm dist-tag `latest` onto a published version (defaults to current `package.json` version) |
27
29
 
30
+ ## Running e2e tests
31
+
32
+ First-time setup: `npm install && npx playwright install chromium` (the
33
+ second command downloads the Chromium binary once; cached afterwards). Then
34
+ `npm run test:e2e` runs the suite. Specs live under `tests/e2e/`; they boot
35
+ the built bundle in headless Chromium against a static fixture page
36
+ (`tests/e2e/fixtures/host.html`) that mimics the minimal contract the Shopify
37
+ theme provides. Firebase is mocked via `InitParams.testHooks`
38
+ (see `src/lib/firebase-mock.ts`); REST endpoints are mocked via
39
+ `page.route()`. See AGENTS.md for the architecture and constraints.
40
+
41
+ For an interactive debug loop, `npm run watch-serve &` in one terminal and
42
+ `npm run test:e2e:ui` in another — Playwright reuses the live-rebuilt
43
+ `:5173`.
44
+
28
45
  ## Release process
29
46
 
30
47
  Releases are explicit, human-initiated steps via a single GitHub Actions
package/dist/index.js CHANGED
@@ -14054,7 +14054,7 @@ function applyFrameBaseUrl(url, baseUrl2) {
14054
14054
  return `${cleanBase}${url.startsWith("/") ? "" : "/"}${url}`;
14055
14055
  }
14056
14056
  const STORAGE_KEY = "tfr:fitting-room:v1";
14057
- const logger$f = getLogger("fitting-room-storage");
14057
+ const logger$g = getLogger("fitting-room-storage");
14058
14058
  function readAll() {
14059
14059
  try {
14060
14060
  const raw = window.localStorage.getItem(STORAGE_KEY);
@@ -14067,7 +14067,7 @@ function readAll() {
14067
14067
  }
14068
14068
  return parsed;
14069
14069
  } catch (error) {
14070
- logger$f.logWarn("Failed to read fitting room from localStorage", {
14070
+ logger$g.logWarn("Failed to read fitting room from localStorage", {
14071
14071
  error
14072
14072
  });
14073
14073
  return {};
@@ -14077,7 +14077,7 @@ function writeAll(all) {
14077
14077
  try {
14078
14078
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
14079
14079
  } catch (error) {
14080
- logger$f.logWarn("Failed to write fitting room to localStorage", {
14080
+ logger$g.logWarn("Failed to write fitting room to localStorage", {
14081
14081
  error
14082
14082
  });
14083
14083
  }
@@ -14103,7 +14103,7 @@ function _init$7() {
14103
14103
  }
14104
14104
  async function addFittingRoomItem(productId, handle, isPdp) {
14105
14105
  const state = useMainStore.getState();
14106
- logger$f.logDebug("{{ts}} - Adding to fitting room", {
14106
+ logger$g.logDebug("{{ts}} - Adding to fitting room", {
14107
14107
  productId,
14108
14108
  handle,
14109
14109
  isPdp
@@ -14125,7 +14125,7 @@ async function addFittingRoomItem(productId, handle, isPdp) {
14125
14125
  size = selection.size || null;
14126
14126
  color = selection.color;
14127
14127
  } catch (error) {
14128
- logger$f.logWarn("Failed to read selected options from currentProduct", {
14128
+ logger$g.logWarn("Failed to read selected options from currentProduct", {
14129
14129
  error
14130
14130
  });
14131
14131
  }
@@ -14157,7 +14157,7 @@ async function toggleFittingRoomItem(productId, handle, isPdp) {
14157
14157
  const state = useMainStore.getState();
14158
14158
  const isInFittingRoom = state.fittingRoom.some((item) => item.externalId === productId);
14159
14159
  if (isInFittingRoom) {
14160
- logger$f.logDebug("{{ts}} - Removing from fitting room", {
14160
+ logger$g.logDebug("{{ts}} - Removing from fitting room", {
14161
14161
  productId
14162
14162
  });
14163
14163
  state.removeFromFittingRoom(productId);
@@ -23058,7 +23058,7 @@ function isVersionServiceProvider(provider) {
23058
23058
  }
23059
23059
  const name$q = "@firebase/app";
23060
23060
  const version$1$1 = "0.14.5";
23061
- const logger$e = new Logger3("@firebase/app");
23061
+ const logger$f = new Logger3("@firebase/app");
23062
23062
  const name$p = "@firebase/app-compat";
23063
23063
  const name$o = "@firebase/analytics-compat";
23064
23064
  const name$n = "@firebase/analytics";
@@ -23125,13 +23125,13 @@ function _addComponent(app, component) {
23125
23125
  try {
23126
23126
  app.container.addComponent(component);
23127
23127
  } catch (e) {
23128
- logger$e.debug(`Component ${component.name} failed to register with FirebaseApp ${app.name}`, e);
23128
+ logger$f.debug(`Component ${component.name} failed to register with FirebaseApp ${app.name}`, e);
23129
23129
  }
23130
23130
  }
23131
23131
  function _registerComponent(component) {
23132
23132
  const componentName = component.name;
23133
23133
  if (_components.has(componentName)) {
23134
- logger$e.debug(`There were multiple attempts to register component ${componentName}.`);
23134
+ logger$f.debug(`There were multiple attempts to register component ${componentName}.`);
23135
23135
  return false;
23136
23136
  }
23137
23137
  _components.set(componentName, component);
@@ -23340,7 +23340,7 @@ function registerVersion(libraryKeyOrName, version2, variant) {
23340
23340
  if (versionMismatch) {
23341
23341
  warning.push(`version name "${version2}" contains illegal characters (whitespace or "/")`);
23342
23342
  }
23343
- logger$e.warn(warning.join(" "));
23343
+ logger$f.warn(warning.join(" "));
23344
23344
  return;
23345
23345
  }
23346
23346
  _registerComponent(new Component(
@@ -23384,12 +23384,12 @@ async function readHeartbeatsFromIndexedDB(app) {
23384
23384
  return result;
23385
23385
  } catch (e) {
23386
23386
  if (e instanceof FirebaseError) {
23387
- logger$e.warn(e.message);
23387
+ logger$f.warn(e.message);
23388
23388
  } else {
23389
23389
  const idbGetError = ERROR_FACTORY.create("idb-get", {
23390
23390
  originalErrorMessage: e?.message
23391
23391
  });
23392
- logger$e.warn(idbGetError.message);
23392
+ logger$f.warn(idbGetError.message);
23393
23393
  }
23394
23394
  }
23395
23395
  }
@@ -23402,12 +23402,12 @@ async function writeHeartbeatsToIndexedDB(app, heartbeatObject) {
23402
23402
  await tx.done;
23403
23403
  } catch (e) {
23404
23404
  if (e instanceof FirebaseError) {
23405
- logger$e.warn(e.message);
23405
+ logger$f.warn(e.message);
23406
23406
  } else {
23407
23407
  const idbGetError = ERROR_FACTORY.create("idb-set", {
23408
23408
  originalErrorMessage: e?.message
23409
23409
  });
23410
- logger$e.warn(idbGetError.message);
23410
+ logger$f.warn(idbGetError.message);
23411
23411
  }
23412
23412
  }
23413
23413
  }
@@ -23456,7 +23456,7 @@ class HeartbeatServiceImpl {
23456
23456
  }
23457
23457
  return this._storage.overwrite(this._heartbeatsCache);
23458
23458
  } catch (e) {
23459
- logger$e.warn(e);
23459
+ logger$f.warn(e);
23460
23460
  }
23461
23461
  }
23462
23462
  /**
@@ -23487,7 +23487,7 @@ class HeartbeatServiceImpl {
23487
23487
  }
23488
23488
  return headerString;
23489
23489
  } catch (e) {
23490
- logger$e.warn(e);
23490
+ logger$f.warn(e);
23491
23491
  return "";
23492
23492
  }
23493
23493
  }
@@ -41051,6 +41051,143 @@ registerAuth(
41051
41051
  "Browser"
41052
41052
  /* ClientPlatform.BROWSER */
41053
41053
  );
41054
+ const logger$e = getLogger("firebase-mock");
41055
+ class MockFirestoreManager {
41056
+ constructor(seedDocs = {}) {
41057
+ const cloned = {};
41058
+ for (const [coll, byId] of Object.entries(seedDocs)) {
41059
+ cloned[coll] = {
41060
+ ...byId
41061
+ };
41062
+ }
41063
+ this.docs = cloned;
41064
+ }
41065
+ async getDocData(collectionName, docId) {
41066
+ const doc2 = this.docs[collectionName]?.[docId];
41067
+ return doc2 ?? null;
41068
+ }
41069
+ async setDocData(collectionName, docId, data) {
41070
+ if (!this.docs[collectionName]) {
41071
+ this.docs[collectionName] = {};
41072
+ }
41073
+ this.docs[collectionName][docId] = data;
41074
+ }
41075
+ async mergeDocData(collectionName, docId, data) {
41076
+ if (!this.docs[collectionName]) {
41077
+ this.docs[collectionName] = {};
41078
+ }
41079
+ const existing = this.docs[collectionName][docId] ?? {};
41080
+ this.docs[collectionName][docId] = {
41081
+ ...existing,
41082
+ ...data
41083
+ };
41084
+ }
41085
+ listenToDoc(collectionName, docId, callback) {
41086
+ const doc2 = this.docs[collectionName]?.[docId];
41087
+ callback(doc2 ?? null);
41088
+ return () => {
41089
+ };
41090
+ }
41091
+ async queryDocs(collectionName, _constraints) {
41092
+ const entries = Object.values(this.docs[collectionName] ?? {});
41093
+ const docs = entries.map((data) => ({
41094
+ data: () => data
41095
+ }));
41096
+ const snapshot = {
41097
+ empty: docs.length === 0,
41098
+ docs,
41099
+ forEach: (cb) => docs.forEach(cb)
41100
+ };
41101
+ return snapshot;
41102
+ }
41103
+ }
41104
+ function makeMockAuthUser(seed) {
41105
+ const fake = {
41106
+ uid: seed.uid,
41107
+ email: seed.email,
41108
+ getIdToken: async (_forceRefresh) => seed.idToken
41109
+ };
41110
+ return fake;
41111
+ }
41112
+ class MockAuthManager {
41113
+ constructor(seed, firestore) {
41114
+ this.userProfile = null;
41115
+ this.authStateChangeListeners = /* @__PURE__ */ new Set();
41116
+ this.userProfileChangeListeners = /* @__PURE__ */ new Set();
41117
+ this.profileUnsub = null;
41118
+ this.seed = seed;
41119
+ this.currentUser = seed ? makeMockAuthUser(seed) : null;
41120
+ this.firestore = firestore;
41121
+ this.addAuthStateChangeListener((authUser) => this.handleAuthStateChanged(authUser));
41122
+ }
41123
+ addAuthStateChangeListener(callback) {
41124
+ this.authStateChangeListeners.add(callback);
41125
+ callback(this.currentUser);
41126
+ return () => {
41127
+ this.authStateChangeListeners.delete(callback);
41128
+ };
41129
+ }
41130
+ removeAuthStateChangeListener(callback) {
41131
+ this.authStateChangeListeners.delete(callback);
41132
+ }
41133
+ addUserProfileChangeListener(callback) {
41134
+ this.userProfileChangeListeners.add(callback);
41135
+ callback(this.userProfile);
41136
+ return () => {
41137
+ this.userProfileChangeListeners.delete(callback);
41138
+ };
41139
+ }
41140
+ removeUserProfileChangeListener(callback) {
41141
+ this.userProfileChangeListeners.delete(callback);
41142
+ }
41143
+ getAuthUser() {
41144
+ return this.currentUser;
41145
+ }
41146
+ async getAuthToken(_forceRefresh = false) {
41147
+ if (!this.currentUser || !this.seed) {
41148
+ throw new Error("No authenticated user");
41149
+ }
41150
+ return this.seed.idToken;
41151
+ }
41152
+ async getUserProfile(_forceRefresh = false) {
41153
+ return this.userProfile;
41154
+ }
41155
+ async login(_email, _password) {
41156
+ throw new Error("MockAuthManager.login is not supported — seed the user via InitParams.testHooks.auth");
41157
+ }
41158
+ async logout() {
41159
+ if (this.profileUnsub) {
41160
+ this.profileUnsub();
41161
+ this.profileUnsub = null;
41162
+ }
41163
+ this.currentUser = null;
41164
+ this.userProfile = null;
41165
+ this.authStateChangeListeners.forEach((cb) => cb(null));
41166
+ this.userProfileChangeListeners.forEach((cb) => cb(null));
41167
+ }
41168
+ async sendPasswordResetEmail(_email) {
41169
+ }
41170
+ async confirmPasswordReset(_code, _newPassword) {
41171
+ }
41172
+ handleAuthStateChanged(authUser) {
41173
+ if (this.profileUnsub) {
41174
+ this.profileUnsub();
41175
+ this.profileUnsub = null;
41176
+ }
41177
+ if (authUser) {
41178
+ logger$e.logDebug("Mock user logged in:", {
41179
+ uid: authUser.uid
41180
+ });
41181
+ this.profileUnsub = this.firestore.listenToDoc("users", authUser.uid, (profile) => {
41182
+ this.userProfile = profile;
41183
+ this.userProfileChangeListeners.forEach((cb) => cb(this.userProfile));
41184
+ });
41185
+ } else {
41186
+ this.userProfile = null;
41187
+ this.userProfileChangeListeners.forEach((cb) => cb(null));
41188
+ }
41189
+ }
41190
+ }
41054
41191
  const firebaseDateToDayjs = (date) => {
41055
41192
  return dayjs(date.seconds * 1e3);
41056
41193
  };
@@ -41238,8 +41375,24 @@ function getAuthManager() {
41238
41375
  async function _init$4() {
41239
41376
  const {
41240
41377
  brandId,
41241
- config
41378
+ config,
41379
+ testHooks
41242
41380
  } = getStaticData();
41381
+ if (testHooks !== void 0) {
41382
+ const seedDocs = {
41383
+ ...testHooks.firestore?.docs ?? {}
41384
+ };
41385
+ if (testHooks.auth?.profile) {
41386
+ seedDocs.users = {
41387
+ ...seedDocs.users ?? {},
41388
+ [testHooks.auth.uid]: testHooks.auth.profile
41389
+ };
41390
+ }
41391
+ firestoreManager = new MockFirestoreManager(seedDocs);
41392
+ authManager = new MockAuthManager(testHooks.auth ?? null, firestoreManager);
41393
+ logger$d.logDebug("Firebase initialized in MOCK mode (testHooks present)");
41394
+ return;
41395
+ }
41243
41396
  {
41244
41397
  const {
41245
41398
  firebase: sdkFirebaseConfig
@@ -42644,6 +42797,35 @@ function AddToCartButton({
42644
42797
  }) {
42645
42798
  return /* @__PURE__ */ jsx$1(ButtonT, { variant: "brand", t: "quick-view.add_to_cart", onClick });
42646
42799
  }
42800
+ function ColorSelector({
42801
+ availableColorLabels,
42802
+ selectedColorLabel,
42803
+ onChangeColor
42804
+ }) {
42805
+ const css2 = useCss((theme) => ({
42806
+ colorContainer: {},
42807
+ colorLabelText: {
42808
+ fontSize: "12px"
42809
+ },
42810
+ colorSelect: {
42811
+ border: "none",
42812
+ color: theme.color_fg_text,
42813
+ fontFamily: theme.font_family,
42814
+ fontSize: "12px"
42815
+ }
42816
+ }));
42817
+ const handleColorSelectChange = reactExports.useCallback((e) => {
42818
+ const newColorLabel = e.target.value || null;
42819
+ onChangeColor(newColorLabel);
42820
+ }, [onChangeColor]);
42821
+ if (availableColorLabels.length < 2) {
42822
+ return null;
42823
+ }
42824
+ return /* @__PURE__ */ jsx$1("div", { css: css2.colorContainer, children: /* @__PURE__ */ jsxs("label", { children: [
42825
+ /* @__PURE__ */ jsx$1(TextT, { variant: "base", css: css2.colorLabelText, t: "quick-view.color_label" }),
42826
+ /* @__PURE__ */ jsx$1("select", { value: selectedColorLabel ?? "", onChange: handleColorSelectChange, css: css2.colorSelect, children: availableColorLabels.map((colorLabel) => /* @__PURE__ */ jsx$1("option", { value: colorLabel, children: colorLabel }, colorLabel)) })
42827
+ ] }) });
42828
+ }
42647
42829
  function ItemFitDetails({
42648
42830
  loadedProductData,
42649
42831
  selectedSizeLabel
@@ -42754,6 +42936,7 @@ function DetailAccordionItem({
42754
42936
  onToggleOpen,
42755
42937
  onChangeDetailMode,
42756
42938
  onChangeSize,
42939
+ onChangeColor,
42757
42940
  onAddToCart,
42758
42941
  onToggleUntuck
42759
42942
  }) {
@@ -42789,13 +42972,23 @@ function DetailAccordionItem({
42789
42972
  }
42790
42973
  return null;
42791
42974
  }, [productData, item.storage.colorwaySizeAssetId]);
42975
+ const availableColorLabels = reactExports.useMemo(() => {
42976
+ if (!productData || !selectedSizeLabel) {
42977
+ return [];
42978
+ }
42979
+ const sizeRec = productData.sizes.find((s) => s.sizeLabel === selectedSizeLabel);
42980
+ if (!sizeRec) {
42981
+ return [];
42982
+ }
42983
+ return sizeRec.colors.map((c) => c.colorLabel).filter((label) => !!label);
42984
+ }, [productData, selectedSizeLabel]);
42792
42985
  const categoryLabel = item.styleCategory?.label_singular ?? item.styleCategory?.label ?? "";
42793
42986
  const productName = item.merchantProduct?.productName ?? item.externalId;
42794
42987
  const tuckable = !!item.styleCategory?.tuckable && canTuck;
42795
42988
  if (platform === "desktop") {
42796
- return /* @__PURE__ */ jsx$1(DesktopAccordionItem, { isOpen, categoryLabel, productData, currentPrice, selectedSizeLabel, onToggleOpen, onChangeSize, onAddToCart });
42989
+ return /* @__PURE__ */ jsx$1(DesktopAccordionItem, { isOpen, categoryLabel, productData, currentPrice, selectedSizeLabel, availableColorLabels, selectedColorLabel: item.storage.color, onToggleOpen, onChangeSize, onChangeColor, onAddToCart });
42797
42990
  }
42798
- return /* @__PURE__ */ jsx$1(MobileAccordionItem, { isOpen, categoryLabel, productName, productData, selectedSizeLabel, currentPrice, detailMode, isMobileQuickRow, tuckable, forceUntuck, onToggleOpen, onChangeDetailMode, onChangeSize, onAddToCart, onToggleUntuck });
42991
+ return /* @__PURE__ */ jsx$1(MobileAccordionItem, { isOpen, categoryLabel, productName, productData, selectedSizeLabel, availableColorLabels, selectedColorLabel: item.storage.color, currentPrice, detailMode, isMobileQuickRow, tuckable, forceUntuck, onToggleOpen, onChangeDetailMode, onChangeSize, onChangeColor, onAddToCart, onToggleUntuck });
42799
42992
  }
42800
42993
  function DesktopAccordionItem({
42801
42994
  isOpen,
@@ -42803,8 +42996,11 @@ function DesktopAccordionItem({
42803
42996
  productData,
42804
42997
  currentPrice,
42805
42998
  selectedSizeLabel,
42999
+ availableColorLabels,
43000
+ selectedColorLabel,
42806
43001
  onToggleOpen,
42807
43002
  onChangeSize,
43003
+ onChangeColor,
42808
43004
  onAddToCart
42809
43005
  }) {
42810
43006
  const ACCORDION_SHADE = "#F4F4F4";
@@ -42850,35 +43046,60 @@ function DesktopAccordionItem({
42850
43046
  price: {
42851
43047
  fontSize: "15px"
42852
43048
  },
43049
+ // Padding matches quick-view's sizeRecommendationFrame (32px / 56px) so
43050
+ // the "fit box" feels visually consistent between the two overlays.
43051
+ //
43052
+ // No flex `gap` — the three text lines (recommended size, fit text,
43053
+ // select-a-size prompt) sit tight against each other (matching
43054
+ // quick-view), with explicit marginTop on the size selector + fit
43055
+ // details below them to introduce the larger break.
42853
43056
  sizeBox: {
42854
43057
  width: "100%",
42855
43058
  border: `1px solid ${theme.color_fg_text}`,
42856
- padding: "20px 24px",
43059
+ padding: "32px 56px",
42857
43060
  display: "flex",
42858
43061
  flexDirection: "column",
42859
43062
  alignItems: "center",
42860
- gap: "12px",
42861
43063
  marginTop: "8px",
42862
43064
  textAlign: "center"
42863
43065
  },
43066
+ colorSelectorContainer: {
43067
+ width: "100%",
43068
+ marginTop: "8px"
43069
+ },
43070
+ // 14px / line-height 1.5 on these three text lines matches quick-view's
43071
+ // fit-box. Quick-view's first line wraps an InfoIcon button alongside
43072
+ // the recommended-size text, which stretches that line vertically; the
43073
+ // simpler line-height bump here matches the *visual* line spacing
43074
+ // without dragging the icon in. The two lines below it inherit
43075
+ // line-height from their containers in quick-view, which the host page's
43076
+ // body styles tend to set looser than Inter's intrinsic `normal` (~1.21).
42864
43077
  recommendedSize: {
42865
- fontSize: "13px",
42866
- fontWeight: "600"
43078
+ fontSize: "14px",
43079
+ fontWeight: "600",
43080
+ lineHeight: 1.5
42867
43081
  },
42868
43082
  selectPrompt: {
42869
- fontSize: "12px"
43083
+ fontSize: "14px",
43084
+ lineHeight: 1.5
42870
43085
  },
42871
43086
  fitText: {
42872
- fontSize: "12px"
43087
+ fontSize: "14px",
43088
+ lineHeight: 1.5,
43089
+ // Tight 8px lift to the recommended-size line above; matches
43090
+ // quick-view's `itemFitContainer` marginTop.
43091
+ marginTop: "8px"
42873
43092
  },
42874
43093
  fitDetails: {
42875
- width: "100%"
43094
+ width: "100%",
43095
+ marginTop: "24px"
42876
43096
  },
42877
43097
  sizeRow: {
42878
43098
  display: "flex",
42879
43099
  gap: "8px",
42880
43100
  alignItems: "center",
42881
- justifyContent: "center"
43101
+ justifyContent: "center",
43102
+ marginTop: "24px"
42882
43103
  },
42883
43104
  cartContainer: {
42884
43105
  width: "100%",
@@ -42899,6 +43120,7 @@ function DesktopAccordionItem({
42899
43120
  !isOpen ? null : /* @__PURE__ */ jsx$1("div", { css: css2.body, children: productData ? /* @__PURE__ */ jsxs(Fragment, { children: [
42900
43121
  /* @__PURE__ */ jsx$1(Text, { variant: "brand", css: css2.productName, children: productData.productName }),
42901
43122
  currentPrice ? /* @__PURE__ */ jsx$1(Text, { variant: "base", css: css2.price, children: currentPrice }) : null,
43123
+ /* @__PURE__ */ jsx$1("div", { css: css2.colorSelectorContainer, children: /* @__PURE__ */ jsx$1(ColorSelector, { availableColorLabels, selectedColorLabel, onChangeColor }) }),
42902
43124
  /* @__PURE__ */ jsxs("div", { css: css2.sizeBox, children: [
42903
43125
  /* @__PURE__ */ jsxs(Text, { variant: "base", css: css2.recommendedSize, children: [
42904
43126
  "Recommended Size: ",
@@ -42922,6 +43144,8 @@ function MobileAccordionItem({
42922
43144
  productName,
42923
43145
  productData,
42924
43146
  selectedSizeLabel,
43147
+ availableColorLabels,
43148
+ selectedColorLabel,
42925
43149
  currentPrice,
42926
43150
  detailMode,
42927
43151
  isMobileQuickRow,
@@ -42930,6 +43154,7 @@ function MobileAccordionItem({
42930
43154
  onToggleOpen,
42931
43155
  onChangeDetailMode,
42932
43156
  onChangeSize,
43157
+ onChangeColor,
42933
43158
  onAddToCart,
42934
43159
  onToggleUntuck
42935
43160
  }) {
@@ -43069,6 +43294,7 @@ function MobileAccordionItem({
43069
43294
  ] }),
43070
43295
  isMobileQuickRow ? /* @__PURE__ */ jsx$1("div", { css: css2.content, children: /* @__PURE__ */ jsx$1("div", { css: css2.quickRow, children: productData ? /* @__PURE__ */ jsx$1(SizeSelector, { loadedProductData: productData, selectedSizeLabel, onChangeSize }) : null }) }) : !isOpen ? null : /* @__PURE__ */ jsx$1("div", { css: css2.content, children: /* @__PURE__ */ jsx$1("div", { css: css2.body, children: productData ? /* @__PURE__ */ jsxs(Fragment, { children: [
43071
43296
  /* @__PURE__ */ jsx$1("div", { css: css2.sizeRow, children: /* @__PURE__ */ jsx$1(SizeSelector, { loadedProductData: productData, selectedSizeLabel, onChangeSize }) }),
43297
+ /* @__PURE__ */ jsx$1(ColorSelector, { availableColorLabels, selectedColorLabel, onChangeColor }),
43072
43298
  /* @__PURE__ */ jsx$1(ItemFitText, { loadedProductData: productData }),
43073
43299
  /* @__PURE__ */ jsx$1("div", { css: css2.fitDetailsContainer, children: /* @__PURE__ */ jsx$1(ItemFitDetails, { loadedProductData: productData, selectedSizeLabel }) }),
43074
43300
  /* @__PURE__ */ jsx$1("div", { css: css2.buttonContainer, children: /* @__PURE__ */ jsx$1(AddToCartButton, { onClick: onAddToCart }) }),
@@ -43095,6 +43321,7 @@ function DetailAccordion({
43095
43321
  onOpenItem,
43096
43322
  onChangeDetailMode,
43097
43323
  onChangeSize,
43324
+ onChangeColor,
43098
43325
  onAddToCart,
43099
43326
  onToggleUntuck
43100
43327
  }) {
@@ -43109,9 +43336,11 @@ function DetailAccordion({
43109
43336
  gap
43110
43337
  }, children: items.map((item) => {
43111
43338
  const isOpen = openItemExternalId === item.externalId;
43112
- return /* @__PURE__ */ jsx$1(DetailAccordionItem, { item, isOpen, platform, detailMode, isMobileQuickRow, forceUntuck, canTuck, onToggleOpen: () => onOpenItem(isOpen ? null : item.externalId), onChangeDetailMode, onChangeSize: (label) => onChangeSize(item.externalId, label), onAddToCart: () => onAddToCart(item.externalId), onToggleUntuck }, item.externalId);
43339
+ return /* @__PURE__ */ jsx$1(DetailAccordionItem, { item, isOpen, platform, detailMode, isMobileQuickRow, forceUntuck, canTuck, onToggleOpen: () => onOpenItem(isOpen ? null : item.externalId), onChangeDetailMode, onChangeSize: (label) => onChangeSize(item.externalId, label), onChangeColor: (label) => onChangeColor(item.externalId, label), onAddToCart: () => onAddToCart(item.externalId), onToggleUntuck }, item.externalId);
43113
43340
  }) });
43114
43341
  }
43342
+ const AXIS_LOCK_PX = 8;
43343
+ const ROTATE_STEP_PX = 50;
43115
43344
  function ZoomModal({
43116
43345
  frameUrls,
43117
43346
  selectedFrameIndex,
@@ -43131,10 +43360,50 @@ function ZoomModal({
43131
43360
  }, [onClose]);
43132
43361
  const {
43133
43362
  rotateLeft,
43134
- rotateRight,
43135
- handleMouseDragStart,
43136
- handleTouchDragStart
43363
+ rotateRight
43137
43364
  } = useFrameRotation(frameUrls, setSelectedFrameIndex);
43365
+ const scrollAreaRef = reactExports.useRef(null);
43366
+ const handleImageMouseDown = reactExports.useCallback((e) => {
43367
+ e.preventDefault();
43368
+ const startX = e.clientX;
43369
+ const startY = e.clientY;
43370
+ const scrollArea = scrollAreaRef.current;
43371
+ const startScrollTop = scrollArea?.scrollTop ?? 0;
43372
+ let mode = "unknown";
43373
+ let lastRotateX = startX;
43374
+ const onMove = (move) => {
43375
+ const deltaX = move.clientX - startX;
43376
+ const deltaY = move.clientY - startY;
43377
+ if (mode === "unknown") {
43378
+ const absX = Math.abs(deltaX);
43379
+ const absY = Math.abs(deltaY);
43380
+ if (absX < AXIS_LOCK_PX && absY < AXIS_LOCK_PX) {
43381
+ return;
43382
+ }
43383
+ mode = absY > absX ? "scroll" : "rotate";
43384
+ lastRotateX = move.clientX;
43385
+ }
43386
+ if (mode === "scroll" && scrollArea) {
43387
+ scrollArea.scrollTop = startScrollTop - deltaY;
43388
+ } else if (mode === "rotate") {
43389
+ const rotateDelta = move.clientX - lastRotateX;
43390
+ if (Math.abs(rotateDelta) >= ROTATE_STEP_PX) {
43391
+ if (rotateDelta > 0) {
43392
+ rotateRight();
43393
+ } else {
43394
+ rotateLeft();
43395
+ }
43396
+ lastRotateX = move.clientX;
43397
+ }
43398
+ }
43399
+ };
43400
+ const onUp = () => {
43401
+ window.removeEventListener("mousemove", onMove);
43402
+ window.removeEventListener("mouseup", onUp);
43403
+ };
43404
+ window.addEventListener("mousemove", onMove);
43405
+ window.addEventListener("mouseup", onUp);
43406
+ }, [rotateLeft, rotateRight]);
43138
43407
  const css2 = useCss((_theme) => ({
43139
43408
  backdrop: {
43140
43409
  position: "fixed",
@@ -43213,7 +43482,7 @@ function ZoomModal({
43213
43482
  }));
43214
43483
  const imageUrl = frameUrls[selectedFrameIndex ?? 0];
43215
43484
  return /* @__PURE__ */ jsxs("div", { css: css2.backdrop, children: [
43216
- /* @__PURE__ */ jsx$1("div", { css: css2.scrollArea, children: /* @__PURE__ */ jsx$1("div", { css: css2.imageWrap, children: /* @__PURE__ */ jsx$1("img", { src: imageUrl, css: css2.image, alt: "", onMouseDown: handleMouseDragStart, onTouchStart: handleTouchDragStart }) }) }),
43485
+ /* @__PURE__ */ jsx$1("div", { ref: scrollAreaRef, css: css2.scrollArea, children: /* @__PURE__ */ jsx$1("div", { css: css2.imageWrap, children: /* @__PURE__ */ jsx$1("img", { src: imageUrl, css: css2.image, alt: "", onMouseDown: handleImageMouseDown }) }) }),
43217
43486
  /* @__PURE__ */ jsx$1("div", { css: /* @__PURE__ */ css$1({
43218
43487
  ...css2.chevron,
43219
43488
  ...css2.chevronLeft
@@ -43246,6 +43515,7 @@ function DesktopLayout$1({
43246
43515
  onOpenAccordionItem,
43247
43516
  onChangeDetailMode,
43248
43517
  onChangeSize,
43518
+ onChangeColor,
43249
43519
  onAddToCart,
43250
43520
  onToggleUntuck,
43251
43521
  onSignOut
@@ -43342,7 +43612,7 @@ function DesktopLayout$1({
43342
43612
  gridTemplateColumns
43343
43613
  }, children: [
43344
43614
  /* @__PURE__ */ jsx$1("div", { css: css2.avatarColumn, onMouseEnter: () => setAvatarHovered(true), onMouseLeave: () => setAvatarHovered(false), children: /* @__PURE__ */ jsx$1(AvatarPane, { hasSelection, frameUrls, controls, selectedFrameIndex, setSelectedFrameIndex }) }),
43345
- hasSelection ? /* @__PURE__ */ jsx$1("div", { css: css2.detailColumn, children: /* @__PURE__ */ jsx$1(DetailAccordion, { items: selectedItems, openItemExternalId: openAccordionItemId, platform: "desktop", detailMode, isMobileQuickRow: false, forceUntuck, canTuck, onOpenItem: onOpenAccordionItem, onChangeDetailMode, onChangeSize, onAddToCart, onToggleUntuck }) }) : null,
43615
+ hasSelection ? /* @__PURE__ */ jsx$1("div", { css: css2.detailColumn, children: /* @__PURE__ */ jsx$1(DetailAccordion, { items: selectedItems, openItemExternalId: openAccordionItemId, platform: "desktop", detailMode, isMobileQuickRow: false, forceUntuck, canTuck, onOpenItem: onOpenAccordionItem, onChangeDetailMode, onChangeSize, onChangeColor, onAddToCart, onToggleUntuck }) }) : null,
43346
43616
  /* @__PURE__ */ jsxs("div", { css: css2.railsColumn, children: [
43347
43617
  /* @__PURE__ */ jsxs("span", { css: css2.signOutWrapper, onClick: onSignOut, children: [
43348
43618
  /* @__PURE__ */ jsx$1(SvgTfrIcon, { css: css2.signOutIcon }),
@@ -43477,13 +43747,14 @@ function MobileLayout$1({
43477
43747
  onOpenAccordionItem,
43478
43748
  onChangeDetailMode,
43479
43749
  onChangeSize,
43750
+ onChangeColor,
43480
43751
  onAddToCart,
43481
43752
  onToggleUntuck
43482
43753
  }) {
43483
43754
  if (mode === "browse") {
43484
43755
  return /* @__PURE__ */ jsx$1(BrowseView, { resolved, availabilityByExternalId, selectedCount: selectedItems.length, onSelectItem, onRemoveItem, onTryItOn });
43485
43756
  }
43486
- return /* @__PURE__ */ jsx$1(TryOnView, { selectedItems, openAccordionItemId, detailMode, forceUntuck, canTuck, frameUrls, sheetSnap, sheetTouchStart, onBackToBrowse, onOpenAccordionItem, onChangeDetailMode, onChangeSize, onAddToCart, onToggleUntuck });
43757
+ return /* @__PURE__ */ jsx$1(TryOnView, { selectedItems, openAccordionItemId, detailMode, forceUntuck, canTuck, frameUrls, sheetSnap, sheetTouchStart, onBackToBrowse, onOpenAccordionItem, onChangeDetailMode, onChangeSize, onChangeColor, onAddToCart, onToggleUntuck });
43487
43758
  }
43488
43759
  function BrowseView({
43489
43760
  resolved,
@@ -43583,6 +43854,7 @@ function TryOnView({
43583
43854
  onOpenAccordionItem,
43584
43855
  onChangeDetailMode,
43585
43856
  onChangeSize,
43857
+ onChangeColor,
43586
43858
  onAddToCart,
43587
43859
  onToggleUntuck
43588
43860
  }) {
@@ -43683,7 +43955,7 @@ function TryOnView({
43683
43955
  /* @__PURE__ */ jsx$1(SvgDragHandle, {}),
43684
43956
  /* @__PURE__ */ jsx$1(Text, { variant: "base", css: css2.sheetTitle, children: "RECOMMENDED SIZES" })
43685
43957
  ] }),
43686
- sheetSnap === "collapsed" ? null : /* @__PURE__ */ jsx$1("div", { css: css2.sheetContent, children: /* @__PURE__ */ jsx$1(DetailAccordion, { items: selectedItems, openItemExternalId: openAccordionItemId, platform: "mobile", detailMode, isMobileQuickRow, forceUntuck, canTuck, onOpenItem: onOpenAccordionItem, onChangeDetailMode, onChangeSize, onAddToCart, onToggleUntuck }) })
43958
+ sheetSnap === "collapsed" ? null : /* @__PURE__ */ jsx$1("div", { css: css2.sheetContent, children: /* @__PURE__ */ jsx$1(DetailAccordion, { items: selectedItems, openItemExternalId: openAccordionItemId, platform: "mobile", detailMode, isMobileQuickRow, forceUntuck, canTuck, onOpenItem: onOpenAccordionItem, onChangeDetailMode, onChangeSize, onChangeColor, onAddToCart, onToggleUntuck }) })
43687
43959
  ] }) })
43688
43960
  ] });
43689
43961
  }
@@ -43956,6 +44228,25 @@ function FittingRoomOverlay({
43956
44228
  color: csa.colorLabel
43957
44229
  });
43958
44230
  }, [resolved.items, updateFittingRoomItem]);
44231
+ const handleChangeColor = reactExports.useCallback((externalId, colorLabel) => {
44232
+ const item = resolved.items.find((i) => i.externalId === externalId);
44233
+ if (!item || !item.storage.size) {
44234
+ return;
44235
+ }
44236
+ const productData = buildVtoProductDataFromResolved(item);
44237
+ if (!productData) {
44238
+ return;
44239
+ }
44240
+ const csa = findCsaByLabel(productData, item.storage.size, colorLabel);
44241
+ if (!csa) {
44242
+ return;
44243
+ }
44244
+ updateFittingRoomItem(externalId, {
44245
+ colorwaySizeAssetId: csa.colorwaySizeAssetId,
44246
+ size: item.storage.size,
44247
+ color: csa.colorLabel
44248
+ });
44249
+ }, [resolved.items, updateFittingRoomItem]);
43959
44250
  const handleAddToCart = reactExports.useCallback(async (externalId) => {
43960
44251
  const {
43961
44252
  addToCart
@@ -44148,7 +44439,7 @@ function FittingRoomOverlay({
44148
44439
  /* @__PURE__ */ jsx$1(TextT, { variant: "base", css: css2.emptyTagline, t: "landing.description" }),
44149
44440
  /* @__PURE__ */ jsx$1("div", { css: css2.emptyShopNow, children: /* @__PURE__ */ jsx$1(ButtonT, { variant: "primary", t: "fitting_room.shop_now", onClick: handleShopNow }) }),
44150
44441
  userIsLoggedIn ? /* @__PURE__ */ jsx$1(LinkT, { variant: "underline", css: css2.emptySignOut, t: "fitting_room.sign_out", onClick: handleSignOut }) : null
44151
- ] }) }) : isMobileLayout ? /* @__PURE__ */ jsx$1(MobileLayout$1, { mode: mobileMode, resolved, selectedItems, availabilityByExternalId, openAccordionItemId, detailMode, forceUntuck, canTuck, frameUrls, sheetSnap, sheetTouchStart, onSelectItem: handleSelectItem, onRemoveItem: handleRemoveItem, onTryItOn: handleTryItOn, onBackToBrowse: handleBackToBrowse, onOpenAccordionItem: setOpenAccordionItemId, onChangeDetailMode: setDetailMode, onChangeSize: handleChangeSize, onAddToCart: handleAddToCart, onToggleUntuck: handleToggleUntuck }) : /* @__PURE__ */ jsx$1(DesktopLayout$1, { resolved, selectedItems, availabilityByExternalId, openAccordionItemId, detailMode, forceUntuck, canTuck, frameUrls, onSelectItem: handleSelectItem, onRemoveItem: handleRemoveItem, onOpenAccordionItem: setOpenAccordionItemId, onChangeDetailMode: setDetailMode, onChangeSize: handleChangeSize, onAddToCart: handleAddToCart, onToggleUntuck: handleToggleUntuck, onSignOut: handleSignOut }),
44442
+ ] }) }) : isMobileLayout ? /* @__PURE__ */ jsx$1(MobileLayout$1, { mode: mobileMode, resolved, selectedItems, availabilityByExternalId, openAccordionItemId, detailMode, forceUntuck, canTuck, frameUrls, sheetSnap, sheetTouchStart, onSelectItem: handleSelectItem, onRemoveItem: handleRemoveItem, onTryItOn: handleTryItOn, onBackToBrowse: handleBackToBrowse, onOpenAccordionItem: setOpenAccordionItemId, onChangeDetailMode: setDetailMode, onChangeSize: handleChangeSize, onChangeColor: handleChangeColor, onAddToCart: handleAddToCart, onToggleUntuck: handleToggleUntuck }) : /* @__PURE__ */ jsx$1(DesktopLayout$1, { resolved, selectedItems, availabilityByExternalId, openAccordionItemId, detailMode, forceUntuck, canTuck, frameUrls, onSelectItem: handleSelectItem, onRemoveItem: handleRemoveItem, onOpenAccordionItem: setOpenAccordionItemId, onChangeDetailMode: setDetailMode, onChangeSize: handleChangeSize, onChangeColor: handleChangeColor, onAddToCart: handleAddToCart, onToggleUntuck: handleToggleUntuck, onSignOut: handleSignOut }),
44152
44443
  vtoError ? /* @__PURE__ */ jsx$1(Snackbar, { messageKey: "fitting_room.vto_error", onDismiss: clearVtoError }) : null
44153
44444
  ] }) });
44154
44445
  }
@@ -44942,7 +45233,9 @@ function QuickViewOverlay() {
44942
45233
  const {
44943
45234
  color: selectedColor
44944
45235
  } = await currentProduct.getSelectedOptions();
44945
- const styleCategoryLabel = storeProduct.style.style_category_label || null;
45236
+ const styleCategoryIndex = await loadStyleCategoryIndex();
45237
+ const styleCategoryGroup = styleCategoryIndex.groupForCategory(storeProduct.style.style_category_name);
45238
+ const styleCategoryLabel = styleCategoryGroup?.label ?? null;
44946
45239
  const sizeRecommendationRecord = storeProduct.sizeFitRecommendation;
44947
45240
  {
44948
45241
  const recommendedSizeId = sizeRecommendationRecord.recommended_size.id || null;
@@ -45892,35 +46185,6 @@ function ProductSummaryRow({
45892
46185
  /* @__PURE__ */ jsx$1("div", { css: css2.nameContainer, children: /* @__PURE__ */ jsx$1(Text, { variant: "brand", css: css2.nameText, children: loadedProductData.productName }) })
45893
46186
  ] });
45894
46187
  }
45895
- function ColorSelector({
45896
- availableColorLabels,
45897
- selectedColorLabel,
45898
- onChangeColor
45899
- }) {
45900
- const css2 = useCss((theme) => ({
45901
- colorContainer: {},
45902
- colorLabelText: {
45903
- fontSize: "12px"
45904
- },
45905
- colorSelect: {
45906
- border: "none",
45907
- color: theme.color_fg_text,
45908
- fontFamily: theme.font_family,
45909
- fontSize: "12px"
45910
- }
45911
- }));
45912
- const handleColorSelectChange = reactExports.useCallback((e) => {
45913
- const newColorLabel = e.target.value || null;
45914
- onChangeColor(newColorLabel);
45915
- }, []);
45916
- if (availableColorLabels.length < 2) {
45917
- return null;
45918
- }
45919
- return /* @__PURE__ */ jsx$1("div", { css: css2.colorContainer, children: /* @__PURE__ */ jsxs("label", { children: [
45920
- /* @__PURE__ */ jsx$1(TextT, { variant: "base", css: css2.colorLabelText, t: "quick-view.color_label" }),
45921
- /* @__PURE__ */ jsx$1("select", { value: selectedColorLabel ?? "", onChange: handleColorSelectChange, css: css2.colorSelect, children: availableColorLabels.map((colorLabel) => /* @__PURE__ */ jsx$1("option", { value: colorLabel, children: colorLabel }, colorLabel)) })
45922
- ] }) });
45923
- }
45924
46188
  function RecommendedSizeText({
45925
46189
  loadedProductData,
45926
46190
  textCss
@@ -46501,9 +46765,9 @@ const SHARED_CONFIG = {
46501
46765
  appGooglePlayUrl: "https://play.google.com/store/apps/details?id=com.thefittingroom.marketplace"
46502
46766
  },
46503
46767
  build: {
46504
- version: `${"5.0.24"}`,
46505
- commitHash: `${"931e0b2"}`,
46506
- date: `${"2026-05-19T21:17:10.057Z"}`
46768
+ version: `${"5.0.25"}`,
46769
+ commitHash: `${"101d833"}`,
46770
+ date: `${"2026-05-22T00:57:54.147Z"}`
46507
46771
  }
46508
46772
  };
46509
46773
  const CONFIGS = {
@@ -46650,7 +46914,8 @@ async function init(initParams) {
46650
46914
  debug,
46651
46915
  productLookup,
46652
46916
  getOverlayTopOffset,
46653
- addToCart
46917
+ addToCart,
46918
+ testHooks
46654
46919
  } = initParams;
46655
46920
  if (!brandId || typeof brandId !== "number" || isNaN(brandId) || brandId <= 0) {
46656
46921
  throw new Error(`Invalid brandId "${brandId}"`);
@@ -46676,7 +46941,8 @@ async function init(initParams) {
46676
46941
  config,
46677
46942
  productLookup: productLookup ?? null,
46678
46943
  getOverlayTopOffset: getOverlayTopOffset ?? null,
46679
- addToCart: addToCart ?? null
46944
+ addToCart: addToCart ?? null,
46945
+ testHooks
46680
46946
  });
46681
46947
  _init$7();
46682
46948
  _init$5();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thefittingroom/shop-ui",
3
- "version": "5.0.24",
3
+ "version": "5.0.25",
4
4
  "description": "the fitting room UI library",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,8 +22,12 @@
22
22
  "promote-latest": "sh scripts/promote-latest.sh",
23
23
  "lint": "eslint .",
24
24
  "lint:fix": "eslint . --fix",
25
- "format": "prettier --write src",
26
- "format:check": "prettier --check src"
25
+ "format": "prettier --write src tests playwright.config.ts",
26
+ "format:check": "prettier --check src tests playwright.config.ts",
27
+ "test": "npm run test:e2e",
28
+ "test:e2e": "playwright test",
29
+ "test:e2e:debug": "PWDEBUG=1 playwright test",
30
+ "test:e2e:ui": "playwright test --ui"
27
31
  },
28
32
  "engines": {
29
33
  "node": ">=22"
@@ -33,6 +37,7 @@
33
37
  },
34
38
  "devDependencies": {
35
39
  "@eslint/js": "^9.39.4",
40
+ "@playwright/test": "^1.60.0",
36
41
  "@types/react": "^19.2.7",
37
42
  "@types/react-dom": "^19.2.3",
38
43
  "@types/react-modal": "^3.16.3",