@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.
- package/LICENSE.md +49 -0
- package/README.md +63 -0
- package/dist/commerce/countdown.d.ts +18 -0
- package/dist/commerce/countdown.js +28 -0
- package/dist/commerce/events.d.ts +29 -0
- package/dist/commerce/events.js +14 -0
- package/dist/commerce/facets.d.ts +27 -0
- package/dist/commerce/facets.js +71 -0
- package/dist/commerce/index.d.ts +11 -0
- package/dist/commerce/index.js +11 -0
- package/dist/commerce/money.d.ts +51 -0
- package/dist/commerce/money.js +83 -0
- package/dist/commerce/product.d.ts +102 -0
- package/dist/commerce/product.js +93 -0
- package/dist/editor/frame.d.ts +57 -0
- package/dist/editor/frame.js +241 -0
- package/dist/editor/host.d.ts +31 -0
- package/dist/editor/host.js +67 -0
- package/dist/editor/index.d.ts +9 -0
- package/dist/editor/index.js +9 -0
- package/dist/react/adapter.d.ts +49 -0
- package/dist/react/adapter.js +23 -0
- package/dist/react/blocks/_box.d.ts +10 -0
- package/dist/react/blocks/_box.js +17 -0
- package/dist/react/blocks/_slide.d.ts +24 -0
- package/dist/react/blocks/_slide.js +21 -0
- package/dist/react/blocks/_video-card.d.ts +11 -0
- package/dist/react/blocks/_video-card.js +39 -0
- package/dist/react/blocks/accordion.d.ts +12 -0
- package/dist/react/blocks/accordion.js +5 -0
- package/dist/react/blocks/button.d.ts +10 -0
- package/dist/react/blocks/button.js +15 -0
- package/dist/react/blocks/countdown.d.ts +9 -0
- package/dist/react/blocks/countdown.js +64 -0
- package/dist/react/blocks/custom-liquid.d.ts +11 -0
- package/dist/react/blocks/custom-liquid.js +4 -0
- package/dist/react/blocks/group.d.ts +9 -0
- package/dist/react/blocks/group.js +8 -0
- package/dist/react/blocks/image.d.ts +8 -0
- package/dist/react/blocks/image.js +7 -0
- package/dist/react/blocks/message.d.ts +14 -0
- package/dist/react/blocks/message.js +5 -0
- package/dist/react/blocks/quote.d.ts +9 -0
- package/dist/react/blocks/quote.js +5 -0
- package/dist/react/blocks/spacer.d.ts +8 -0
- package/dist/react/blocks/spacer.js +5 -0
- package/dist/react/blocks/story.d.ts +17 -0
- package/dist/react/blocks/story.js +7 -0
- package/dist/react/blocks/text.d.ts +9 -0
- package/dist/react/blocks/text.js +5 -0
- package/dist/react/blocks/video.d.ts +17 -0
- package/dist/react/blocks/video.js +56 -0
- package/dist/react/components/Facets.d.ts +21 -0
- package/dist/react/components/Facets.js +220 -0
- package/dist/react/components/Icon.d.ts +12 -0
- package/dist/react/components/Icon.js +66 -0
- package/dist/react/components/PaymentIcons.d.ts +5 -0
- package/dist/react/components/PaymentIcons.generated.d.ts +22 -0
- package/dist/react/components/PaymentIcons.generated.js +21 -0
- package/dist/react/components/PaymentIcons.js +18 -0
- package/dist/react/components/Price.d.ts +24 -0
- package/dist/react/components/Price.js +9 -0
- package/dist/react/components/ProductBadges.d.ts +24 -0
- package/dist/react/components/ProductBadges.js +57 -0
- package/dist/react/components/ProductCard.d.ts +17 -0
- package/dist/react/components/ProductCard.js +202 -0
- package/dist/react/components/Rating.d.ts +11 -0
- package/dist/react/components/Rating.js +35 -0
- package/dist/react/components/SwatchStyle.d.ts +23 -0
- package/dist/react/components/SwatchStyle.js +22 -0
- package/dist/react/components/VideoModal.d.ts +55 -0
- package/dist/react/components/VideoModal.js +102 -0
- package/dist/react/engine/CssVariables.d.ts +1 -0
- package/dist/react/engine/CssVariables.js +105 -0
- package/dist/react/engine/context.d.ts +43 -0
- package/dist/react/engine/context.js +45 -0
- package/dist/react/engine/fonts.d.ts +15 -0
- package/dist/react/engine/fonts.js +33 -0
- package/dist/react/engine/images.d.ts +24 -0
- package/dist/react/engine/images.js +40 -0
- package/dist/react/engine/render.d.ts +29 -0
- package/dist/react/engine/render.js +87 -0
- package/dist/react/engine/settings.d.ts +8 -0
- package/dist/react/engine/settings.js +68 -0
- package/dist/react/engine/store.d.ts +64 -0
- package/dist/react/engine/store.js +42 -0
- package/dist/react/engine/translate.d.ts +7 -0
- package/dist/react/engine/translate.js +33 -0
- package/dist/react/engine/types.d.ts +128 -0
- package/dist/react/engine/types.js +1 -0
- package/dist/react/index.d.ts +38 -0
- package/dist/react/index.js +39 -0
- package/dist/react/registries.d.ts +10 -0
- package/dist/react/registries.js +68 -0
- package/dist/react/registries.server.d.ts +2 -0
- package/dist/react/registries.server.js +98 -0
- package/dist/react/sections/404.d.ts +10 -0
- package/dist/react/sections/404.js +10 -0
- package/dist/react/sections/announcement-bar.d.ts +14 -0
- package/dist/react/sections/announcement-bar.js +84 -0
- package/dist/react/sections/article.d.ts +17 -0
- package/dist/react/sections/article.js +31 -0
- package/dist/react/sections/contact-form.d.ts +16 -0
- package/dist/react/sections/contact-form.js +8 -0
- package/dist/react/sections/custom-liquid.d.ts +14 -0
- package/dist/react/sections/custom-liquid.js +7 -0
- package/dist/react/sections/custom-section.d.ts +14 -0
- package/dist/react/sections/custom-section.js +9 -0
- package/dist/react/sections/featured-collection.d.ts +10 -0
- package/dist/react/sections/featured-collection.js +23 -0
- package/dist/react/sections/hero.d.ts +6 -0
- package/dist/react/sections/hero.js +35 -0
- package/dist/react/sections/image-with-text.d.ts +13 -0
- package/dist/react/sections/image-with-text.js +9 -0
- package/dist/react/sections/multicolumn.d.ts +15 -0
- package/dist/react/sections/multicolumn.js +8 -0
- package/dist/react/sections/page.d.ts +13 -0
- package/dist/react/sections/page.js +11 -0
- package/dist/react/sections/password.d.ts +15 -0
- package/dist/react/sections/password.js +9 -0
- package/dist/react/sections/rich-text.d.ts +12 -0
- package/dist/react/sections/rich-text.js +7 -0
- package/dist/react/sections/stories.d.ts +18 -0
- package/dist/react/sections/stories.js +10 -0
- package/dist/react/sections/video-bubble.d.ts +13 -0
- package/dist/react/sections/video-bubble.js +54 -0
- package/dist/react/sections/video-carousel.d.ts +19 -0
- package/dist/react/sections/video-carousel.js +9 -0
- package/dist/react/server.d.ts +7 -0
- package/dist/react/server.js +42 -0
- package/dist/schemas/bridge.d.ts +98 -0
- package/dist/schemas/bridge.js +50 -0
- package/dist/schemas/data.d.ts +54 -0
- package/dist/schemas/data.js +22 -0
- package/dist/schemas/index.d.ts +14 -0
- package/dist/schemas/index.js +14 -0
- package/dist/schemas/manifest.d.ts +63 -0
- package/dist/schemas/manifest.js +32 -0
- package/dist/schemas/schema.d.ts +159 -0
- package/dist/schemas/schema.js +129 -0
- package/dist/schemas/validate.d.ts +22 -0
- package/dist/schemas/validate.js +199 -0
- package/package.json +84 -0
- package/src/commerce/countdown.ts +38 -0
- package/src/commerce/events.ts +43 -0
- package/src/commerce/facets.ts +88 -0
- package/src/commerce/index.ts +11 -0
- package/src/commerce/money.ts +120 -0
- package/src/commerce/product.ts +177 -0
- package/src/editor/frame.ts +305 -0
- package/src/editor/host.ts +101 -0
- package/src/editor/index.ts +9 -0
- package/src/react/adapter.tsx +83 -0
- package/src/react/blocks/_box.tsx +37 -0
- package/src/react/blocks/_slide.tsx +96 -0
- package/src/react/blocks/_video-card.tsx +131 -0
- package/src/react/blocks/accordion.tsx +33 -0
- package/src/react/blocks/button.tsx +36 -0
- package/src/react/blocks/countdown.tsx +105 -0
- package/src/react/blocks/custom-liquid.tsx +21 -0
- package/src/react/blocks/group.tsx +32 -0
- package/src/react/blocks/image.tsx +29 -0
- package/src/react/blocks/message.tsx +28 -0
- package/src/react/blocks/quote.tsx +24 -0
- package/src/react/blocks/spacer.tsx +17 -0
- package/src/react/blocks/story.tsx +40 -0
- package/src/react/blocks/text.tsx +24 -0
- package/src/react/blocks/video.tsx +132 -0
- package/src/react/components/Facets.tsx +424 -0
- package/src/react/components/Icon.tsx +96 -0
- package/src/react/components/PaymentIcons.generated.ts +24 -0
- package/src/react/components/PaymentIcons.tsx +27 -0
- package/src/react/components/Price.tsx +72 -0
- package/src/react/components/ProductBadges.tsx +95 -0
- package/src/react/components/ProductCard.tsx +417 -0
- package/src/react/components/Rating.tsx +59 -0
- package/src/react/components/SwatchStyle.tsx +50 -0
- package/src/react/components/VideoModal.tsx +236 -0
- package/src/react/engine/CssVariables.tsx +142 -0
- package/src/react/engine/context.tsx +108 -0
- package/src/react/engine/fonts.ts +42 -0
- package/src/react/engine/images.tsx +58 -0
- package/src/react/engine/render.tsx +190 -0
- package/src/react/engine/settings.ts +89 -0
- package/src/react/engine/store.ts +103 -0
- package/src/react/engine/translate.ts +40 -0
- package/src/react/engine/types.ts +142 -0
- package/src/react/index.ts +78 -0
- package/src/react/registries.server.ts +110 -0
- package/src/react/registries.ts +80 -0
- package/src/react/sections/404.tsx +37 -0
- package/src/react/sections/announcement-bar.tsx +170 -0
- package/src/react/sections/article.tsx +97 -0
- package/src/react/sections/contact-form.tsx +104 -0
- package/src/react/sections/custom-liquid.tsx +39 -0
- package/src/react/sections/custom-section.tsx +44 -0
- package/src/react/sections/featured-collection.tsx +89 -0
- package/src/react/sections/hero.tsx +84 -0
- package/src/react/sections/image-with-text.tsx +61 -0
- package/src/react/sections/multicolumn.tsx +46 -0
- package/src/react/sections/page.tsx +43 -0
- package/src/react/sections/password.tsx +60 -0
- package/src/react/sections/rich-text.tsx +37 -0
- package/src/react/sections/stories.tsx +51 -0
- package/src/react/sections/video-bubble.tsx +145 -0
- package/src/react/sections/video-carousel.tsx +57 -0
- package/src/react/server.ts +60 -0
- package/src/schemas/bridge.ts +143 -0
- package/src/schemas/data.ts +72 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/manifest.ts +85 -0
- package/src/schemas/schema.ts +287 -0
- 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
|
+
}
|