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,330 @@
|
|
|
1
|
+
import {useSearchParams} from 'react-router';
|
|
2
|
+
import {useState} from 'react';
|
|
3
|
+
|
|
4
|
+
export type SortOption = {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type FilterOption = {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string;
|
|
12
|
+
count?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type FilterGroup = {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
options: FilterOption[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type CollectionFiltersProps = {
|
|
22
|
+
filters?: FilterGroup[];
|
|
23
|
+
sortOptions?: SortOption[];
|
|
24
|
+
productCount?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const DEFAULT_SORT_OPTIONS: SortOption[] = [
|
|
28
|
+
{label: 'Featured', value: 'featured'},
|
|
29
|
+
{label: 'Best Selling', value: 'best-selling'},
|
|
30
|
+
{label: 'Alphabetically, A-Z', value: 'title-asc'},
|
|
31
|
+
{label: 'Alphabetically, Z-A', value: 'title-desc'},
|
|
32
|
+
{label: 'Price, Low to High', value: 'price-asc'},
|
|
33
|
+
{label: 'Price, High to Low', value: 'price-desc'},
|
|
34
|
+
{label: 'Date, Old to New', value: 'created-asc'},
|
|
35
|
+
{label: 'Date, New to Old', value: 'created-desc'},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collection filters and sorting controls.
|
|
40
|
+
*/
|
|
41
|
+
export function CollectionFilters({
|
|
42
|
+
filters = [],
|
|
43
|
+
sortOptions = DEFAULT_SORT_OPTIONS,
|
|
44
|
+
productCount,
|
|
45
|
+
}: CollectionFiltersProps) {
|
|
46
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
47
|
+
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
|
48
|
+
|
|
49
|
+
const currentSort = searchParams.get('sort') || 'featured';
|
|
50
|
+
const activeFilters = getActiveFilters(searchParams, filters);
|
|
51
|
+
|
|
52
|
+
function handleSortChange(value: string) {
|
|
53
|
+
const newParams = new URLSearchParams(searchParams);
|
|
54
|
+
if (value === 'featured') {
|
|
55
|
+
newParams.delete('sort');
|
|
56
|
+
} else {
|
|
57
|
+
newParams.set('sort', value);
|
|
58
|
+
}
|
|
59
|
+
setSearchParams(newParams);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleFilterChange(groupId: string, value: string) {
|
|
63
|
+
const newParams = new URLSearchParams(searchParams);
|
|
64
|
+
const currentValues = newParams.getAll(groupId);
|
|
65
|
+
|
|
66
|
+
if (currentValues.includes(value)) {
|
|
67
|
+
// Remove the filter
|
|
68
|
+
newParams.delete(groupId);
|
|
69
|
+
currentValues
|
|
70
|
+
.filter((v) => v !== value)
|
|
71
|
+
.forEach((v) => newParams.append(groupId, v));
|
|
72
|
+
} else {
|
|
73
|
+
// Add the filter
|
|
74
|
+
newParams.append(groupId, value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setSearchParams(newParams);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clearAllFilters() {
|
|
81
|
+
const newParams = new URLSearchParams();
|
|
82
|
+
const sort = searchParams.get('sort');
|
|
83
|
+
if (sort) {
|
|
84
|
+
newParams.set('sort', sort);
|
|
85
|
+
}
|
|
86
|
+
setSearchParams(newParams);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="mb-8">
|
|
91
|
+
{/* Top bar with product count and sort */}
|
|
92
|
+
<div className="flex items-center justify-between border-b border-secondary-200 pb-4">
|
|
93
|
+
<div className="flex items-center gap-4">
|
|
94
|
+
{/* Mobile filter toggle */}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
|
|
98
|
+
className="flex items-center gap-2 rounded-md border border-secondary-300 px-3 py-2 text-sm font-medium text-secondary-700 transition-colors hover:bg-secondary-50 lg:hidden"
|
|
99
|
+
>
|
|
100
|
+
<svg
|
|
101
|
+
className="h-5 w-5"
|
|
102
|
+
fill="none"
|
|
103
|
+
stroke="currentColor"
|
|
104
|
+
viewBox="0 0 24 24"
|
|
105
|
+
>
|
|
106
|
+
<path
|
|
107
|
+
strokeLinecap="round"
|
|
108
|
+
strokeLinejoin="round"
|
|
109
|
+
strokeWidth={2}
|
|
110
|
+
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
|
111
|
+
/>
|
|
112
|
+
</svg>
|
|
113
|
+
Filters
|
|
114
|
+
{activeFilters.length > 0 && (
|
|
115
|
+
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary-600 text-xs text-white">
|
|
116
|
+
{activeFilters.length}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
</button>
|
|
120
|
+
|
|
121
|
+
{/* Product count */}
|
|
122
|
+
{productCount !== undefined && (
|
|
123
|
+
<span className="text-sm text-secondary-500">
|
|
124
|
+
{productCount} {productCount === 1 ? 'product' : 'products'}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Sort dropdown */}
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
<label htmlFor="sort" className="text-sm text-secondary-600">
|
|
132
|
+
Sort by:
|
|
133
|
+
</label>
|
|
134
|
+
<select
|
|
135
|
+
id="sort"
|
|
136
|
+
value={currentSort}
|
|
137
|
+
onChange={(e) => handleSortChange(e.target.value)}
|
|
138
|
+
className="rounded-md border border-secondary-300 bg-white py-2 pl-3 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
139
|
+
>
|
|
140
|
+
{sortOptions.map((option) => (
|
|
141
|
+
<option key={option.value} value={option.value}>
|
|
142
|
+
{option.label}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Active filters */}
|
|
150
|
+
{activeFilters.length > 0 && (
|
|
151
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
152
|
+
<span className="text-sm text-secondary-500">Active filters:</span>
|
|
153
|
+
{activeFilters.map((filter) => (
|
|
154
|
+
<button
|
|
155
|
+
key={`${filter.groupId}-${filter.value}`}
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => handleFilterChange(filter.groupId, filter.value)}
|
|
158
|
+
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-3 py-1 text-sm text-primary-700 transition-colors hover:bg-primary-100"
|
|
159
|
+
>
|
|
160
|
+
{filter.label}
|
|
161
|
+
<svg
|
|
162
|
+
className="h-4 w-4"
|
|
163
|
+
fill="none"
|
|
164
|
+
stroke="currentColor"
|
|
165
|
+
viewBox="0 0 24 24"
|
|
166
|
+
>
|
|
167
|
+
<path
|
|
168
|
+
strokeLinecap="round"
|
|
169
|
+
strokeLinejoin="round"
|
|
170
|
+
strokeWidth={2}
|
|
171
|
+
d="M6 18L18 6M6 6l12 12"
|
|
172
|
+
/>
|
|
173
|
+
</svg>
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={clearAllFilters}
|
|
179
|
+
className="text-sm text-secondary-500 underline-offset-2 hover:text-secondary-700 hover:underline"
|
|
180
|
+
>
|
|
181
|
+
Clear all
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Filter groups (mobile: collapsible, desktop: horizontal) */}
|
|
187
|
+
{filters.length > 0 && (
|
|
188
|
+
<div
|
|
189
|
+
className={`mt-4 space-y-4 lg:flex lg:gap-6 lg:space-y-0 ${
|
|
190
|
+
isFiltersOpen ? 'block' : 'hidden lg:flex'
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
{filters.map((group) => (
|
|
194
|
+
<FilterGroupComponent
|
|
195
|
+
key={group.id}
|
|
196
|
+
group={group}
|
|
197
|
+
searchParams={searchParams}
|
|
198
|
+
onFilterChange={handleFilterChange}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type FilterGroupComponentProps = {
|
|
208
|
+
group: FilterGroup;
|
|
209
|
+
searchParams: URLSearchParams;
|
|
210
|
+
onFilterChange: (groupId: string, value: string) => void;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function FilterGroupComponent({
|
|
214
|
+
group,
|
|
215
|
+
searchParams,
|
|
216
|
+
onFilterChange,
|
|
217
|
+
}: FilterGroupComponentProps) {
|
|
218
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
219
|
+
const selectedValues = searchParams.getAll(group.id);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className="relative">
|
|
223
|
+
{/* Desktop: Dropdown button */}
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
227
|
+
className="hidden items-center gap-1 rounded-md border border-secondary-300 px-3 py-2 text-sm font-medium text-secondary-700 transition-colors hover:bg-secondary-50 lg:flex"
|
|
228
|
+
>
|
|
229
|
+
{group.label}
|
|
230
|
+
{selectedValues.length > 0 && (
|
|
231
|
+
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary-600 text-xs text-white">
|
|
232
|
+
{selectedValues.length}
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
<svg
|
|
236
|
+
className={`ml-1 h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
237
|
+
fill="none"
|
|
238
|
+
stroke="currentColor"
|
|
239
|
+
viewBox="0 0 24 24"
|
|
240
|
+
>
|
|
241
|
+
<path
|
|
242
|
+
strokeLinecap="round"
|
|
243
|
+
strokeLinejoin="round"
|
|
244
|
+
strokeWidth={2}
|
|
245
|
+
d="M19 9l-7 7-7-7"
|
|
246
|
+
/>
|
|
247
|
+
</svg>
|
|
248
|
+
</button>
|
|
249
|
+
|
|
250
|
+
{/* Mobile: Always visible as section */}
|
|
251
|
+
<div className="lg:hidden">
|
|
252
|
+
<h4 className="mb-2 text-sm font-medium text-secondary-900">
|
|
253
|
+
{group.label}
|
|
254
|
+
</h4>
|
|
255
|
+
<div className="space-y-2">
|
|
256
|
+
{group.options.map((option) => (
|
|
257
|
+
<FilterCheckbox
|
|
258
|
+
key={option.value}
|
|
259
|
+
option={option}
|
|
260
|
+
checked={selectedValues.includes(option.value)}
|
|
261
|
+
onChange={() => onFilterChange(group.id, option.value)}
|
|
262
|
+
/>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Desktop: Dropdown panel */}
|
|
268
|
+
{isOpen && (
|
|
269
|
+
<div className="absolute left-0 top-full z-10 mt-2 hidden w-56 rounded-lg border border-secondary-200 bg-white p-4 shadow-lg lg:block">
|
|
270
|
+
<div className="space-y-2">
|
|
271
|
+
{group.options.map((option) => (
|
|
272
|
+
<FilterCheckbox
|
|
273
|
+
key={option.value}
|
|
274
|
+
option={option}
|
|
275
|
+
checked={selectedValues.includes(option.value)}
|
|
276
|
+
onChange={() => onFilterChange(group.id, option.value)}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
type FilterCheckboxProps = {
|
|
287
|
+
option: FilterOption;
|
|
288
|
+
checked: boolean;
|
|
289
|
+
onChange: () => void;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function FilterCheckbox({option, checked, onChange}: FilterCheckboxProps) {
|
|
293
|
+
return (
|
|
294
|
+
<label className="flex cursor-pointer items-center gap-2">
|
|
295
|
+
<input
|
|
296
|
+
type="checkbox"
|
|
297
|
+
checked={checked}
|
|
298
|
+
onChange={onChange}
|
|
299
|
+
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
300
|
+
/>
|
|
301
|
+
<span className="text-sm text-secondary-700">{option.label}</span>
|
|
302
|
+
{option.count !== undefined && (
|
|
303
|
+
<span className="text-sm text-secondary-400">({option.count})</span>
|
|
304
|
+
)}
|
|
305
|
+
</label>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getActiveFilters(
|
|
310
|
+
searchParams: URLSearchParams,
|
|
311
|
+
filters: FilterGroup[],
|
|
312
|
+
) {
|
|
313
|
+
const active: Array<{groupId: string; value: string; label: string}> = [];
|
|
314
|
+
|
|
315
|
+
filters.forEach((group) => {
|
|
316
|
+
const values = searchParams.getAll(group.id);
|
|
317
|
+
values.forEach((value) => {
|
|
318
|
+
const option = group.options.find((o) => o.value === value);
|
|
319
|
+
if (option) {
|
|
320
|
+
active.push({
|
|
321
|
+
groupId: group.id,
|
|
322
|
+
value,
|
|
323
|
+
label: `${group.label}: ${option.label}`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return active;
|
|
330
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {Link} from 'react-router';
|
|
2
|
+
import {Image} from '@shopify/hydrogen';
|
|
3
|
+
import type {Collection} from '@shopify/hydrogen/storefront-api-types';
|
|
4
|
+
|
|
5
|
+
type CollectionGridProps = {
|
|
6
|
+
collections: Pick<
|
|
7
|
+
Collection,
|
|
8
|
+
'id' | 'title' | 'handle' | 'description' | 'image'
|
|
9
|
+
>[];
|
|
10
|
+
columns?: 2 | 3 | 4;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A responsive grid of collection cards with images and titles.
|
|
15
|
+
*/
|
|
16
|
+
export function CollectionGrid({
|
|
17
|
+
collections,
|
|
18
|
+
columns = 3,
|
|
19
|
+
}: CollectionGridProps) {
|
|
20
|
+
const gridCols = {
|
|
21
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
22
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
23
|
+
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={`grid gap-6 ${gridCols[columns]}`}>
|
|
28
|
+
{collections.map((collection) => (
|
|
29
|
+
<CollectionCard key={collection.id} collection={collection} />
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type CollectionCardProps = {
|
|
36
|
+
collection: Pick<
|
|
37
|
+
Collection,
|
|
38
|
+
'id' | 'title' | 'handle' | 'description' | 'image'
|
|
39
|
+
>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A single collection card with image, title, and optional description.
|
|
44
|
+
*/
|
|
45
|
+
export function CollectionCard({collection}: CollectionCardProps) {
|
|
46
|
+
return (
|
|
47
|
+
<Link
|
|
48
|
+
to={`/collections/${collection.handle}`}
|
|
49
|
+
prefetch="intent"
|
|
50
|
+
className="group block"
|
|
51
|
+
>
|
|
52
|
+
<div className="relative aspect-square overflow-hidden rounded-xl bg-secondary-100">
|
|
53
|
+
{collection.image ? (
|
|
54
|
+
<Image
|
|
55
|
+
data={collection.image}
|
|
56
|
+
aspectRatio="1/1"
|
|
57
|
+
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
|
58
|
+
className="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
|
59
|
+
/>
|
|
60
|
+
) : (
|
|
61
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
62
|
+
<svg
|
|
63
|
+
className="h-16 w-16 text-secondary-300"
|
|
64
|
+
fill="none"
|
|
65
|
+
stroke="currentColor"
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
>
|
|
68
|
+
<path
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
strokeLinejoin="round"
|
|
71
|
+
strokeWidth={1}
|
|
72
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
73
|
+
/>
|
|
74
|
+
</svg>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{/* Overlay gradient */}
|
|
79
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-transparent" />
|
|
80
|
+
|
|
81
|
+
{/* Title overlay */}
|
|
82
|
+
<div className="absolute inset-x-0 bottom-0 p-4">
|
|
83
|
+
<h3 className="text-lg font-semibold text-white drop-shadow-md">
|
|
84
|
+
{collection.title}
|
|
85
|
+
</h3>
|
|
86
|
+
{collection.description && (
|
|
87
|
+
<p className="mt-1 line-clamp-2 text-sm text-white/80">
|
|
88
|
+
{collection.description}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</Link>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Skeleton loader for collection grid.
|
|
99
|
+
*/
|
|
100
|
+
export function CollectionGridSkeleton({count = 6}: {count?: number}) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
103
|
+
{Array.from({length: count}).map((_, i) => (
|
|
104
|
+
<div key={i} className="animate-pulse">
|
|
105
|
+
<div className="aspect-square rounded-xl bg-secondary-200" />
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Empty state for when there are no collections.
|
|
114
|
+
*/
|
|
115
|
+
export function CollectionGridEmpty() {
|
|
116
|
+
return (
|
|
117
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
118
|
+
<div className="mb-4 rounded-full bg-secondary-100 p-4">
|
|
119
|
+
<svg
|
|
120
|
+
className="h-8 w-8 text-secondary-400"
|
|
121
|
+
fill="none"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
>
|
|
125
|
+
<path
|
|
126
|
+
strokeLinecap="round"
|
|
127
|
+
strokeLinejoin="round"
|
|
128
|
+
strokeWidth={1.5}
|
|
129
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
130
|
+
/>
|
|
131
|
+
</svg>
|
|
132
|
+
</div>
|
|
133
|
+
<h3 className="text-lg font-medium text-secondary-900">
|
|
134
|
+
No collections found
|
|
135
|
+
</h3>
|
|
136
|
+
<p className="mt-1 text-sm text-secondary-500">
|
|
137
|
+
Check back later for new collections.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import {Suspense} from 'react';
|
|
2
|
+
import {Await, NavLink} from 'react-router';
|
|
3
|
+
import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
|
|
4
|
+
|
|
5
|
+
export interface FooterProps {
|
|
6
|
+
footer: Promise<FooterQuery | null>;
|
|
7
|
+
header: HeaderQuery;
|
|
8
|
+
publicStoreDomain: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Footer({
|
|
12
|
+
footer: footerPromise,
|
|
13
|
+
header,
|
|
14
|
+
publicStoreDomain,
|
|
15
|
+
}: FooterProps) {
|
|
16
|
+
return (
|
|
17
|
+
<Suspense>
|
|
18
|
+
<Await resolve={footerPromise}>
|
|
19
|
+
{(footer) => (
|
|
20
|
+
<footer className="border-t border-secondary-200 bg-secondary-50">
|
|
21
|
+
<div className="container-narrow py-12">
|
|
22
|
+
<div className="grid gap-8 md:grid-cols-4">
|
|
23
|
+
{/* Brand Section */}
|
|
24
|
+
<div className="md:col-span-2">
|
|
25
|
+
<h3 className="text-lg font-bold text-secondary-900">
|
|
26
|
+
{header.shop.name}
|
|
27
|
+
</h3>
|
|
28
|
+
<p className="mt-2 text-sm text-secondary-600">
|
|
29
|
+
Quality products, exceptional service.
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
{/* Navigation Section */}
|
|
34
|
+
<div>
|
|
35
|
+
<h4 className="text-sm font-semibold uppercase tracking-wider text-secondary-900">
|
|
36
|
+
Quick Links
|
|
37
|
+
</h4>
|
|
38
|
+
{footer?.menu && header.shop.primaryDomain?.url && (
|
|
39
|
+
<FooterMenu
|
|
40
|
+
menu={footer.menu}
|
|
41
|
+
primaryDomainUrl={header.shop.primaryDomain.url}
|
|
42
|
+
publicStoreDomain={publicStoreDomain}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{/* Connect Section */}
|
|
48
|
+
<div>
|
|
49
|
+
<h4 className="text-sm font-semibold uppercase tracking-wider text-secondary-900">
|
|
50
|
+
Connect
|
|
51
|
+
</h4>
|
|
52
|
+
<div className="mt-4 flex space-x-4">
|
|
53
|
+
<SocialLink href="#" label="Twitter">
|
|
54
|
+
<svg
|
|
55
|
+
className="h-5 w-5"
|
|
56
|
+
fill="currentColor"
|
|
57
|
+
viewBox="0 0 24 24"
|
|
58
|
+
>
|
|
59
|
+
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
|
|
60
|
+
</svg>
|
|
61
|
+
</SocialLink>
|
|
62
|
+
<SocialLink href="#" label="Instagram">
|
|
63
|
+
<svg
|
|
64
|
+
className="h-5 w-5"
|
|
65
|
+
fill="currentColor"
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
>
|
|
68
|
+
<path
|
|
69
|
+
fillRule="evenodd"
|
|
70
|
+
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
|
|
71
|
+
clipRule="evenodd"
|
|
72
|
+
/>
|
|
73
|
+
</svg>
|
|
74
|
+
</SocialLink>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Copyright */}
|
|
80
|
+
<div className="mt-12 border-t border-secondary-200 pt-8">
|
|
81
|
+
<p className="text-center text-sm text-secondary-500">
|
|
82
|
+
© {new Date().getFullYear()} {header.shop.name}. All rights
|
|
83
|
+
reserved.
|
|
84
|
+
</p>
|
|
85
|
+
<p className="mt-2 text-center text-xs text-secondary-400">
|
|
86
|
+
Powered by{' '}
|
|
87
|
+
<a
|
|
88
|
+
href="https://github.com/nathanmcmullendev/hydrogen-forge"
|
|
89
|
+
target="_blank"
|
|
90
|
+
rel="noopener noreferrer"
|
|
91
|
+
className="text-primary-600 hover:text-primary-700"
|
|
92
|
+
>
|
|
93
|
+
Hydrogen Forge
|
|
94
|
+
</a>
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</footer>
|
|
99
|
+
)}
|
|
100
|
+
</Await>
|
|
101
|
+
</Suspense>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function FooterMenu({
|
|
106
|
+
menu,
|
|
107
|
+
primaryDomainUrl,
|
|
108
|
+
publicStoreDomain,
|
|
109
|
+
}: {
|
|
110
|
+
menu: FooterQuery['menu'];
|
|
111
|
+
primaryDomainUrl: FooterProps['header']['shop']['primaryDomain']['url'];
|
|
112
|
+
publicStoreDomain: string;
|
|
113
|
+
}) {
|
|
114
|
+
return (
|
|
115
|
+
<nav className="mt-4 flex flex-col space-y-2" role="navigation">
|
|
116
|
+
{(menu || FALLBACK_FOOTER_MENU).items.map((item) => {
|
|
117
|
+
if (!item.url) return null;
|
|
118
|
+
// if the url is internal, we strip the domain
|
|
119
|
+
const url =
|
|
120
|
+
item.url.includes('myshopify.com') ||
|
|
121
|
+
item.url.includes(publicStoreDomain) ||
|
|
122
|
+
item.url.includes(primaryDomainUrl)
|
|
123
|
+
? new URL(item.url).pathname
|
|
124
|
+
: item.url;
|
|
125
|
+
const isExternal = !url.startsWith('/');
|
|
126
|
+
return isExternal ? (
|
|
127
|
+
<a
|
|
128
|
+
href={url}
|
|
129
|
+
key={item.id}
|
|
130
|
+
rel="noopener noreferrer"
|
|
131
|
+
target="_blank"
|
|
132
|
+
className="text-sm text-secondary-600 transition-colors hover:text-secondary-900"
|
|
133
|
+
>
|
|
134
|
+
{item.title}
|
|
135
|
+
</a>
|
|
136
|
+
) : (
|
|
137
|
+
<NavLink
|
|
138
|
+
end
|
|
139
|
+
key={item.id}
|
|
140
|
+
prefetch="intent"
|
|
141
|
+
to={url}
|
|
142
|
+
className={({isActive}) =>
|
|
143
|
+
isActive
|
|
144
|
+
? 'text-sm font-medium text-secondary-900'
|
|
145
|
+
: 'text-sm text-secondary-600 transition-colors hover:text-secondary-900'
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
{item.title}
|
|
149
|
+
</NavLink>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
</nav>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function SocialLink({
|
|
157
|
+
href,
|
|
158
|
+
label,
|
|
159
|
+
children,
|
|
160
|
+
}: {
|
|
161
|
+
href: string;
|
|
162
|
+
label: string;
|
|
163
|
+
children: React.ReactNode;
|
|
164
|
+
}) {
|
|
165
|
+
return (
|
|
166
|
+
<a
|
|
167
|
+
href={href}
|
|
168
|
+
target="_blank"
|
|
169
|
+
rel="noopener noreferrer"
|
|
170
|
+
aria-label={label}
|
|
171
|
+
className="text-secondary-500 transition-colors hover:text-secondary-700"
|
|
172
|
+
>
|
|
173
|
+
{children}
|
|
174
|
+
</a>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const FALLBACK_FOOTER_MENU = {
|
|
179
|
+
id: 'gid://shopify/Menu/199655620664',
|
|
180
|
+
items: [
|
|
181
|
+
{
|
|
182
|
+
id: 'gid://shopify/MenuItem/461633060920',
|
|
183
|
+
resourceId: 'gid://shopify/ShopPolicy/23358046264',
|
|
184
|
+
tags: [],
|
|
185
|
+
title: 'Privacy Policy',
|
|
186
|
+
type: 'SHOP_POLICY',
|
|
187
|
+
url: '/policies/privacy-policy',
|
|
188
|
+
items: [],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'gid://shopify/MenuItem/461633093688',
|
|
192
|
+
resourceId: 'gid://shopify/ShopPolicy/23358013496',
|
|
193
|
+
tags: [],
|
|
194
|
+
title: 'Refund Policy',
|
|
195
|
+
type: 'SHOP_POLICY',
|
|
196
|
+
url: '/policies/refund-policy',
|
|
197
|
+
items: [],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'gid://shopify/MenuItem/461633126456',
|
|
201
|
+
resourceId: 'gid://shopify/ShopPolicy/23358111800',
|
|
202
|
+
tags: [],
|
|
203
|
+
title: 'Shipping Policy',
|
|
204
|
+
type: 'SHOP_POLICY',
|
|
205
|
+
url: '/policies/shipping-policy',
|
|
206
|
+
items: [],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'gid://shopify/MenuItem/461633159224',
|
|
210
|
+
resourceId: 'gid://shopify/ShopPolicy/23358079032',
|
|
211
|
+
tags: [],
|
|
212
|
+
title: 'Terms of Service',
|
|
213
|
+
type: 'SHOP_POLICY',
|
|
214
|
+
url: '/policies/terms-of-service',
|
|
215
|
+
items: [],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|