hydrogen-forge 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/README.md +212 -0
- package/dist/commands/add.d.ts +7 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +123 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +160 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/setup-mcp.d.ts +7 -0
- package/dist/commands/setup-mcp.d.ts.map +1 -0
- package/dist/commands/setup-mcp.js +179 -0
- package/dist/commands/setup-mcp.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/generators.d.ts +6 -0
- package/dist/lib/generators.d.ts.map +1 -0
- package/dist/lib/generators.js +470 -0
- package/dist/lib/generators.js.map +1 -0
- package/dist/lib/utils.d.ts +17 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +101 -0
- package/dist/lib/utils.js.map +1 -0
- package/package.json +54 -0
- package/templates/starter/.env.example +21 -0
- package/templates/starter/.graphqlrc.ts +27 -0
- package/templates/starter/README.md +117 -0
- package/templates/starter/app/assets/favicon.svg +28 -0
- package/templates/starter/app/components/AddToCartButton.tsx +102 -0
- package/templates/starter/app/components/Aside.tsx +136 -0
- package/templates/starter/app/components/CartLineItem.tsx +229 -0
- package/templates/starter/app/components/CartMain.tsx +131 -0
- package/templates/starter/app/components/CartSummary.tsx +315 -0
- package/templates/starter/app/components/CollectionFilters.tsx +330 -0
- package/templates/starter/app/components/CollectionGrid.tsx +141 -0
- package/templates/starter/app/components/Footer.tsx +218 -0
- package/templates/starter/app/components/Header.tsx +296 -0
- package/templates/starter/app/components/PageLayout.tsx +174 -0
- package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
- package/templates/starter/app/components/ProductCard.tsx +151 -0
- package/templates/starter/app/components/ProductForm.tsx +156 -0
- package/templates/starter/app/components/ProductGallery.tsx +164 -0
- package/templates/starter/app/components/ProductGrid.tsx +64 -0
- package/templates/starter/app/components/ProductImage.tsx +23 -0
- package/templates/starter/app/components/ProductItem.tsx +44 -0
- package/templates/starter/app/components/ProductPrice.tsx +97 -0
- package/templates/starter/app/components/SearchDialog.tsx +599 -0
- package/templates/starter/app/components/SearchForm.tsx +68 -0
- package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
- package/templates/starter/app/components/SearchResults.tsx +161 -0
- package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
- package/templates/starter/app/entry.client.tsx +21 -0
- package/templates/starter/app/entry.server.tsx +53 -0
- package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
- package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
- package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
- package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
- package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
- package/templates/starter/app/lib/context.ts +60 -0
- package/templates/starter/app/lib/fragments.ts +234 -0
- package/templates/starter/app/lib/orderFilters.ts +90 -0
- package/templates/starter/app/lib/redirect.ts +23 -0
- package/templates/starter/app/lib/search.ts +79 -0
- package/templates/starter/app/lib/session.ts +72 -0
- package/templates/starter/app/lib/variants.ts +46 -0
- package/templates/starter/app/root.tsx +209 -0
- package/templates/starter/app/routes/$.tsx +11 -0
- package/templates/starter/app/routes/[robots.txt].tsx +117 -0
- package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
- package/templates/starter/app/routes/_index.tsx +167 -0
- package/templates/starter/app/routes/account.$.tsx +9 -0
- package/templates/starter/app/routes/account._index.tsx +5 -0
- package/templates/starter/app/routes/account.addresses.tsx +516 -0
- package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
- package/templates/starter/app/routes/account.orders._index.tsx +222 -0
- package/templates/starter/app/routes/account.profile.tsx +133 -0
- package/templates/starter/app/routes/account.tsx +97 -0
- package/templates/starter/app/routes/account_.authorize.tsx +5 -0
- package/templates/starter/app/routes/account_.login.tsx +7 -0
- package/templates/starter/app/routes/account_.logout.tsx +11 -0
- package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
- package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
- package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
- package/templates/starter/app/routes/blogs._index.tsx +109 -0
- package/templates/starter/app/routes/cart.$lines.tsx +70 -0
- package/templates/starter/app/routes/cart.tsx +117 -0
- package/templates/starter/app/routes/collections.$handle.tsx +161 -0
- package/templates/starter/app/routes/collections._index.tsx +133 -0
- package/templates/starter/app/routes/collections.all.tsx +122 -0
- package/templates/starter/app/routes/discount.$code.tsx +48 -0
- package/templates/starter/app/routes/pages.$handle.tsx +88 -0
- package/templates/starter/app/routes/policies.$handle.tsx +93 -0
- package/templates/starter/app/routes/policies._index.tsx +69 -0
- package/templates/starter/app/routes/products.$handle.tsx +232 -0
- package/templates/starter/app/routes/search.tsx +426 -0
- package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
- package/templates/starter/app/routes.ts +9 -0
- package/templates/starter/app/styles/app.css +574 -0
- package/templates/starter/app/styles/reset.css +139 -0
- package/templates/starter/app/styles/tailwind.css +116 -0
- package/templates/starter/customer-accountapi.generated.d.ts +543 -0
- package/templates/starter/env.d.ts +7 -0
- package/templates/starter/eslint.config.js +247 -0
- package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
- package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
- package/templates/starter/guides/search/search.jpg +0 -0
- package/templates/starter/guides/search/search.md +335 -0
- package/templates/starter/package.json +71 -0
- package/templates/starter/postcss.config.js +6 -0
- package/templates/starter/public/.gitkeep +0 -0
- package/templates/starter/react-router.config.ts +13 -0
- package/templates/starter/server.ts +59 -0
- package/templates/starter/storefrontapi.generated.d.ts +1264 -0
- package/templates/starter/tailwind.config.js +83 -0
- package/templates/starter/tsconfig.json +67 -0
- package/templates/starter/vite.config.ts +32 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {Link, useNavigate} from 'react-router';
|
|
2
|
+
import {type MappedProductOptions} from '@shopify/hydrogen';
|
|
3
|
+
import type {
|
|
4
|
+
Maybe,
|
|
5
|
+
ProductOptionValueSwatch,
|
|
6
|
+
} from '@shopify/hydrogen/storefront-api-types';
|
|
7
|
+
import {AddToCartButton} from './AddToCartButton';
|
|
8
|
+
import {useAside} from './Aside';
|
|
9
|
+
import type {ProductFragment} from 'storefrontapi.generated';
|
|
10
|
+
|
|
11
|
+
export function ProductForm({
|
|
12
|
+
productOptions,
|
|
13
|
+
selectedVariant,
|
|
14
|
+
}: {
|
|
15
|
+
productOptions: MappedProductOptions[];
|
|
16
|
+
selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
|
|
17
|
+
}) {
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const {open} = useAside();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="space-y-6">
|
|
23
|
+
{/* Product Options */}
|
|
24
|
+
{productOptions.map((option) => {
|
|
25
|
+
// If there is only a single value in the option values, don't display the option
|
|
26
|
+
if (option.optionValues.length === 1) return null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div key={option.name} className="space-y-3">
|
|
30
|
+
<h5 className="text-sm font-medium text-secondary-900">
|
|
31
|
+
{option.name}
|
|
32
|
+
</h5>
|
|
33
|
+
<div className="flex flex-wrap gap-2">
|
|
34
|
+
{option.optionValues.map((value) => {
|
|
35
|
+
const {
|
|
36
|
+
name,
|
|
37
|
+
handle,
|
|
38
|
+
variantUriQuery,
|
|
39
|
+
selected,
|
|
40
|
+
available,
|
|
41
|
+
exists,
|
|
42
|
+
isDifferentProduct,
|
|
43
|
+
swatch,
|
|
44
|
+
} = value;
|
|
45
|
+
|
|
46
|
+
const baseClasses =
|
|
47
|
+
'relative flex min-w-[3rem] items-center justify-center rounded-md border px-3 py-2 text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2';
|
|
48
|
+
|
|
49
|
+
const stateClasses = selected
|
|
50
|
+
? 'border-primary-600 bg-primary-50 text-primary-700'
|
|
51
|
+
: available
|
|
52
|
+
? 'border-secondary-300 bg-white text-secondary-900 hover:border-secondary-400'
|
|
53
|
+
: 'border-secondary-200 bg-secondary-50 text-secondary-400 cursor-not-allowed';
|
|
54
|
+
|
|
55
|
+
if (isDifferentProduct) {
|
|
56
|
+
return (
|
|
57
|
+
<Link
|
|
58
|
+
key={option.name + name}
|
|
59
|
+
className={`${baseClasses} ${stateClasses}`}
|
|
60
|
+
prefetch="intent"
|
|
61
|
+
preventScrollReset
|
|
62
|
+
replace
|
|
63
|
+
to={`/products/${handle}?${variantUriQuery}`}
|
|
64
|
+
>
|
|
65
|
+
<ProductOptionSwatch swatch={swatch} name={name} />
|
|
66
|
+
{!available && (
|
|
67
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
68
|
+
<span className="h-px w-full rotate-[-45deg] bg-secondary-400" />
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</Link>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
key={option.name + name}
|
|
78
|
+
type="button"
|
|
79
|
+
className={`${baseClasses} ${stateClasses}`}
|
|
80
|
+
disabled={!exists}
|
|
81
|
+
onClick={() => {
|
|
82
|
+
if (!selected && exists) {
|
|
83
|
+
void navigate(`?${variantUriQuery}`, {
|
|
84
|
+
replace: true,
|
|
85
|
+
preventScrollReset: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<ProductOptionSwatch swatch={swatch} name={name} />
|
|
91
|
+
{!available && exists && (
|
|
92
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
93
|
+
<span className="h-px w-full rotate-[-45deg] bg-secondary-400" />
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
|
|
104
|
+
{/* Add to Cart */}
|
|
105
|
+
<div className="space-y-3 pt-2">
|
|
106
|
+
<AddToCartButton
|
|
107
|
+
disabled={!selectedVariant || !selectedVariant.availableForSale}
|
|
108
|
+
onClick={() => {
|
|
109
|
+
open('cart');
|
|
110
|
+
}}
|
|
111
|
+
lines={
|
|
112
|
+
selectedVariant
|
|
113
|
+
? [
|
|
114
|
+
{
|
|
115
|
+
merchandiseId: selectedVariant.id,
|
|
116
|
+
quantity: 1,
|
|
117
|
+
selectedVariant,
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
: []
|
|
121
|
+
}
|
|
122
|
+
>
|
|
123
|
+
{selectedVariant?.availableForSale ? 'Add to Cart' : 'Sold Out'}
|
|
124
|
+
</AddToCartButton>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ProductOptionSwatch({
|
|
131
|
+
swatch,
|
|
132
|
+
name,
|
|
133
|
+
}: {
|
|
134
|
+
swatch?: Maybe<ProductOptionValueSwatch> | undefined;
|
|
135
|
+
name: string;
|
|
136
|
+
}) {
|
|
137
|
+
const image = swatch?.image?.previewImage?.url;
|
|
138
|
+
const color = swatch?.color;
|
|
139
|
+
|
|
140
|
+
if (!image && !color) {
|
|
141
|
+
return <span>{name}</span>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<span
|
|
146
|
+
aria-label={name}
|
|
147
|
+
className="block h-6 w-6 rounded-full border border-secondary-300 shadow-sm"
|
|
148
|
+
style={{
|
|
149
|
+
backgroundColor: color || 'transparent',
|
|
150
|
+
backgroundImage: image ? `url(${image})` : undefined,
|
|
151
|
+
backgroundSize: 'cover',
|
|
152
|
+
backgroundPosition: 'center',
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {useState} from 'react';
|
|
2
|
+
import {Image} from '@shopify/hydrogen';
|
|
3
|
+
|
|
4
|
+
export interface ProductGalleryProps {
|
|
5
|
+
images: Array<{
|
|
6
|
+
id?: string;
|
|
7
|
+
url: string;
|
|
8
|
+
altText?: string | null;
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
}>;
|
|
12
|
+
selectedVariantImage?: {
|
|
13
|
+
id?: string;
|
|
14
|
+
url: string;
|
|
15
|
+
altText?: string | null;
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ProductGallery({
|
|
22
|
+
images,
|
|
23
|
+
selectedVariantImage,
|
|
24
|
+
}: ProductGalleryProps) {
|
|
25
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
26
|
+
|
|
27
|
+
// If there's a selected variant image, find its index or use it as the main image
|
|
28
|
+
const allImages = selectedVariantImage
|
|
29
|
+
? [
|
|
30
|
+
selectedVariantImage,
|
|
31
|
+
...images.filter((img) => img.id !== selectedVariantImage.id),
|
|
32
|
+
]
|
|
33
|
+
: images;
|
|
34
|
+
|
|
35
|
+
const mainImage = allImages[selectedIndex] || allImages[0];
|
|
36
|
+
const hasThumbnails = allImages.length > 1;
|
|
37
|
+
|
|
38
|
+
if (!allImages.length) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="aspect-square w-full rounded-lg bg-secondary-100">
|
|
41
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
42
|
+
<svg
|
|
43
|
+
className="h-16 w-16 text-secondary-300"
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
viewBox="0 0 24 24"
|
|
47
|
+
>
|
|
48
|
+
<path
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeLinejoin="round"
|
|
51
|
+
strokeWidth={1}
|
|
52
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
53
|
+
/>
|
|
54
|
+
</svg>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-col gap-4">
|
|
62
|
+
{/* Main Image */}
|
|
63
|
+
<div className="relative aspect-square overflow-hidden rounded-lg bg-secondary-100">
|
|
64
|
+
{mainImage && (
|
|
65
|
+
<Image
|
|
66
|
+
alt={mainImage.altText || 'Product image'}
|
|
67
|
+
aspectRatio="1/1"
|
|
68
|
+
data={mainImage}
|
|
69
|
+
sizes="(min-width: 1024px) 50vw, 100vw"
|
|
70
|
+
className="h-full w-full object-cover object-center"
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{/* Navigation Arrows */}
|
|
75
|
+
{hasThumbnails && (
|
|
76
|
+
<>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-white/80 p-2 shadow-md backdrop-blur-sm transition-all hover:bg-white disabled:opacity-50"
|
|
80
|
+
onClick={() =>
|
|
81
|
+
setSelectedIndex((prev) =>
|
|
82
|
+
prev === 0 ? allImages.length - 1 : prev - 1,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
aria-label="Previous image"
|
|
86
|
+
>
|
|
87
|
+
<svg
|
|
88
|
+
className="h-5 w-5 text-secondary-900"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
viewBox="0 0 24 24"
|
|
92
|
+
>
|
|
93
|
+
<path
|
|
94
|
+
strokeLinecap="round"
|
|
95
|
+
strokeLinejoin="round"
|
|
96
|
+
strokeWidth={2}
|
|
97
|
+
d="M15 19l-7-7 7-7"
|
|
98
|
+
/>
|
|
99
|
+
</svg>
|
|
100
|
+
</button>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white/80 p-2 shadow-md backdrop-blur-sm transition-all hover:bg-white disabled:opacity-50"
|
|
104
|
+
onClick={() =>
|
|
105
|
+
setSelectedIndex((prev) =>
|
|
106
|
+
prev === allImages.length - 1 ? 0 : prev + 1,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
aria-label="Next image"
|
|
110
|
+
>
|
|
111
|
+
<svg
|
|
112
|
+
className="h-5 w-5 text-secondary-900"
|
|
113
|
+
fill="none"
|
|
114
|
+
stroke="currentColor"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
>
|
|
117
|
+
<path
|
|
118
|
+
strokeLinecap="round"
|
|
119
|
+
strokeLinejoin="round"
|
|
120
|
+
strokeWidth={2}
|
|
121
|
+
d="M9 5l7 7-7 7"
|
|
122
|
+
/>
|
|
123
|
+
</svg>
|
|
124
|
+
</button>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Image Counter */}
|
|
129
|
+
{hasThumbnails && (
|
|
130
|
+
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-xs text-white backdrop-blur-sm">
|
|
131
|
+
{selectedIndex + 1} / {allImages.length}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Thumbnails */}
|
|
137
|
+
{hasThumbnails && (
|
|
138
|
+
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
139
|
+
{allImages.map((image, index) => (
|
|
140
|
+
<button
|
|
141
|
+
key={image.id || index}
|
|
142
|
+
type="button"
|
|
143
|
+
className={`relative aspect-square w-16 flex-shrink-0 overflow-hidden rounded-md transition-all ${
|
|
144
|
+
index === selectedIndex
|
|
145
|
+
? 'ring-2 ring-primary-600 ring-offset-2'
|
|
146
|
+
: 'opacity-70 hover:opacity-100'
|
|
147
|
+
}`}
|
|
148
|
+
onClick={() => setSelectedIndex(index)}
|
|
149
|
+
aria-label={`View image ${index + 1}`}
|
|
150
|
+
>
|
|
151
|
+
<Image
|
|
152
|
+
alt={image.altText || `Product thumbnail ${index + 1}`}
|
|
153
|
+
aspectRatio="1/1"
|
|
154
|
+
data={image}
|
|
155
|
+
sizes="64px"
|
|
156
|
+
className="h-full w-full object-cover object-center"
|
|
157
|
+
/>
|
|
158
|
+
</button>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {ProductCard, type ProductCardProps} from './ProductCard';
|
|
2
|
+
|
|
3
|
+
export interface ProductGridProps {
|
|
4
|
+
products: ProductCardProps['product'][];
|
|
5
|
+
columns?: 2 | 3 | 4;
|
|
6
|
+
loading?: 'eager' | 'lazy';
|
|
7
|
+
showVendor?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ProductGrid({
|
|
11
|
+
products,
|
|
12
|
+
columns = 4,
|
|
13
|
+
loading = 'lazy',
|
|
14
|
+
showVendor = false,
|
|
15
|
+
}: ProductGridProps) {
|
|
16
|
+
const gridCols = {
|
|
17
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
18
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
19
|
+
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (!products || products.length === 0) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex min-h-[200px] items-center justify-center rounded-lg border border-dashed border-secondary-300 bg-secondary-50">
|
|
25
|
+
<p className="text-secondary-500">No products found</p>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={`grid gap-4 sm:gap-6 ${gridCols[columns]}`}>
|
|
32
|
+
{products.map((product, index) => (
|
|
33
|
+
<ProductCard
|
|
34
|
+
key={product.id}
|
|
35
|
+
product={product}
|
|
36
|
+
loading={index < 4 ? 'eager' : loading}
|
|
37
|
+
showVendor={showVendor}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ProductGridSkeleton({columns = 4}: {columns?: 2 | 3 | 4}) {
|
|
45
|
+
const gridCols = {
|
|
46
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
47
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
48
|
+
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={`grid gap-4 sm:gap-6 ${gridCols[columns]}`}>
|
|
53
|
+
{Array.from({length: columns * 2}).map((_, index) => (
|
|
54
|
+
<div key={index} className="animate-pulse">
|
|
55
|
+
<div className="aspect-square rounded-lg bg-secondary-200" />
|
|
56
|
+
<div className="mt-3 space-y-2">
|
|
57
|
+
<div className="h-4 w-3/4 rounded bg-secondary-200" />
|
|
58
|
+
<div className="h-4 w-1/2 rounded bg-secondary-200" />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {ProductVariantFragment} from 'storefrontapi.generated';
|
|
2
|
+
import {Image} from '@shopify/hydrogen';
|
|
3
|
+
|
|
4
|
+
export function ProductImage({
|
|
5
|
+
image,
|
|
6
|
+
}: {
|
|
7
|
+
image: ProductVariantFragment['image'];
|
|
8
|
+
}) {
|
|
9
|
+
if (!image) {
|
|
10
|
+
return <div className="product-image" />;
|
|
11
|
+
}
|
|
12
|
+
return (
|
|
13
|
+
<div className="product-image">
|
|
14
|
+
<Image
|
|
15
|
+
alt={image.altText || 'Product Image'}
|
|
16
|
+
aspectRatio="1/1"
|
|
17
|
+
data={image}
|
|
18
|
+
key={image.id}
|
|
19
|
+
sizes="(min-width: 45em) 50vw, 100vw"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {Link} from 'react-router';
|
|
2
|
+
import {Image, Money} from '@shopify/hydrogen';
|
|
3
|
+
import type {
|
|
4
|
+
ProductItemFragment,
|
|
5
|
+
CollectionItemFragment,
|
|
6
|
+
RecommendedProductFragment,
|
|
7
|
+
} from 'storefrontapi.generated';
|
|
8
|
+
import {useVariantUrl} from '~/lib/variants';
|
|
9
|
+
|
|
10
|
+
export function ProductItem({
|
|
11
|
+
product,
|
|
12
|
+
loading,
|
|
13
|
+
}: {
|
|
14
|
+
product:
|
|
15
|
+
| CollectionItemFragment
|
|
16
|
+
| ProductItemFragment
|
|
17
|
+
| RecommendedProductFragment;
|
|
18
|
+
loading?: 'eager' | 'lazy';
|
|
19
|
+
}) {
|
|
20
|
+
const variantUrl = useVariantUrl(product.handle);
|
|
21
|
+
const image = product.featuredImage;
|
|
22
|
+
return (
|
|
23
|
+
<Link
|
|
24
|
+
className="product-item"
|
|
25
|
+
key={product.id}
|
|
26
|
+
prefetch="intent"
|
|
27
|
+
to={variantUrl}
|
|
28
|
+
>
|
|
29
|
+
{image && (
|
|
30
|
+
<Image
|
|
31
|
+
alt={image.altText || product.title}
|
|
32
|
+
aspectRatio="1/1"
|
|
33
|
+
data={image}
|
|
34
|
+
loading={loading}
|
|
35
|
+
sizes="(min-width: 45em) 400px, 100vw"
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
38
|
+
<h4>{product.title}</h4>
|
|
39
|
+
<small>
|
|
40
|
+
<Money data={product.priceRange.minVariantPrice} />
|
|
41
|
+
</small>
|
|
42
|
+
</Link>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {Money} from '@shopify/hydrogen';
|
|
2
|
+
import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
|
|
3
|
+
|
|
4
|
+
export interface ProductPriceProps {
|
|
5
|
+
price?: MoneyV2;
|
|
6
|
+
compareAtPrice?: MoneyV2 | null;
|
|
7
|
+
size?: 'sm' | 'md' | 'lg';
|
|
8
|
+
layout?: 'inline' | 'stacked';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProductPrice({
|
|
12
|
+
price,
|
|
13
|
+
compareAtPrice,
|
|
14
|
+
size = 'md',
|
|
15
|
+
layout = 'inline',
|
|
16
|
+
}: ProductPriceProps) {
|
|
17
|
+
const isOnSale =
|
|
18
|
+
compareAtPrice?.amount &&
|
|
19
|
+
price?.amount &&
|
|
20
|
+
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
|
21
|
+
|
|
22
|
+
const sizeClasses = {
|
|
23
|
+
sm: 'text-sm',
|
|
24
|
+
md: 'text-lg',
|
|
25
|
+
lg: 'text-2xl',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const containerClasses =
|
|
29
|
+
layout === 'inline' ? 'flex items-center gap-2' : 'flex flex-col gap-1';
|
|
30
|
+
|
|
31
|
+
if (!price) {
|
|
32
|
+
return (
|
|
33
|
+
<span className={`${sizeClasses[size]} text-secondary-500`}> </span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={containerClasses}>
|
|
39
|
+
{isOnSale ? (
|
|
40
|
+
<>
|
|
41
|
+
<span className={`${sizeClasses[size]} font-semibold text-red-600`}>
|
|
42
|
+
<Money data={price} />
|
|
43
|
+
</span>
|
|
44
|
+
{compareAtPrice && (
|
|
45
|
+
<span
|
|
46
|
+
className={`${size === 'lg' ? 'text-lg' : 'text-sm'} text-secondary-500 line-through`}
|
|
47
|
+
>
|
|
48
|
+
<Money data={compareAtPrice} />
|
|
49
|
+
</span>
|
|
50
|
+
)}
|
|
51
|
+
<SaleBadge
|
|
52
|
+
price={price}
|
|
53
|
+
compareAtPrice={compareAtPrice}
|
|
54
|
+
size={size}
|
|
55
|
+
/>
|
|
56
|
+
</>
|
|
57
|
+
) : (
|
|
58
|
+
<span
|
|
59
|
+
className={`${sizeClasses[size]} font-semibold text-secondary-900`}
|
|
60
|
+
>
|
|
61
|
+
<Money data={price} />
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function SaleBadge({
|
|
69
|
+
price,
|
|
70
|
+
compareAtPrice,
|
|
71
|
+
size,
|
|
72
|
+
}: {
|
|
73
|
+
price: MoneyV2;
|
|
74
|
+
compareAtPrice: MoneyV2;
|
|
75
|
+
size: 'sm' | 'md' | 'lg';
|
|
76
|
+
}) {
|
|
77
|
+
const savings = parseFloat(compareAtPrice.amount) - parseFloat(price.amount);
|
|
78
|
+
const percentOff = Math.round(
|
|
79
|
+
(savings / parseFloat(compareAtPrice.amount)) * 100,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (percentOff <= 0) return null;
|
|
83
|
+
|
|
84
|
+
const badgeSizeClasses = {
|
|
85
|
+
sm: 'text-xs px-1.5 py-0.5',
|
|
86
|
+
md: 'text-xs px-2 py-0.5',
|
|
87
|
+
lg: 'text-sm px-2 py-1',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<span
|
|
92
|
+
className={`${badgeSizeClasses[size]} rounded bg-red-100 font-medium text-red-700`}
|
|
93
|
+
>
|
|
94
|
+
Save {percentOff}%
|
|
95
|
+
</span>
|
|
96
|
+
);
|
|
97
|
+
}
|