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.
Files changed (118) hide show
  1. package/README.md +212 -0
  2. package/dist/commands/add.d.ts +7 -0
  3. package/dist/commands/add.d.ts.map +1 -0
  4. package/dist/commands/add.js +123 -0
  5. package/dist/commands/add.js.map +1 -0
  6. package/dist/commands/create.d.ts +8 -0
  7. package/dist/commands/create.d.ts.map +1 -0
  8. package/dist/commands/create.js +160 -0
  9. package/dist/commands/create.js.map +1 -0
  10. package/dist/commands/setup-mcp.d.ts +7 -0
  11. package/dist/commands/setup-mcp.d.ts.map +1 -0
  12. package/dist/commands/setup-mcp.js +179 -0
  13. package/dist/commands/setup-mcp.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +50 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/generators.d.ts +6 -0
  19. package/dist/lib/generators.d.ts.map +1 -0
  20. package/dist/lib/generators.js +470 -0
  21. package/dist/lib/generators.js.map +1 -0
  22. package/dist/lib/utils.d.ts +17 -0
  23. package/dist/lib/utils.d.ts.map +1 -0
  24. package/dist/lib/utils.js +101 -0
  25. package/dist/lib/utils.js.map +1 -0
  26. package/package.json +54 -0
  27. package/templates/starter/.env.example +21 -0
  28. package/templates/starter/.graphqlrc.ts +27 -0
  29. package/templates/starter/README.md +117 -0
  30. package/templates/starter/app/assets/favicon.svg +28 -0
  31. package/templates/starter/app/components/AddToCartButton.tsx +102 -0
  32. package/templates/starter/app/components/Aside.tsx +136 -0
  33. package/templates/starter/app/components/CartLineItem.tsx +229 -0
  34. package/templates/starter/app/components/CartMain.tsx +131 -0
  35. package/templates/starter/app/components/CartSummary.tsx +315 -0
  36. package/templates/starter/app/components/CollectionFilters.tsx +330 -0
  37. package/templates/starter/app/components/CollectionGrid.tsx +141 -0
  38. package/templates/starter/app/components/Footer.tsx +218 -0
  39. package/templates/starter/app/components/Header.tsx +296 -0
  40. package/templates/starter/app/components/PageLayout.tsx +174 -0
  41. package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
  42. package/templates/starter/app/components/ProductCard.tsx +151 -0
  43. package/templates/starter/app/components/ProductForm.tsx +156 -0
  44. package/templates/starter/app/components/ProductGallery.tsx +164 -0
  45. package/templates/starter/app/components/ProductGrid.tsx +64 -0
  46. package/templates/starter/app/components/ProductImage.tsx +23 -0
  47. package/templates/starter/app/components/ProductItem.tsx +44 -0
  48. package/templates/starter/app/components/ProductPrice.tsx +97 -0
  49. package/templates/starter/app/components/SearchDialog.tsx +599 -0
  50. package/templates/starter/app/components/SearchForm.tsx +68 -0
  51. package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
  52. package/templates/starter/app/components/SearchResults.tsx +161 -0
  53. package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
  54. package/templates/starter/app/entry.client.tsx +21 -0
  55. package/templates/starter/app/entry.server.tsx +53 -0
  56. package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
  57. package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
  58. package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
  59. package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
  60. package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
  61. package/templates/starter/app/lib/context.ts +60 -0
  62. package/templates/starter/app/lib/fragments.ts +234 -0
  63. package/templates/starter/app/lib/orderFilters.ts +90 -0
  64. package/templates/starter/app/lib/redirect.ts +23 -0
  65. package/templates/starter/app/lib/search.ts +79 -0
  66. package/templates/starter/app/lib/session.ts +72 -0
  67. package/templates/starter/app/lib/variants.ts +46 -0
  68. package/templates/starter/app/root.tsx +209 -0
  69. package/templates/starter/app/routes/$.tsx +11 -0
  70. package/templates/starter/app/routes/[robots.txt].tsx +117 -0
  71. package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
  72. package/templates/starter/app/routes/_index.tsx +167 -0
  73. package/templates/starter/app/routes/account.$.tsx +9 -0
  74. package/templates/starter/app/routes/account._index.tsx +5 -0
  75. package/templates/starter/app/routes/account.addresses.tsx +516 -0
  76. package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
  77. package/templates/starter/app/routes/account.orders._index.tsx +222 -0
  78. package/templates/starter/app/routes/account.profile.tsx +133 -0
  79. package/templates/starter/app/routes/account.tsx +97 -0
  80. package/templates/starter/app/routes/account_.authorize.tsx +5 -0
  81. package/templates/starter/app/routes/account_.login.tsx +7 -0
  82. package/templates/starter/app/routes/account_.logout.tsx +11 -0
  83. package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
  84. package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
  85. package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
  86. package/templates/starter/app/routes/blogs._index.tsx +109 -0
  87. package/templates/starter/app/routes/cart.$lines.tsx +70 -0
  88. package/templates/starter/app/routes/cart.tsx +117 -0
  89. package/templates/starter/app/routes/collections.$handle.tsx +161 -0
  90. package/templates/starter/app/routes/collections._index.tsx +133 -0
  91. package/templates/starter/app/routes/collections.all.tsx +122 -0
  92. package/templates/starter/app/routes/discount.$code.tsx +48 -0
  93. package/templates/starter/app/routes/pages.$handle.tsx +88 -0
  94. package/templates/starter/app/routes/policies.$handle.tsx +93 -0
  95. package/templates/starter/app/routes/policies._index.tsx +69 -0
  96. package/templates/starter/app/routes/products.$handle.tsx +232 -0
  97. package/templates/starter/app/routes/search.tsx +426 -0
  98. package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
  99. package/templates/starter/app/routes.ts +9 -0
  100. package/templates/starter/app/styles/app.css +574 -0
  101. package/templates/starter/app/styles/reset.css +139 -0
  102. package/templates/starter/app/styles/tailwind.css +116 -0
  103. package/templates/starter/customer-accountapi.generated.d.ts +543 -0
  104. package/templates/starter/env.d.ts +7 -0
  105. package/templates/starter/eslint.config.js +247 -0
  106. package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
  107. package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
  108. package/templates/starter/guides/search/search.jpg +0 -0
  109. package/templates/starter/guides/search/search.md +335 -0
  110. package/templates/starter/package.json +71 -0
  111. package/templates/starter/postcss.config.js +6 -0
  112. package/templates/starter/public/.gitkeep +0 -0
  113. package/templates/starter/react-router.config.ts +13 -0
  114. package/templates/starter/server.ts +59 -0
  115. package/templates/starter/storefrontapi.generated.d.ts +1264 -0
  116. package/templates/starter/tailwind.config.js +83 -0
  117. package/templates/starter/tsconfig.json +67 -0
  118. 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
+ };