@akinon/pz-theme 2.0.37-rc.0 → 2.0.37

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 (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,691 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render, waitFor } from '@testing-library/react';
3
+ import StoreLocatorBlock from '../store-locator-block';
4
+ import { ThemePageContextProvider } from '../../theme-page-context';
5
+
6
+ /**
7
+ * The store list is the block a brand already has on the page, and the find-in-
8
+ * store form is new capability bolted beside it. Two things have to hold: the
9
+ * list a published composition renders today cannot move by a byte, and the
10
+ * form that opens over the page has to be a real dialog rather than a div that
11
+ * looks like one.
12
+ *
13
+ * The golden below was captured by rendering the block at the commit BEFORE
14
+ * this work and dumping `container.innerHTML`; it is pasted here verbatim, so
15
+ * it cannot be regenerated from the code it is meant to police.
16
+ */
17
+ const GOLDEN: Record<string, string> = {
18
+ "default":
19
+ "<div data-store-locator=\"\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+902123531500\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=41.0678,29.0092\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+903125411000\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=39.9497,32.8241\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div>",
20
+ "default-designer":
21
+ "<div data-store-locator=\"\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"#\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"#\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"#\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"#\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div>",
22
+ "grid":
23
+ "<div data-store-locator=\"\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><style>[data-store-locator-id=\"sl-1\"]{grid-template-columns:repeat(2,minmax(0,1fr));}@media (max-width:1023px){[data-store-locator-id=\"sl-1\"]{grid-template-columns:repeat(2,minmax(0,1fr));}}</style><ul data-part=\"list\" data-store-locator-id=\"sl-1\" style=\"display: grid; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+903125411000\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=39.9497,32.8241\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+902123531500\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=41.0678,29.0092\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div>",
24
+ "no-filter":
25
+ "<div data-store-locator=\"\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div>",
26
+ "distance":
27
+ "<div data-store-locator=\"\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><button type=\"button\" data-store-locator-location=\"\" data-part=\"location-button\" style=\"display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; background: transparent; cursor: pointer;\">Use my location</button><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+902123531500\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=41.0678,29.0092\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+903125411000\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=39.9497,32.8241\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div>",
28
+ // The trigger form, captured the same way at the same commit. It is newer
29
+ // than the list above and unreleased, but "unreleased" is exactly when a
30
+ // golden is worth having: `aria-haspopup` on the trigger, `aria-hidden` on
31
+ // the backdrop, `role`/`aria-modal`/`aria-label`/`tabindex` on the panel and
32
+ // the placement box are all load-bearing, and none of them is obvious enough
33
+ // to survive a refactor by luck.
34
+ "trigger":
35
+ "<div data-store-locator=\"\" data-store-locator-mode=\"trigger\" style=\"position: relative;\"><button type=\"button\" data-store-locator-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"false\" style=\"display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; background: transparent; cursor: pointer;\">Find in store</button></div>",
36
+ "trigger-open":
37
+ "<div data-store-locator=\"\" data-store-locator-mode=\"trigger\" style=\"position: relative;\"><button type=\"button\" data-store-locator-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"true\" style=\"display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; background: transparent; cursor: pointer;\" data-state=\"open\">Find in store</button><div data-store-locator-backdrop=\"\" data-part=\"backdrop\" aria-hidden=\"true\" style=\"position: fixed; inset: 0; z-index: 40;\"></div><div data-store-locator-panel=\"\" data-part=\"panel\" data-state=\"open\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Find in store\" tabindex=\"-1\" style=\"position: fixed; z-index: 41; display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); padding: 20px; overflow-y: auto; top: 50%; left: 50%; transform: translate(-50%, -50%); max-height: calc(100% - 64px);\"><div style=\"display: flex; align-items: center; justify-content: space-between; gap: 12px;\"><span data-store-locator-panel-title=\"\" data-part=\"panel-title\" style=\"font-weight: 600;\">Find in store</span><button type=\"button\" data-store-locator-panel-close=\"\" data-part=\"panel-close\" style=\"background: transparent; cursor: pointer; padding: 4px;\">Close</button></div><div style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+902123531500\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=41.0678,29.0092\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+903125411000\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=39.9497,32.8241\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div></div></div>",
38
+ "trigger-open-designer":
39
+ "<div data-store-locator=\"\" data-store-locator-mode=\"trigger\" style=\"position: relative;\"><button type=\"button\" data-store-locator-trigger=\"\" data-part=\"trigger\" data-state=\"open\" aria-haspopup=\"dialog\" aria-expanded=\"true\" style=\"display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; background: transparent; cursor: pointer;\">Find in store</button><div data-store-locator-backdrop=\"\" data-part=\"backdrop\" aria-hidden=\"true\" style=\"position: fixed; inset: 0; z-index: 40;\"></div><div data-store-locator-panel=\"\" data-part=\"panel\" data-state=\"open\" role=\"dialog\" aria-label=\"Find in store\" tabindex=\"-1\" style=\"position: fixed; z-index: 41; display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); padding: 20px; overflow-y: auto; top: 0px; right: 0px; bottom: 0px;\"><div style=\"display: flex; align-items: center; justify-content: space-between; gap: 12px;\"><span data-store-locator-panel-title=\"\" data-part=\"panel-title\" style=\"font-weight: 600;\">Find in store</span><button type=\"button\" data-store-locator-panel-close=\"\" data-part=\"panel-close\" style=\"background: transparent; cursor: pointer; padding: 4px;\">Close</button></div><div style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"#\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"#\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"#\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"#\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div></div></div>"
40
+ };
41
+
42
+ /**
43
+ * `innerHTML` records the order attributes were ADDED in rather than the
44
+ * markup: one that first appears on a later commit is appended at the end. The
45
+ * trigger goldens are therefore compared attribute-set by attribute-set — every
46
+ * element, every attribute and every value still has to match exactly, but a
47
+ * value that arrives one commit later is not a golden break.
48
+ */
49
+ const canonical = (html: string): string => {
50
+ const host = document.createElement('div');
51
+ host.innerHTML = html;
52
+
53
+ const sortAttributes = (element: Element) => {
54
+ const pairs = element
55
+ .getAttributeNames()
56
+ .sort()
57
+ .map((name) => [name, element.getAttribute(name) ?? ''] as const);
58
+
59
+ pairs.forEach(([name]) => element.removeAttribute(name));
60
+ pairs.forEach(([name, value]) => element.setAttribute(name, value));
61
+ Array.from(element.children).forEach(sortAttributes);
62
+ };
63
+
64
+ Array.from(host.children).forEach(sortAttributes);
65
+
66
+ return host.innerHTML;
67
+ };
68
+
69
+ /** The store rows the golden was captured against — they may not be edited. */
70
+ const GOLDEN_STORES = [
71
+ {
72
+ pk: 1,
73
+ name: 'Kanyon AVM',
74
+ address: 'Büyükdere Cad. No:185',
75
+ phone_number: '+902123531500',
76
+ latitude: '41.0678',
77
+ longitude: '29.0092',
78
+ township: { name: 'Şişli', city: { name: 'İstanbul' } }
79
+ },
80
+ {
81
+ pk: 2,
82
+ name: 'Ankamall',
83
+ address: 'Akköprü Mah.',
84
+ phone_number: '+903125411000',
85
+ latitude: '39.9497',
86
+ longitude: '32.8241',
87
+ city: { name: 'Ankara' }
88
+ },
89
+ {
90
+ pk: 3,
91
+ name: 'Merkez',
92
+ address: '',
93
+ phone_number: '',
94
+ latitude: '0',
95
+ longitude: '0',
96
+ city: 'İzmir'
97
+ }
98
+ ];
99
+
100
+ let mockStores: unknown[] = [];
101
+ let mockCities: unknown[] = [];
102
+ let mockStockRows: unknown[] = [];
103
+ let mockStockRejects = false;
104
+ const stockCalls: unknown[] = [];
105
+
106
+ jest.mock('@akinon/next/data/client/address', () => ({
107
+ __esModule: true,
108
+ useGetStoresQuery: () => ({
109
+ data: { results: mockStores },
110
+ isLoading: false
111
+ }),
112
+ useGetRetailStoreQuery: () => ({ data: { results: mockCities } }),
113
+ useGetRetailStoreStockMutation: () => [
114
+ (args: unknown) => {
115
+ stockCalls.push(args);
116
+ return {
117
+ unwrap: () =>
118
+ mockStockRejects
119
+ ? Promise.reject(new Error('commerce is down'))
120
+ : Promise.resolve(mockStockRows)
121
+ };
122
+ }
123
+ ]
124
+ }));
125
+
126
+ const PRODUCT_CONTEXT = {
127
+ product: {
128
+ pk: 4242,
129
+ name: 'Oxford Ayakkabı',
130
+ variants: [
131
+ {
132
+ attribute_key: 'size',
133
+ attribute_name: 'Beden',
134
+ options: [{ value: '42', is_selected: true }]
135
+ }
136
+ ]
137
+ }
138
+ };
139
+
140
+ const renderBlock = (
141
+ properties: Record<string, unknown>,
142
+ extra: Record<string, unknown> = {},
143
+ context: Record<string, unknown> | null = null
144
+ ) =>
145
+ render(
146
+ <ThemePageContextProvider value={context}>
147
+ <StoreLocatorBlock
148
+ {...({
149
+ block: {
150
+ id: 'sl-1',
151
+ type: 'store-locator',
152
+ styles: {},
153
+ properties,
154
+ order: 0,
155
+ hidden: false
156
+ },
157
+ placeholderId: 'p',
158
+ sectionId: 's',
159
+ ...extra
160
+ } as never)}
161
+ />
162
+ </ThemePageContextProvider>
163
+ );
164
+
165
+ const trigger = (c: HTMLElement) =>
166
+ c.querySelector('[data-store-locator-trigger]') as HTMLButtonElement;
167
+ const panel = (c: HTMLElement) =>
168
+ c.querySelector('[data-store-locator-panel]') as HTMLElement | null;
169
+
170
+ beforeEach(() => {
171
+ mockStores = GOLDEN_STORES;
172
+ mockCities = [];
173
+ mockStockRows = [];
174
+ mockStockRejects = false;
175
+ stockCalls.length = 0;
176
+ document.body.style.overflow = '';
177
+ });
178
+
179
+ describe('store locator — the list that already ships', () => {
180
+ it('renders the default composition byte for byte', () => {
181
+ expect(renderBlock({}).container.innerHTML).toBe(GOLDEN['default']);
182
+ });
183
+
184
+ it('renders the default composition in the designer byte for byte', () => {
185
+ expect(renderBlock({}, { isDesigner: true }).container.innerHTML).toBe(
186
+ GOLDEN['default-designer']
187
+ );
188
+ });
189
+
190
+ it('renders a sorted grid byte for byte', () => {
191
+ expect(
192
+ renderBlock({ columns: 2, columnsMobile: 2, sort: 'name' }).container
193
+ .innerHTML
194
+ ).toBe(GOLDEN['grid']);
195
+ });
196
+
197
+ it('renders a stripped-down list byte for byte', () => {
198
+ expect(
199
+ renderBlock({ showCityFilter: false, showMap: false, showPhone: false })
200
+ .container.innerHTML
201
+ ).toBe(GOLDEN['no-filter']);
202
+ });
203
+
204
+ it('renders the distance form byte for byte', () => {
205
+ expect(renderBlock({ showDistance: true }).container.innerHTML).toBe(
206
+ GOLDEN['distance']
207
+ );
208
+ });
209
+
210
+ it('adds nothing to the availability list but the notice route', () => {
211
+ // The one place the new work reaches an existing render, and it is a
212
+ // composition that had to opt into `productMode` to get there. Removing
213
+ // the added anchor has to leave the previous markup exactly.
214
+ const html = renderBlock(
215
+ { productMode: true, availabilityHelpUrl: 'https://brand.example/stores' },
216
+ {},
217
+ PRODUCT_CONTEXT
218
+ ).container.innerHTML;
219
+
220
+ const added =
221
+ '<a data-store-locator-notice-link="" data-part="notice-link" ' +
222
+ 'href="https://brand.example/stores" style="font-size: 0.9em;">' +
223
+ 'Check availability</a>';
224
+
225
+ expect(html).toContain(added);
226
+ expect(html.replace(added, '')).toBe(
227
+ "<div data-store-locator=\"\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px);\"><label data-store-locator-filter=\"\" data-part=\"filter\" style=\"display: inline-flex; align-items: center; gap: 8px;\"><span data-part=\"filter-label\">City</span><select data-part=\"select\" style=\"background: transparent; padding: 8px 10px;\"><option value=\"\">All cities</option><option value=\"Ankara\">Ankara</option><option value=\"İstanbul\">İstanbul</option><option value=\"İzmir\">İzmir</option></select></label><span data-store-locator-notice=\"\" data-part=\"notice\" style=\"font-size: 0.9em; opacity: 0.8;\">Pick a city to see stock for this product</span><ul data-part=\"list\" style=\"display: flex; flex-direction: column; gap: var(--store-locator-gap, 16px); list-style: none; margin: 0px; padding: 0px;\"><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Kanyon AVM</span><a data-store-locator-availability=\"\" data-store-locator-availability-state=\"unknown\" data-part=\"availability\" data-state=\"unknown\" href=\"https://brand.example/stores\" style=\"font-size: 0.85em;\">Check availability</a><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Büyükdere Cad. No:185</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İstanbul</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+902123531500\">+902123531500</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=41.0678,29.0092\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Ankamall</span><a data-store-locator-availability=\"\" data-store-locator-availability-state=\"unknown\" data-part=\"availability\" data-state=\"unknown\" href=\"https://brand.example/stores\" style=\"font-size: 0.85em;\">Check availability</a><span data-store-locator-address=\"\" data-part=\"address\" style=\"opacity: 0.8;\">Akköprü Mah.</span><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">Ankara</span><a data-store-locator-phone=\"\" data-part=\"phone\" href=\"tel:+903125411000\">+903125411000</a><a data-store-locator-map=\"\" data-part=\"map-link\" href=\"https://www.google.com/maps/search/?api=1&amp;query=39.9497,32.8241\" target=\"_blank\" rel=\"noopener noreferrer\">View on map</a></li><li data-store-locator-item=\"\" data-part=\"card\" style=\"display: flex; flex-direction: column; gap: 4px; padding-bottom: 12px;\"><span data-store-locator-name=\"\" data-part=\"name\" style=\"font-weight: 600;\">Merkez</span><a data-store-locator-availability=\"\" data-store-locator-availability-state=\"unknown\" data-part=\"availability\" data-state=\"unknown\" href=\"https://brand.example/stores\" style=\"font-size: 0.85em;\">Check availability</a><span data-store-locator-city=\"\" data-part=\"city\" style=\"opacity: 0.7;\">İzmir</span></li></ul></div>"
228
+ );
229
+ });
230
+ });
231
+
232
+ describe('store locator — find in store is a dialog', () => {
233
+ it('renders the trigger byte for byte', () => {
234
+ expect(
235
+ canonical(renderBlock({ displayMode: 'trigger' }).container.innerHTML)
236
+ ).toBe(canonical(GOLDEN['trigger']));
237
+ });
238
+
239
+ it('renders the open overlay byte for byte', () => {
240
+ const { container } = renderBlock({ displayMode: 'trigger' });
241
+ act(() => trigger(container).click());
242
+
243
+ expect(canonical(container.innerHTML)).toBe(
244
+ canonical(GOLDEN['trigger-open'])
245
+ );
246
+ });
247
+
248
+ it('renders the open overlay in the designer byte for byte', () => {
249
+ // Held open, placed as a side drawer, and — the line the golden exists to
250
+ // hold — with no `aria-modal`, because nothing traps focus on the canvas.
251
+ const { container } = renderBlock(
252
+ { displayMode: 'trigger', panelPlacement: 'right' },
253
+ { isDesigner: true }
254
+ );
255
+
256
+ expect(canonical(container.innerHTML)).toBe(
257
+ canonical(GOLDEN['trigger-open-designer'])
258
+ );
259
+ });
260
+
261
+ it('shows a trigger and nothing else until it is pressed', () => {
262
+ const { container } = renderBlock({ displayMode: 'trigger' });
263
+
264
+ expect(trigger(container)).toBeTruthy();
265
+ expect(trigger(container).getAttribute('aria-haspopup')).toBe('dialog');
266
+ expect(trigger(container).getAttribute('aria-expanded')).toBe('false');
267
+ expect(panel(container)).toBeNull();
268
+ expect(document.body.style.overflow).toBe('');
269
+ });
270
+
271
+ it('moves focus in, locks the page and claims aria-modal honestly', () => {
272
+ const { container } = renderBlock({ displayMode: 'trigger' });
273
+
274
+ act(() => {
275
+ trigger(container).click();
276
+ });
277
+
278
+ const dialog = panel(container) as HTMLElement;
279
+ expect(dialog.getAttribute('role')).toBe('dialog');
280
+ expect(dialog.getAttribute('aria-modal')).toBe('true');
281
+ expect(dialog.getAttribute('aria-label')).toBe('Find in store');
282
+ expect(trigger(container).getAttribute('aria-expanded')).toBe('true');
283
+ expect(dialog.contains(document.activeElement)).toBe(true);
284
+ expect(document.body.style.overflow).toBe('hidden');
285
+ });
286
+
287
+ it('holds Tab inside the dialog', () => {
288
+ const { container } = renderBlock({ displayMode: 'trigger' });
289
+ act(() => {
290
+ trigger(container).click();
291
+ });
292
+
293
+ const dialog = panel(container) as HTMLElement;
294
+ const focusables = Array.from(
295
+ dialog.querySelectorAll<HTMLElement>('a[href], button, select')
296
+ );
297
+ expect(focusables.length).toBeGreaterThan(1);
298
+
299
+ const first = focusables[0];
300
+ const last = focusables[focusables.length - 1];
301
+
302
+ act(() => last.focus());
303
+ fireEvent.keyDown(dialog, { key: 'Tab' });
304
+ expect(document.activeElement).toBe(first);
305
+
306
+ fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
307
+ expect(document.activeElement).toBe(last);
308
+ });
309
+
310
+ it('pulls focus back when it lands outside the dialog', () => {
311
+ const outside = document.createElement('button');
312
+ document.body.appendChild(outside);
313
+
314
+ const { container } = renderBlock({ displayMode: 'trigger' });
315
+ act(() => {
316
+ trigger(container).click();
317
+ });
318
+
319
+ const dialog = panel(container) as HTMLElement;
320
+ act(() => outside.focus());
321
+
322
+ expect(dialog.contains(document.activeElement)).toBe(true);
323
+ outside.remove();
324
+ });
325
+
326
+ it('closes on Escape and gives focus back to the trigger', () => {
327
+ const { container } = renderBlock({ displayMode: 'trigger' });
328
+ act(() => {
329
+ trigger(container).click();
330
+ });
331
+
332
+ act(() => {
333
+ fireEvent.keyDown(document, { key: 'Escape' });
334
+ });
335
+
336
+ expect(panel(container)).toBeNull();
337
+ expect(document.activeElement).toBe(trigger(container));
338
+ expect(document.body.style.overflow).toBe('');
339
+ });
340
+
341
+ it('closes on the close button and on the backdrop', () => {
342
+ const { container } = renderBlock({ displayMode: 'trigger' });
343
+
344
+ act(() => trigger(container).click());
345
+ act(() => {
346
+ (
347
+ container.querySelector(
348
+ '[data-store-locator-panel-close]'
349
+ ) as HTMLElement
350
+ ).click();
351
+ });
352
+ expect(panel(container)).toBeNull();
353
+
354
+ act(() => trigger(container).click());
355
+ act(() => {
356
+ (
357
+ container.querySelector('[data-store-locator-backdrop]') as HTMLElement
358
+ ).click();
359
+ });
360
+ expect(panel(container)).toBeNull();
361
+ });
362
+
363
+ it('counts the holders of the scroll lock', () => {
364
+ // Two panels on one page is the ordinary composition, not a corner case:
365
+ // the mobile buy row carries one and the desktop one carries another.
366
+ const a = renderBlock({ displayMode: 'trigger' });
367
+ const b = renderBlock({ displayMode: 'trigger' });
368
+
369
+ act(() => trigger(a.container).click());
370
+ act(() => trigger(b.container).click());
371
+ expect(document.body.style.overflow).toBe('hidden');
372
+
373
+ b.unmount();
374
+ // The first panel is still over the page, so the lock is not the second
375
+ // panel's to release.
376
+ expect(document.body.style.overflow).toBe('hidden');
377
+
378
+ a.unmount();
379
+ expect(document.body.style.overflow).toBe('');
380
+ });
381
+
382
+ it('survives a second panel on the same page', () => {
383
+ // A product page carries this block twice — once on the mobile buy row and
384
+ // once on the desktop one — so two open panels is the ordinary
385
+ // composition. Each used to install a document-level `focusin` listener
386
+ // that pulled focus into its OWN panel, so the two pulled against each
387
+ // other until the tab died with "Maximum call stack size exceeded".
388
+ const outside = document.createElement('button');
389
+ document.body.appendChild(outside);
390
+
391
+ const a = renderBlock({ displayMode: 'trigger' });
392
+ const b = renderBlock({ displayMode: 'trigger' });
393
+
394
+ act(() => trigger(a.container).click());
395
+ act(() => trigger(b.container).click());
396
+
397
+ expect(() => act(() => outside.focus())).not.toThrow();
398
+
399
+ const second = panel(b.container) as HTMLElement;
400
+ // The panel a shopper opened last is the one holding focus.
401
+ expect(second.contains(document.activeElement)).toBe(true);
402
+ // And only that one may tell assistive tech the rest of the page is gone.
403
+ expect(second.getAttribute('aria-modal')).toBe('true');
404
+ expect(
405
+ (panel(a.container) as HTMLElement).getAttribute('aria-modal')
406
+ ).toBeNull();
407
+
408
+ outside.remove();
409
+ });
410
+
411
+ it('closes the panel on top and leaves the one underneath open', () => {
412
+ const a = renderBlock({ displayMode: 'trigger' });
413
+ const b = renderBlock({ displayMode: 'trigger' });
414
+
415
+ act(() => trigger(a.container).click());
416
+ act(() => trigger(b.container).click());
417
+
418
+ act(() => {
419
+ fireEvent.keyDown(document, { key: 'Escape' });
420
+ });
421
+
422
+ expect(panel(b.container)).toBeNull();
423
+ expect(panel(a.container)).toBeTruthy();
424
+ // Escape backed out of one panel, not out of every panel on the page — so
425
+ // the page stays frozen behind the one still covering it, and that one
426
+ // takes the claim back now that it is on top.
427
+ expect(document.body.style.overflow).toBe('hidden');
428
+ expect(
429
+ (panel(a.container) as HTMLElement).getAttribute('aria-modal')
430
+ ).toBe('true');
431
+
432
+ act(() => {
433
+ fireEvent.keyDown(document, { key: 'Escape' });
434
+ });
435
+
436
+ expect(panel(a.container)).toBeNull();
437
+ expect(document.body.style.overflow).toBe('');
438
+ });
439
+
440
+ it('restores the page value the first locker found, not an empty one', () => {
441
+ document.body.style.overflow = 'clip';
442
+
443
+ const { container } = renderBlock({ displayMode: 'trigger' });
444
+ act(() => trigger(container).click());
445
+ expect(document.body.style.overflow).toBe('hidden');
446
+
447
+ act(() => {
448
+ fireEvent.keyDown(document, { key: 'Escape' });
449
+ });
450
+ expect(document.body.style.overflow).toBe('clip');
451
+ });
452
+ });
453
+
454
+ describe('store locator — the designer is not a shopper', () => {
455
+ it('holds the panel open without claiming it, locking or stealing focus', () => {
456
+ const outside = document.createElement('button');
457
+ document.body.appendChild(outside);
458
+ act(() => outside.focus());
459
+
460
+ const { container } = renderBlock(
461
+ { displayMode: 'trigger' },
462
+ { isDesigner: true }
463
+ );
464
+
465
+ const dialog = panel(container) as HTMLElement;
466
+ expect(dialog).toBeTruthy();
467
+ expect(dialog.getAttribute('aria-modal')).toBeNull();
468
+ expect(document.activeElement).toBe(outside);
469
+ expect(document.body.style.overflow).toBe('');
470
+
471
+ // Nothing a click reaches may close the panel being styled.
472
+ act(() => trigger(container).click());
473
+ act(() =>
474
+ (
475
+ container.querySelector(
476
+ '[data-store-locator-panel-close]'
477
+ ) as HTMLElement
478
+ ).click()
479
+ );
480
+ act(() => {
481
+ fireEvent.keyDown(document, { key: 'Escape' });
482
+ });
483
+ expect(panel(container)).toBeTruthy();
484
+
485
+ outside.remove();
486
+ });
487
+
488
+ it('asks commerce nothing and lets no link navigate', () => {
489
+ const { container } = renderBlock(
490
+ {
491
+ displayMode: 'trigger',
492
+ productMode: true,
493
+ availabilityHelpUrl: 'https://brand.example/stores'
494
+ },
495
+ { isDesigner: true },
496
+ PRODUCT_CONTEXT
497
+ );
498
+
499
+ expect(stockCalls).toHaveLength(0);
500
+ container
501
+ .querySelectorAll('a[href]')
502
+ .forEach((anchor) => expect(anchor.getAttribute('href')).toBe('#'));
503
+ });
504
+ });
505
+
506
+ describe('store locator — an unasked store is unknown, not unavailable', () => {
507
+ const AVAILABILITY_PROPS = {
508
+ productMode: true,
509
+ availabilityHelpUrl: 'https://brand.example/stores'
510
+ };
511
+
512
+ const markers = (c: HTMLElement) =>
513
+ Object.fromEntries(
514
+ Array.from(
515
+ c.querySelectorAll('[data-store-locator-item]')
516
+ ).map((card) => [
517
+ card.querySelector('[data-part="name"]')?.textContent,
518
+ card
519
+ .querySelector('[data-store-locator-availability]')
520
+ ?.getAttribute('data-store-locator-availability-state')
521
+ ])
522
+ );
523
+
524
+ beforeEach(() => {
525
+ mockStores = [
526
+ ...GOLDEN_STORES,
527
+ {
528
+ pk: 4,
529
+ name: 'Akasya',
530
+ address: 'Acıbadem',
531
+ latitude: '40.9910',
532
+ longitude: '29.0630',
533
+ township: { name: 'Üsküdar', city: { name: 'İstanbul' } }
534
+ }
535
+ ];
536
+ mockCities = [{ pk: 34, name: 'İstanbul' }];
537
+ });
538
+
539
+ const chooseIstanbul = (container: HTMLElement) =>
540
+ fireEvent.change(container.querySelector('select') as HTMLSelectElement, {
541
+ target: { value: 'İstanbul' }
542
+ });
543
+
544
+ it('marks only what the city answer actually mentioned', async () => {
545
+ mockStockRows = [{ name: 'Kanyon AVM', stock: '3' }];
546
+
547
+ const { container } = renderBlock(AVAILABILITY_PROPS, {}, PRODUCT_CONTEXT);
548
+ chooseIstanbul(container);
549
+
550
+ await waitFor(() =>
551
+ expect(markers(container)['Kanyon AVM']).toBe('available')
552
+ );
553
+ // Akasya is in the answered city and the answer never named it. That is
554
+ // not a no — it is a question nobody asked.
555
+ expect(markers(container)['Akasya']).toBe('unknown');
556
+ expect(
557
+ container.querySelector(
558
+ '[data-store-locator-availability-state="unavailable"]'
559
+ )
560
+ ).toBeNull();
561
+ });
562
+
563
+ it('says unavailable only when commerce said zero', async () => {
564
+ mockStockRows = [
565
+ { name: 'Kanyon AVM', stock: '0' },
566
+ { name: 'Akasya', stock: 'n/a' }
567
+ ];
568
+
569
+ const { container } = renderBlock(AVAILABILITY_PROPS, {}, PRODUCT_CONTEXT);
570
+ chooseIstanbul(container);
571
+
572
+ await waitFor(() =>
573
+ expect(markers(container)['Kanyon AVM']).toBe('unavailable')
574
+ );
575
+ // A count commerce could not express is not a zero either.
576
+ expect(markers(container)['Akasya']).toBe('unknown');
577
+ });
578
+
579
+ it('keeps every store unknown when the request fails', async () => {
580
+ mockStockRejects = true;
581
+
582
+ const { container } = renderBlock(AVAILABILITY_PROPS, {}, PRODUCT_CONTEXT);
583
+ chooseIstanbul(container);
584
+
585
+ await waitFor(() => expect(markers(container)['Kanyon AVM']).toBe('unknown'));
586
+ expect(markers(container)['Akasya']).toBe('unknown');
587
+ });
588
+
589
+ it('gives the unknown marker its own state to be styled by', async () => {
590
+ mockStockRows = [{ name: 'Kanyon AVM', stock: '3' }];
591
+
592
+ const { container } = renderBlock(AVAILABILITY_PROPS, {}, PRODUCT_CONTEXT);
593
+ chooseIstanbul(container);
594
+
595
+ await waitFor(() =>
596
+ expect(markers(container)['Kanyon AVM']).toBe('available')
597
+ );
598
+
599
+ const unknown = Array.from(
600
+ container.querySelectorAll('[data-part="availability"]')
601
+ ).filter((el) => el.getAttribute('data-state') === 'unknown');
602
+ const unavailable = Array.from(
603
+ container.querySelectorAll('[data-part="availability"]')
604
+ ).filter((el) => el.getAttribute('data-state') === 'unavailable');
605
+
606
+ expect(unknown.length).toBeGreaterThan(0);
607
+ expect(unavailable).toHaveLength(0);
608
+ // The unknown state carries the brand's route rather than a dead label.
609
+ expect(unknown[0].tagName).toBe('A');
610
+ expect(unknown[0].getAttribute('href')).toBe('https://brand.example/stores');
611
+ });
612
+ });
613
+
614
+ describe('store locator — the check-availability route reaches the overlay', () => {
615
+ it('offers the route beside the notice, inside the panel', () => {
616
+ const { container } = renderBlock(
617
+ {
618
+ displayMode: 'trigger',
619
+ productMode: true,
620
+ availabilityHelpUrl: 'https://brand.example/stores'
621
+ },
622
+ {},
623
+ PRODUCT_CONTEXT
624
+ );
625
+
626
+ act(() => trigger(container).click());
627
+
628
+ const dialog = panel(container) as HTMLElement;
629
+ const route = dialog.querySelector(
630
+ '[data-store-locator-notice-link]'
631
+ ) as HTMLAnchorElement;
632
+
633
+ expect(dialog.querySelector('[data-store-locator-notice]')).toBeTruthy();
634
+ expect(route).toBeTruthy();
635
+ expect(route.getAttribute('href')).toBe('https://brand.example/stores');
636
+ // Same wording as the unknown marker — one authorable label, not two.
637
+ expect(route.textContent).toBe('Check availability');
638
+ });
639
+
640
+ it('offers it when the overlay has no store to show at all', () => {
641
+ mockStores = [];
642
+
643
+ const { container } = renderBlock(
644
+ {
645
+ displayMode: 'trigger',
646
+ productMode: true,
647
+ availabilityHelpUrl: 'https://brand.example/stores'
648
+ },
649
+ {},
650
+ PRODUCT_CONTEXT
651
+ );
652
+
653
+ act(() => trigger(container).click());
654
+
655
+ const dialog = panel(container) as HTMLElement;
656
+ expect(dialog.querySelector('[data-part="empty"]')).toBeTruthy();
657
+ expect(
658
+ dialog
659
+ .querySelector('[data-store-locator-notice-link]')
660
+ ?.getAttribute('href')
661
+ ).toBe('https://brand.example/stores');
662
+ });
663
+
664
+ it('stays out of the empty message a plain store list renders', () => {
665
+ mockStores = [];
666
+
667
+ const { container } = renderBlock({ showEmptyState: true });
668
+
669
+ expect(container.querySelector('[data-part="empty"]')).toBeTruthy();
670
+ expect(container.querySelector('[data-store-locator-notice-link]')).toBeNull();
671
+ });
672
+
673
+ it('refuses a url that would execute', () => {
674
+ // The url is typed by a merchant in the editor and lands in an href.
675
+ const { container } = renderBlock(
676
+ {
677
+ productMode: true,
678
+ availabilityHelpUrl: 'java\u0000script:alert(1)'
679
+ },
680
+ {},
681
+ PRODUCT_CONTEXT
682
+ );
683
+
684
+ expect(container.querySelector('[data-store-locator-notice-link]')).toBeNull();
685
+ expect(container.querySelector('a[href^="java"]')).toBeNull();
686
+ // The label survives as text, which is what an unset url does too.
687
+ const marker = container.querySelector('[data-part="availability"]');
688
+ expect(marker?.tagName).toBe('SPAN');
689
+ expect(marker?.textContent).toBe('Check availability');
690
+ });
691
+ });