@zalify/storefront-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE.md +49 -0
  2. package/README.md +63 -0
  3. package/dist/commerce/countdown.d.ts +18 -0
  4. package/dist/commerce/countdown.js +28 -0
  5. package/dist/commerce/events.d.ts +29 -0
  6. package/dist/commerce/events.js +14 -0
  7. package/dist/commerce/facets.d.ts +27 -0
  8. package/dist/commerce/facets.js +71 -0
  9. package/dist/commerce/index.d.ts +11 -0
  10. package/dist/commerce/index.js +11 -0
  11. package/dist/commerce/money.d.ts +51 -0
  12. package/dist/commerce/money.js +83 -0
  13. package/dist/commerce/product.d.ts +102 -0
  14. package/dist/commerce/product.js +93 -0
  15. package/dist/editor/frame.d.ts +57 -0
  16. package/dist/editor/frame.js +241 -0
  17. package/dist/editor/host.d.ts +31 -0
  18. package/dist/editor/host.js +67 -0
  19. package/dist/editor/index.d.ts +9 -0
  20. package/dist/editor/index.js +9 -0
  21. package/dist/react/adapter.d.ts +49 -0
  22. package/dist/react/adapter.js +23 -0
  23. package/dist/react/blocks/_box.d.ts +10 -0
  24. package/dist/react/blocks/_box.js +17 -0
  25. package/dist/react/blocks/_slide.d.ts +24 -0
  26. package/dist/react/blocks/_slide.js +21 -0
  27. package/dist/react/blocks/_video-card.d.ts +11 -0
  28. package/dist/react/blocks/_video-card.js +39 -0
  29. package/dist/react/blocks/accordion.d.ts +12 -0
  30. package/dist/react/blocks/accordion.js +5 -0
  31. package/dist/react/blocks/button.d.ts +10 -0
  32. package/dist/react/blocks/button.js +15 -0
  33. package/dist/react/blocks/countdown.d.ts +9 -0
  34. package/dist/react/blocks/countdown.js +64 -0
  35. package/dist/react/blocks/custom-liquid.d.ts +11 -0
  36. package/dist/react/blocks/custom-liquid.js +4 -0
  37. package/dist/react/blocks/group.d.ts +9 -0
  38. package/dist/react/blocks/group.js +8 -0
  39. package/dist/react/blocks/image.d.ts +8 -0
  40. package/dist/react/blocks/image.js +7 -0
  41. package/dist/react/blocks/message.d.ts +14 -0
  42. package/dist/react/blocks/message.js +5 -0
  43. package/dist/react/blocks/quote.d.ts +9 -0
  44. package/dist/react/blocks/quote.js +5 -0
  45. package/dist/react/blocks/spacer.d.ts +8 -0
  46. package/dist/react/blocks/spacer.js +5 -0
  47. package/dist/react/blocks/story.d.ts +17 -0
  48. package/dist/react/blocks/story.js +7 -0
  49. package/dist/react/blocks/text.d.ts +9 -0
  50. package/dist/react/blocks/text.js +5 -0
  51. package/dist/react/blocks/video.d.ts +17 -0
  52. package/dist/react/blocks/video.js +56 -0
  53. package/dist/react/components/Facets.d.ts +21 -0
  54. package/dist/react/components/Facets.js +220 -0
  55. package/dist/react/components/Icon.d.ts +12 -0
  56. package/dist/react/components/Icon.js +66 -0
  57. package/dist/react/components/PaymentIcons.d.ts +5 -0
  58. package/dist/react/components/PaymentIcons.generated.d.ts +22 -0
  59. package/dist/react/components/PaymentIcons.generated.js +21 -0
  60. package/dist/react/components/PaymentIcons.js +18 -0
  61. package/dist/react/components/Price.d.ts +24 -0
  62. package/dist/react/components/Price.js +9 -0
  63. package/dist/react/components/ProductBadges.d.ts +24 -0
  64. package/dist/react/components/ProductBadges.js +57 -0
  65. package/dist/react/components/ProductCard.d.ts +17 -0
  66. package/dist/react/components/ProductCard.js +202 -0
  67. package/dist/react/components/Rating.d.ts +11 -0
  68. package/dist/react/components/Rating.js +35 -0
  69. package/dist/react/components/SwatchStyle.d.ts +23 -0
  70. package/dist/react/components/SwatchStyle.js +22 -0
  71. package/dist/react/components/VideoModal.d.ts +55 -0
  72. package/dist/react/components/VideoModal.js +102 -0
  73. package/dist/react/engine/CssVariables.d.ts +1 -0
  74. package/dist/react/engine/CssVariables.js +105 -0
  75. package/dist/react/engine/context.d.ts +43 -0
  76. package/dist/react/engine/context.js +45 -0
  77. package/dist/react/engine/fonts.d.ts +15 -0
  78. package/dist/react/engine/fonts.js +33 -0
  79. package/dist/react/engine/images.d.ts +24 -0
  80. package/dist/react/engine/images.js +40 -0
  81. package/dist/react/engine/render.d.ts +29 -0
  82. package/dist/react/engine/render.js +87 -0
  83. package/dist/react/engine/settings.d.ts +8 -0
  84. package/dist/react/engine/settings.js +68 -0
  85. package/dist/react/engine/store.d.ts +64 -0
  86. package/dist/react/engine/store.js +42 -0
  87. package/dist/react/engine/translate.d.ts +7 -0
  88. package/dist/react/engine/translate.js +33 -0
  89. package/dist/react/engine/types.d.ts +128 -0
  90. package/dist/react/engine/types.js +1 -0
  91. package/dist/react/index.d.ts +38 -0
  92. package/dist/react/index.js +39 -0
  93. package/dist/react/registries.d.ts +10 -0
  94. package/dist/react/registries.js +68 -0
  95. package/dist/react/registries.server.d.ts +2 -0
  96. package/dist/react/registries.server.js +98 -0
  97. package/dist/react/sections/404.d.ts +10 -0
  98. package/dist/react/sections/404.js +10 -0
  99. package/dist/react/sections/announcement-bar.d.ts +14 -0
  100. package/dist/react/sections/announcement-bar.js +84 -0
  101. package/dist/react/sections/article.d.ts +17 -0
  102. package/dist/react/sections/article.js +31 -0
  103. package/dist/react/sections/contact-form.d.ts +16 -0
  104. package/dist/react/sections/contact-form.js +8 -0
  105. package/dist/react/sections/custom-liquid.d.ts +14 -0
  106. package/dist/react/sections/custom-liquid.js +7 -0
  107. package/dist/react/sections/custom-section.d.ts +14 -0
  108. package/dist/react/sections/custom-section.js +9 -0
  109. package/dist/react/sections/featured-collection.d.ts +10 -0
  110. package/dist/react/sections/featured-collection.js +23 -0
  111. package/dist/react/sections/hero.d.ts +6 -0
  112. package/dist/react/sections/hero.js +35 -0
  113. package/dist/react/sections/image-with-text.d.ts +13 -0
  114. package/dist/react/sections/image-with-text.js +9 -0
  115. package/dist/react/sections/multicolumn.d.ts +15 -0
  116. package/dist/react/sections/multicolumn.js +8 -0
  117. package/dist/react/sections/page.d.ts +13 -0
  118. package/dist/react/sections/page.js +11 -0
  119. package/dist/react/sections/password.d.ts +15 -0
  120. package/dist/react/sections/password.js +9 -0
  121. package/dist/react/sections/rich-text.d.ts +12 -0
  122. package/dist/react/sections/rich-text.js +7 -0
  123. package/dist/react/sections/stories.d.ts +18 -0
  124. package/dist/react/sections/stories.js +10 -0
  125. package/dist/react/sections/video-bubble.d.ts +13 -0
  126. package/dist/react/sections/video-bubble.js +54 -0
  127. package/dist/react/sections/video-carousel.d.ts +19 -0
  128. package/dist/react/sections/video-carousel.js +9 -0
  129. package/dist/react/server.d.ts +7 -0
  130. package/dist/react/server.js +42 -0
  131. package/dist/schemas/bridge.d.ts +98 -0
  132. package/dist/schemas/bridge.js +50 -0
  133. package/dist/schemas/data.d.ts +54 -0
  134. package/dist/schemas/data.js +22 -0
  135. package/dist/schemas/index.d.ts +14 -0
  136. package/dist/schemas/index.js +14 -0
  137. package/dist/schemas/manifest.d.ts +63 -0
  138. package/dist/schemas/manifest.js +32 -0
  139. package/dist/schemas/schema.d.ts +159 -0
  140. package/dist/schemas/schema.js +129 -0
  141. package/dist/schemas/validate.d.ts +22 -0
  142. package/dist/schemas/validate.js +199 -0
  143. package/package.json +84 -0
  144. package/src/commerce/countdown.ts +38 -0
  145. package/src/commerce/events.ts +43 -0
  146. package/src/commerce/facets.ts +88 -0
  147. package/src/commerce/index.ts +11 -0
  148. package/src/commerce/money.ts +120 -0
  149. package/src/commerce/product.ts +177 -0
  150. package/src/editor/frame.ts +305 -0
  151. package/src/editor/host.ts +101 -0
  152. package/src/editor/index.ts +9 -0
  153. package/src/react/adapter.tsx +83 -0
  154. package/src/react/blocks/_box.tsx +37 -0
  155. package/src/react/blocks/_slide.tsx +96 -0
  156. package/src/react/blocks/_video-card.tsx +131 -0
  157. package/src/react/blocks/accordion.tsx +33 -0
  158. package/src/react/blocks/button.tsx +36 -0
  159. package/src/react/blocks/countdown.tsx +105 -0
  160. package/src/react/blocks/custom-liquid.tsx +21 -0
  161. package/src/react/blocks/group.tsx +32 -0
  162. package/src/react/blocks/image.tsx +29 -0
  163. package/src/react/blocks/message.tsx +28 -0
  164. package/src/react/blocks/quote.tsx +24 -0
  165. package/src/react/blocks/spacer.tsx +17 -0
  166. package/src/react/blocks/story.tsx +40 -0
  167. package/src/react/blocks/text.tsx +24 -0
  168. package/src/react/blocks/video.tsx +132 -0
  169. package/src/react/components/Facets.tsx +424 -0
  170. package/src/react/components/Icon.tsx +96 -0
  171. package/src/react/components/PaymentIcons.generated.ts +24 -0
  172. package/src/react/components/PaymentIcons.tsx +27 -0
  173. package/src/react/components/Price.tsx +72 -0
  174. package/src/react/components/ProductBadges.tsx +95 -0
  175. package/src/react/components/ProductCard.tsx +417 -0
  176. package/src/react/components/Rating.tsx +59 -0
  177. package/src/react/components/SwatchStyle.tsx +50 -0
  178. package/src/react/components/VideoModal.tsx +236 -0
  179. package/src/react/engine/CssVariables.tsx +142 -0
  180. package/src/react/engine/context.tsx +108 -0
  181. package/src/react/engine/fonts.ts +42 -0
  182. package/src/react/engine/images.tsx +58 -0
  183. package/src/react/engine/render.tsx +190 -0
  184. package/src/react/engine/settings.ts +89 -0
  185. package/src/react/engine/store.ts +103 -0
  186. package/src/react/engine/translate.ts +40 -0
  187. package/src/react/engine/types.ts +142 -0
  188. package/src/react/index.ts +78 -0
  189. package/src/react/registries.server.ts +110 -0
  190. package/src/react/registries.ts +80 -0
  191. package/src/react/sections/404.tsx +37 -0
  192. package/src/react/sections/announcement-bar.tsx +170 -0
  193. package/src/react/sections/article.tsx +97 -0
  194. package/src/react/sections/contact-form.tsx +104 -0
  195. package/src/react/sections/custom-liquid.tsx +39 -0
  196. package/src/react/sections/custom-section.tsx +44 -0
  197. package/src/react/sections/featured-collection.tsx +89 -0
  198. package/src/react/sections/hero.tsx +84 -0
  199. package/src/react/sections/image-with-text.tsx +61 -0
  200. package/src/react/sections/multicolumn.tsx +46 -0
  201. package/src/react/sections/page.tsx +43 -0
  202. package/src/react/sections/password.tsx +60 -0
  203. package/src/react/sections/rich-text.tsx +37 -0
  204. package/src/react/sections/stories.tsx +51 -0
  205. package/src/react/sections/video-bubble.tsx +145 -0
  206. package/src/react/sections/video-carousel.tsx +57 -0
  207. package/src/react/server.ts +60 -0
  208. package/src/schemas/bridge.ts +143 -0
  209. package/src/schemas/data.ts +72 -0
  210. package/src/schemas/index.ts +14 -0
  211. package/src/schemas/manifest.ts +85 -0
  212. package/src/schemas/schema.ts +287 -0
  213. package/src/schemas/validate.ts +302 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Port of blocks/video.liquid — general video block: a Shopify-hosted
3
+ * file (ambient loop or click-to-play with native controls) or a
4
+ * YouTube/Vimeo link behind a cover-image facade that loads nothing
5
+ * until clicked. Behavior from the <video-player> island in
6
+ * src/entries/video.ts. CSS: app/styles/components/blocks-video.css
7
+ */
8
+ import {useState} from 'react';
9
+ import type {BlockProps} from '../engine/types';
10
+ import {t} from '../engine/translate';
11
+ import {Icon} from '../components/Icon';
12
+ import {imageUrl, PlaceholderSvg, settingImageUrl} from '../engine/images';
13
+ import {
14
+ mp4Sources,
15
+ useViewportPlayback,
16
+ videoPreviewImage,
17
+ type ThemeVideo,
18
+ } from '../components/VideoModal';
19
+
20
+ interface VideoSettings {
21
+ video?: ThemeVideo;
22
+ /** `video_url` setting: JSON stores the raw URL string; the Liquid
23
+ * runtime's {type, id} object shape is accepted too. */
24
+ video_url?: string | {type?: string; id?: string; url?: string};
25
+ image?: string;
26
+ autoplay?: boolean;
27
+ aspect?: 'auto' | 'square' | 'portrait' | 'landscape';
28
+ }
29
+
30
+ /** Mirror of Liquid's external video_url object (type + id). */
31
+ function parseExternal(
32
+ value: VideoSettings['video_url'],
33
+ ): {type: 'youtube' | 'vimeo'; id: string} | null {
34
+ let url = '';
35
+ if (typeof value === 'object' && value !== null) {
36
+ if ((value.type === 'youtube' || value.type === 'vimeo') && value.id) {
37
+ return {type: value.type, id: value.id};
38
+ }
39
+ url = value.url ?? '';
40
+ } else if (typeof value === 'string') {
41
+ url = value;
42
+ }
43
+ if (!url) return null;
44
+ const youtube = url.match(
45
+ /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{6,})/,
46
+ );
47
+ if (youtube) return {type: 'youtube', id: youtube[1]};
48
+ const vimeo = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
49
+ if (vimeo) return {type: 'vimeo', id: vimeo[1]};
50
+ return null;
51
+ }
52
+
53
+ export default function VideoBlock({settings}: BlockProps<VideoSettings>) {
54
+ const {autoplay = true, aspect = 'auto'} = settings;
55
+ const hostedMp4 = mp4Sources(settings.video).at(-1);
56
+ const external = parseExternal(settings.video_url);
57
+ const poster =
58
+ settingImageUrl(settings.image) ?? videoPreviewImage(settings.video);
59
+
60
+ // Ambient loops play only while visible (watchPreviews in video.ts).
61
+ const previewRef = useViewportPlayback();
62
+ // External embed facade: the iframe gets its src only on click.
63
+ const [playing, setPlaying] = useState(false);
64
+
65
+ const frameClass = hostedMp4 ? '' : ' video-block--frame';
66
+ const embedUrl = external
67
+ ? external.type === 'youtube'
68
+ ? `https://www.youtube-nocookie.com/embed/${external.id}?autoplay=1&playsinline=1&rel=0`
69
+ : `https://player.vimeo.com/video/${external.id}?autoplay=1`
70
+ : null;
71
+
72
+ return (
73
+ <div className={`video-block video-block--${aspect}${frameClass}`}>
74
+ {hostedMp4 ? (
75
+ <video
76
+ ref={autoplay ? previewRef : undefined}
77
+ className="video-block__media"
78
+ playsInline
79
+ preload="metadata"
80
+ {...(autoplay
81
+ ? {autoPlay: true, loop: true, muted: true, 'data-video-preview': ''}
82
+ : {controls: true})}
83
+ poster={poster ? imageUrl(poster, 1500) : undefined}
84
+ >
85
+ <source src={hostedMp4.url} type={hostedMp4.mime_type} />
86
+ </video>
87
+ ) : embedUrl ? (
88
+ <>
89
+ <button
90
+ type="button"
91
+ className="video-block__facade"
92
+ onClick={() => setPlaying(true)}
93
+ aria-label={t('general.play_video')}
94
+ hidden={playing}
95
+ >
96
+ {poster ? (
97
+ <img
98
+ className="video-block__media"
99
+ src={imageUrl(poster, 1500)}
100
+ srcSet={[400, 700, 1000, 1500]
101
+ .map((w) => `${imageUrl(poster, w)} ${w}w`)
102
+ .join(', ')}
103
+ loading="lazy"
104
+ alt=""
105
+ />
106
+ ) : (
107
+ <PlaceholderSvg className="video-block__media video-block__placeholder" />
108
+ )}
109
+ <span className="video-block__play">
110
+ <Icon name="icon-play" />
111
+ </span>
112
+ </button>
113
+ <iframe
114
+ className="video-block__media"
115
+ src={playing ? embedUrl : undefined}
116
+ title={t('general.play_video')}
117
+ allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
118
+ allowFullScreen
119
+ hidden={!playing}
120
+ />
121
+ </>
122
+ ) : (
123
+ <>
124
+ <PlaceholderSvg className="video-block__media video-block__placeholder" />
125
+ <span className="video-block__play">
126
+ <Icon name="icon-play" />
127
+ </span>
128
+ </>
129
+ )}
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Port of snippets/facets.liquid + src/entries/facets.ts (<facets-form>).
3
+ * CSS: app/styles/components/snippets-facets.css
4
+ *
5
+ * One GET-style form holds the toolbar (sort popover) and the Filters
6
+ * modal. Sort changes apply immediately; filter changes apply on
7
+ * "Apply". Closing the dialog without applying resets the form to the
8
+ * currently applied (= URL) state. Applying navigates with the host's
9
+ * router (via the adapter seam), updating the URL search params (the
10
+ * mirror of the Liquid island's Section Rendering swap + pushState).
11
+ *
12
+ * URL contract (what the route loader should parse):
13
+ * sort_by=<value> – sort handle (see sort options)
14
+ * filter.<key>=<json> – one param per applied filter
15
+ * value, where <key> is the ProductFilter key from the Storefront
16
+ * filter value's `input` (e.g. filter.variantOption={"name":"color",
17
+ * "value":"Red"}), and price as filter.price={"min":10,"max":50}
18
+ * q=<terms> – preserved search terms
19
+ */
20
+ import {useEffect, useRef} from 'react';
21
+ import type React from 'react';
22
+ import {cleanFormParams} from '../../commerce/index.ts';
23
+ import {useAdapter} from '../adapter';
24
+ import {t} from '../engine/translate';
25
+ import {Icon} from './Icon';
26
+
27
+ const FILTER_PREFIX = 'filter.';
28
+
29
+ export interface SortOption {
30
+ value: string;
31
+ label: string;
32
+ }
33
+
34
+ /** Shopify's canonical collection sort handles (Liquid collection.sort_options). */
35
+ export const COLLECTION_SORT_OPTIONS: SortOption[] = [
36
+ {value: 'manual', label: 'Featured'},
37
+ {value: 'best-selling', label: 'Best selling'},
38
+ {value: 'title-ascending', label: 'Alphabetically, A-Z'},
39
+ {value: 'title-descending', label: 'Alphabetically, Z-A'},
40
+ {value: 'price-ascending', label: 'Price, low to high'},
41
+ {value: 'price-descending', label: 'Price, high to low'},
42
+ {value: 'created-ascending', label: 'Date, old to new'},
43
+ {value: 'created-descending', label: 'Date, new to old'},
44
+ ];
45
+
46
+ /** Shopify's canonical search sort handles (Liquid search.sort_options). */
47
+ export const SEARCH_SORT_OPTIONS: SortOption[] = [
48
+ {value: 'relevance', label: 'Relevance'},
49
+ {value: 'price-ascending', label: 'Price, low to high'},
50
+ {value: 'price-descending', label: 'Price, high to low'},
51
+ ];
52
+
53
+ export interface FacetsProps {
54
+ /** Storefront API filters (collection.products.filters / search filters). */
55
+ filters: any[];
56
+ /** Base URL the clear-all link points at (mirror of results_url). */
57
+ resultsUrl: string;
58
+ resultsCount: number;
59
+ sortOptions?: SortOption[];
60
+ defaultSortBy?: string;
61
+ /** Search terms, preserved as a hidden q input. */
62
+ terms?: string;
63
+ }
64
+
65
+ /** Vetted CSS color keywords — mirror of snippets/swatch-style.liquid. */
66
+ const CSS_COLORS =
67
+ ',aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen,';
68
+
69
+ /**
70
+ * Mirror of snippets/swatch-style.liquid: resolve the `--swatch` fill
71
+ * from the value's native swatch, else the value name as a vetted CSS
72
+ * keyword. (The metaobject-based swatch_color lookup has no Storefront
73
+ * mirror here.)
74
+ */
75
+ function swatchStyle(swatch: any, name: string): React.CSSProperties {
76
+ const imageUrl =
77
+ swatch?.image?.previewImage?.url ??
78
+ swatch?.image?.image?.url ??
79
+ (typeof swatch?.image === 'string' ? swatch.image : undefined);
80
+ if (imageUrl) return {'--swatch': `url(${imageUrl})`} as React.CSSProperties;
81
+ if (swatch?.color) return {'--swatch': swatch.color} as React.CSSProperties;
82
+ const probe = String(name ?? '')
83
+ .toLowerCase()
84
+ .trim()
85
+ .replace(/[\s-]/g, '');
86
+ if (probe && CSS_COLORS.includes(`,${probe},`))
87
+ return {'--swatch': probe} as React.CSSProperties;
88
+ return {};
89
+ }
90
+
91
+ /** filter value → URL param, from the Storefront `input` JSON. */
92
+ function valueParam(value: any): {name: string; value: string} | null {
93
+ try {
94
+ const input =
95
+ typeof value?.input === 'string' ? JSON.parse(value.input) : value?.input;
96
+ if (!input || typeof input !== 'object') return null;
97
+ const key = Object.keys(input)[0];
98
+ if (!key) return null;
99
+ return {name: `${FILTER_PREFIX}${key}`, value: JSON.stringify(input[key])};
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function isPriceFilter(filter: any): boolean {
106
+ const type = String(filter?.type ?? '').toLowerCase();
107
+ return type === 'price_range';
108
+ }
109
+
110
+ export function Facets({
111
+ filters,
112
+ resultsUrl,
113
+ resultsCount,
114
+ sortOptions = COLLECTION_SORT_OPTIONS,
115
+ defaultSortBy = 'manual',
116
+ terms,
117
+ }: FacetsProps) {
118
+ const adapter = useAdapter();
119
+ const {Link} = adapter;
120
+ const navigate = adapter.useNavigate();
121
+ const searchParams = adapter.useSearchParams();
122
+ const pathname = adapter.usePathname();
123
+ const rootRef = useRef<HTMLDivElement>(null);
124
+ const formRef = useRef<HTMLFormElement>(null);
125
+ const dialogRef = useRef<HTMLDialogElement>(null);
126
+ const timerRef = useRef<ReturnType<typeof setTimeout>>();
127
+
128
+ // Applied state comes straight from the URL (the mirror of the
129
+ // server-rendered form state in Liquid).
130
+ const activeCount = [...searchParams.keys()].filter((key) =>
131
+ key.startsWith(FILTER_PREFIX),
132
+ ).length;
133
+
134
+ const currentSort = searchParams.get('sort_by') ?? defaultSortBy;
135
+ const sortLabel =
136
+ sortOptions.find((option) => option.value === currentSort)?.label ?? '';
137
+
138
+ let appliedPrice: {min?: number; max?: number} = {};
139
+ try {
140
+ appliedPrice = JSON.parse(searchParams.get('filter.price') ?? 'null') ?? {};
141
+ } catch {
142
+ appliedPrice = {};
143
+ }
144
+
145
+ /** Mirror of facets.ts #formUrl(): FormData → clean search params
146
+ * (shared cleanFormParams from commerce-core), with the price pair
147
+ * merged into one filter.price JSON param. */
148
+ const formUrl = (): string => {
149
+ const form = formRef.current;
150
+ if (!form) return pathname;
151
+ const entries: Array<[string, FormDataEntryValue]> = [];
152
+ let priceMin = '';
153
+ let priceMax = '';
154
+ for (const [name, raw] of new FormData(form)) {
155
+ const value = typeof raw === 'string' ? raw : '';
156
+ if (name === 'price.min') {
157
+ priceMin = value;
158
+ continue;
159
+ }
160
+ if (name === 'price.max') {
161
+ priceMax = value;
162
+ continue;
163
+ }
164
+ entries.push([name, value]);
165
+ }
166
+ const params = cleanFormParams(entries);
167
+ if (priceMin !== '' || priceMax !== '') {
168
+ const price: {min?: number; max?: number} = {};
169
+ if (priceMin !== '') price.min = Number(priceMin);
170
+ if (priceMax !== '') price.max = Number(priceMax);
171
+ params.append('filter.price', JSON.stringify(price));
172
+ }
173
+ return `${pathname}?${params}`;
174
+ };
175
+
176
+ const closePanels = () => {
177
+ for (const panel of rootRef.current?.querySelectorAll('details[open]') ??
178
+ []) {
179
+ panel.removeAttribute('open');
180
+ }
181
+ };
182
+
183
+ const openFilters = () => {
184
+ const dialog = dialogRef.current;
185
+ if (dialog && !dialog.open) dialog.showModal();
186
+ };
187
+
188
+ const cancelFilters = () => dialogRef.current?.close();
189
+
190
+ /** Backdrop light-dismiss: clicks on the <dialog> itself hit the backdrop. */
191
+ const onDialogClick = (event: React.MouseEvent) => {
192
+ if (event.target === dialogRef.current) dialogRef.current?.close();
193
+ };
194
+
195
+ /** Any close path (Cancel, X, Esc, backdrop) discards unapplied edits. */
196
+ const onDialogClose = () => formRef.current?.reset();
197
+
198
+ /** Sort radios apply immediately. */
199
+ const onSortChange = () => {
200
+ clearTimeout(timerRef.current);
201
+ timerRef.current = setTimeout(() => void navigate(formUrl()), 100);
202
+ };
203
+
204
+ const onSubmit = (event: React.FormEvent) => {
205
+ event.preventDefault();
206
+ // Capture the form state BEFORE closing — close resets the form
207
+ const url = formUrl();
208
+ dialogRef.current?.close();
209
+ void navigate(url);
210
+ };
211
+
212
+ useEffect(() => {
213
+ const onDocumentClick = (event: MouseEvent) => {
214
+ if (event.target instanceof Node && rootRef.current?.contains(event.target))
215
+ return;
216
+ closePanels();
217
+ };
218
+ const onKeydown = (event: KeyboardEvent) => {
219
+ if (event.key === 'Escape') closePanels();
220
+ };
221
+ document.addEventListener('click', onDocumentClick);
222
+ document.addEventListener('keydown', onKeydown);
223
+ return () => {
224
+ document.removeEventListener('click', onDocumentClick);
225
+ document.removeEventListener('keydown', onKeydown);
226
+ clearTimeout(timerRef.current);
227
+ };
228
+ }, []);
229
+
230
+ const clearAllHref = `${resultsUrl}${terms ? `?q=${encodeURIComponent(terms)}` : ''}`;
231
+ const colorNames = t('products.color_option_match')
232
+ .toLowerCase()
233
+ .split(',')
234
+ .map((name) => name.trim())
235
+ .filter(Boolean);
236
+
237
+ return (
238
+ <div ref={rootRef}>
239
+ {/* Remount on URL change so uncontrolled defaults track the applied state */}
240
+ <form
241
+ key={searchParams.toString()}
242
+ ref={formRef}
243
+ action={resultsUrl}
244
+ method="get"
245
+ onSubmit={onSubmit}
246
+ >
247
+ {terms ? <input type="hidden" name="q" value={terms} /> : null}
248
+ <div className="facets-toolbar">
249
+ <span className="facets-toolbar__count">
250
+ {t(resultsCount === 1 ? 'facets.items.one' : 'facets.items.other', {
251
+ count: resultsCount,
252
+ })}
253
+ </span>
254
+
255
+ <div className="facets-toolbar__actions">
256
+ <span className="facets-toolbar__group">
257
+ <button
258
+ type="button"
259
+ className="facets-toolbar__button"
260
+ onClick={openFilters}
261
+ >
262
+ {t('facets.filter')}
263
+ <span className="facets-toolbar__value">( {activeCount} )</span>
264
+ </button>
265
+ {activeCount > 0 && (
266
+ <Link
267
+ className="facets-toolbar__clear"
268
+ to={clearAllHref}
269
+ aria-label={t('facets.clear_all')}
270
+ >
271
+ <Icon name="icon-close" />
272
+ </Link>
273
+ )}
274
+ </span>
275
+
276
+ <details className="facet-sort">
277
+ <summary className="facets-toolbar__button">
278
+ {t('facets.sort')}
279
+ <span className="facets-toolbar__value">( {sortLabel} )</span>
280
+ </summary>
281
+ <div className="facet-sort__panel">
282
+ {sortOptions.map((option) => (
283
+ <label className="facet-sort__option" key={option.value}>
284
+ <input
285
+ type="radio"
286
+ className="visually-hidden"
287
+ name="sort_by"
288
+ value={option.value}
289
+ defaultChecked={option.value === currentSort}
290
+ onChange={onSortChange}
291
+ />
292
+ <span>{option.label}</span>
293
+ </label>
294
+ ))}
295
+ </div>
296
+ </details>
297
+ </div>
298
+ </div>
299
+
300
+ <dialog
301
+ className="facets-dialog"
302
+ ref={dialogRef}
303
+ scroll-lock=""
304
+ onClick={onDialogClick}
305
+ onClose={onDialogClose}
306
+ aria-label={t('facets.filters')}
307
+ >
308
+ <header className="facets-dialog__header">
309
+ <h2 className="facets-dialog__title">{t('facets.filters')}</h2>
310
+ <button
311
+ type="button"
312
+ className="facets-dialog__close"
313
+ onClick={cancelFilters}
314
+ aria-label={t('general.close')}
315
+ >
316
+ <Icon name="icon-close" />
317
+ </button>
318
+ </header>
319
+
320
+ <div className="facets-dialog__body">
321
+ {filters.map((filter: any) => {
322
+ if (isPriceFilter(filter)) {
323
+ return (
324
+ <fieldset className="facets-group" key={filter.id ?? filter.label}>
325
+ <legend className="facets-group__label">{filter.label}</legend>
326
+ <div className="facets-group__price cluster">
327
+ <label>
328
+ <span className="visually-hidden">{t('facets.from')}</span>
329
+ <input
330
+ type="number"
331
+ name="price.min"
332
+ placeholder={t('facets.from')}
333
+ min="0"
334
+ defaultValue={appliedPrice.min ?? ''}
335
+ />
336
+ </label>
337
+ <label>
338
+ <span className="visually-hidden">{t('facets.to')}</span>
339
+ <input
340
+ type="number"
341
+ name="price.max"
342
+ placeholder={t('facets.to')}
343
+ min="0"
344
+ defaultValue={appliedPrice.max ?? ''}
345
+ />
346
+ </label>
347
+ </div>
348
+ </fieldset>
349
+ );
350
+ }
351
+
352
+ // Color filters get swatch chips: native swatch data on any
353
+ // value, or a filter named like a color
354
+ const values: any[] = filter.values ?? [];
355
+ const filterLabelDown = String(filter.label ?? '').toLowerCase();
356
+ const filterIsColor =
357
+ Boolean(values[0]?.swatch) ||
358
+ colorNames.some((name) => filterLabelDown.includes(name));
359
+
360
+ return (
361
+ <fieldset className="facets-group" key={filter.id ?? filter.label}>
362
+ <legend className="facets-group__label">{filter.label}</legend>
363
+ <div className="facets-group__values">
364
+ {values.map((value: any) => {
365
+ const param = valueParam(value);
366
+ if (!param) return null;
367
+ const active = searchParams
368
+ .getAll(param.name)
369
+ .includes(param.value);
370
+ const disabled = value.count === 0 && !active;
371
+ return (
372
+ <label
373
+ className={`facet-pill${disabled ? ' facet-pill--disabled' : ''}`}
374
+ key={value.id ?? param.value}
375
+ >
376
+ <input
377
+ type="checkbox"
378
+ className="visually-hidden"
379
+ name={param.name}
380
+ value={param.value}
381
+ defaultChecked={active}
382
+ disabled={disabled}
383
+ />
384
+ <span className="facet-pill__label">
385
+ {filterIsColor && (
386
+ <span
387
+ className="swatch facet-pill__swatch"
388
+ style={swatchStyle(value.swatch, value.label)}
389
+ ></span>
390
+ )}
391
+ {value.label}
392
+ </span>
393
+ </label>
394
+ );
395
+ })}
396
+ </div>
397
+ </fieldset>
398
+ );
399
+ })}
400
+ </div>
401
+
402
+ <footer className="facets-dialog__footer">
403
+ <button
404
+ type="button"
405
+ className="facets-dialog__cancel"
406
+ onClick={cancelFilters}
407
+ >
408
+ {t('facets.cancel')}
409
+ </button>
410
+ <button type="submit" className="button">
411
+ {t('facets.apply')}
412
+ </button>
413
+ </footer>
414
+ </dialog>
415
+
416
+ <noscript>
417
+ <button type="submit" className="button button--secondary">
418
+ {t('facets.apply')}
419
+ </button>
420
+ </noscript>
421
+ </form>
422
+ </div>
423
+ );
424
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Mirror of `{{ 'icon-*.svg' | inline_asset_content }}`: inlines the
3
+ * theme's SVG icons (copied from the theme's assets/) so they inherit
4
+ * currentColor. The SVG markup is inlined as string constants (copied
5
+ * verbatim from the theme's assets/icons/*.svg) so the package stays
6
+ * bundler-agnostic — no import.meta.glob.
7
+ */
8
+
9
+ export type IconName =
10
+ | 'icon-account'
11
+ | 'icon-cart'
12
+ | 'icon-chevron-left'
13
+ | 'icon-chevron-right'
14
+ | 'icon-close'
15
+ | 'icon-facebook'
16
+ | 'icon-instagram'
17
+ | 'icon-pause'
18
+ | 'icon-pinterest'
19
+ | 'icon-play'
20
+ | 'icon-search'
21
+ | 'icon-star'
22
+ | 'icon-tiktok'
23
+ | 'icon-x'
24
+ | 'icon-youtube';
25
+
26
+ const ICONS: Record<IconName, string> = {
27
+ 'icon-account': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" slot="signed-out-avatar">
28
+ <circle cx="10" cy="6.5" r="2.75" stroke="currentColor" stroke-width="var(--icon-stroke-width)" />
29
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
30
+ stroke-width="var(--icon-stroke-width)"
31
+ d="m5.172 14.591-.437.786a.588.588 0 0 0 .514.873h9.502a.588.588 0 0 0 .514-.873l-.437-.786a5.523 5.523 0 0 0-9.656 0" />
32
+ </svg>`,
33
+ 'icon-cart': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
34
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
35
+ stroke-width="var(--icon-stroke-width)"
36
+ d="M3.392 6.875h13.216v8.016c0 .567-.224 1.112-.624 1.513-.4.402-.941.627-1.506.627H5.522a2.13 2.13 0 0 1-1.506-.627 2.15 2.15 0 0 1-.624-1.513zM8.818 2.969h2.333c.618 0 1.211.247 1.649.686a2.35 2.35 0 0 1 .683 1.658v1.562H6.486V5.313c0-.622.246-1.218.683-1.658a2.33 2.33 0 0 1 1.65-.686" />
37
+ </svg>`,
38
+ 'icon-chevron-left': `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
39
+ <path d="M14.5 5.5L8 12l6.5 6.5"/>
40
+ </svg>`,
41
+ 'icon-chevron-right': `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
42
+ <path d="M9.5 5.5L16 12l-6.5 6.5"/>
43
+ </svg>`,
44
+ 'icon-close': `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
45
+ <path d="M6 6l12 12M18 6L6 18"/>
46
+ </svg>`,
47
+ 'icon-facebook': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
48
+ <path stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>
49
+ </svg>`,
50
+ 'icon-instagram': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
51
+ <rect x="2.5" y="2.5" width="19" height="19" rx="5" stroke="currentColor" stroke-width="1.5"/>
52
+ <circle cx="12" cy="12" r="4.25" stroke="currentColor" stroke-width="1.5"/>
53
+ <circle cx="17.3" cy="6.7" r="1.1" fill="currentColor"/>
54
+ </svg>`,
55
+ 'icon-pause': `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
56
+ <path d="M5.5 3.5v9M10.5 3.5v9"/>
57
+ </svg>`,
58
+ 'icon-pinterest': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
59
+ <path fill="currentColor" d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026z"/>
60
+ </svg>`,
61
+ 'icon-play': `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
62
+ <path d="M5 3.2v9.6c0 .4.45.65.8.43l7-4.8a.5.5 0 0 0 0-.86l-7-4.8A.5.5 0 0 0 5 3.2Z"/>
63
+ </svg>`,
64
+ 'icon-search': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" aria-hidden="true">
65
+ <path stroke-linejoin="round" stroke-linecap="round" stroke="currentColor" d="M3.12939 3.15747C4.90196 1.47122 7.63947 1.37352 9.36572 2.98853L9.52979 3.15063C11.2696 4.96073 11.2614 7.81258 9.54443 9.53247C7.78203 11.2161 5.03421 11.2591 3.29541 9.69653L3.13037 9.54028C1.40138 7.73421 1.40065 4.96368 3.12939 3.15747Z"/>
66
+ <path stroke-linejoin="round" stroke-linecap="round" stroke="currentColor" d="M9.66699 9.66675L13.3337 13.3334"/>
67
+ </svg>`,
68
+ 'icon-star': `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
69
+ <path d="M12 2.5l2.95 6.32 6.93.84-5.11 4.75 1.34 6.84L12 17.85l-6.11 3.4 1.34-6.84-5.11-4.75 6.93-.84z"/>
70
+ </svg>`,
71
+ 'icon-tiktok': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
72
+ <path fill="currentColor" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/>
73
+ </svg>`,
74
+ 'icon-x': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
75
+ <path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/>
76
+ </svg>`,
77
+ 'icon-youtube': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
78
+ <path stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z"/>
79
+ <path fill="currentColor" d="M9.75 15.02 15.5 11.75 9.75 8.48z"/>
80
+ </svg>`,
81
+ };
82
+
83
+ export function Icon({name, className}: {name: IconName; className?: string}) {
84
+ const svg = ICONS[name];
85
+ if (!svg) return null;
86
+ if (!className) {
87
+ return <span dangerouslySetInnerHTML={{__html: svg}} style={{display: 'contents'}} />;
88
+ }
89
+ return (
90
+ <span
91
+ className={className}
92
+ style={{display: 'contents'}}
93
+ dangerouslySetInnerHTML={{__html: svg}}
94
+ />
95
+ );
96
+ }