@shopify/cli-hydrogen 3.27.0 → 4.0.0-alpha.1

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 (249) hide show
  1. package/dist/commands/hydrogen/build.js +89 -0
  2. package/dist/commands/hydrogen/dev.js +116 -0
  3. package/dist/commands/hydrogen/init.js +42 -0
  4. package/dist/commands/hydrogen/preview.js +34 -0
  5. package/dist/hooks/init.js +21 -0
  6. package/dist/templates/demo-store/.editorconfig +8 -0
  7. package/dist/templates/demo-store/.eslintignore +4 -0
  8. package/dist/templates/demo-store/.eslintrc.js +16 -0
  9. package/dist/templates/demo-store/.graphqlrc.yml +1 -0
  10. package/dist/templates/demo-store/.prettierignore +2 -0
  11. package/dist/templates/demo-store/.turbo/turbo-build.log +13 -0
  12. package/dist/templates/demo-store/app/components/AccountAddressBook.tsx +97 -0
  13. package/dist/templates/demo-store/app/components/AccountDetails.tsx +41 -0
  14. package/dist/templates/demo-store/app/components/AddToCartButton.tsx +42 -0
  15. package/dist/templates/demo-store/app/components/Breadcrumbs.tsx +36 -0
  16. package/dist/templates/demo-store/app/components/Button.tsx +56 -0
  17. package/dist/templates/demo-store/app/components/Cart.tsx +431 -0
  18. package/dist/templates/demo-store/app/components/CartLoading.tsx +50 -0
  19. package/dist/templates/demo-store/app/components/CountrySelector.tsx +180 -0
  20. package/dist/templates/demo-store/app/components/Drawer.tsx +115 -0
  21. package/dist/templates/demo-store/app/components/FeaturedCollections.tsx +54 -0
  22. package/dist/templates/demo-store/app/components/FeaturedProducts.tsx +116 -0
  23. package/dist/templates/demo-store/app/components/FeaturedSection.tsx +39 -0
  24. package/dist/templates/demo-store/app/components/GenericError.tsx +58 -0
  25. package/dist/templates/demo-store/app/components/Grid.tsx +44 -0
  26. package/dist/templates/demo-store/app/components/Hero.tsx +136 -0
  27. package/dist/templates/demo-store/app/components/Icon.tsx +253 -0
  28. package/dist/templates/demo-store/app/components/Input.tsx +24 -0
  29. package/dist/templates/demo-store/app/components/Layout.tsx +492 -0
  30. package/dist/templates/demo-store/app/components/Link.tsx +46 -0
  31. package/dist/templates/demo-store/app/components/Modal.tsx +46 -0
  32. package/dist/templates/demo-store/app/components/NotFound.tsx +22 -0
  33. package/dist/templates/demo-store/app/components/OrderCard.tsx +85 -0
  34. package/dist/templates/demo-store/app/components/Pagination.tsx +277 -0
  35. package/dist/templates/demo-store/app/components/ProductCard.tsx +146 -0
  36. package/dist/templates/demo-store/app/components/ProductGallery.tsx +114 -0
  37. package/dist/templates/demo-store/app/components/ProductGrid.tsx +93 -0
  38. package/dist/templates/demo-store/app/components/ProductSwimlane.tsx +30 -0
  39. package/dist/templates/demo-store/app/components/Skeleton.tsx +24 -0
  40. package/dist/templates/demo-store/app/components/SortFilter.tsx +411 -0
  41. package/dist/templates/demo-store/app/components/Text.tsx +192 -0
  42. package/dist/templates/demo-store/app/components/index.ts +28 -0
  43. package/dist/templates/demo-store/app/data/countries.ts +194 -0
  44. package/dist/templates/demo-store/app/data/index.ts +1037 -0
  45. package/dist/templates/demo-store/app/entry.client.tsx +4 -0
  46. package/dist/templates/demo-store/app/entry.server.tsx +26 -0
  47. package/dist/templates/demo-store/app/hooks/useCartFetchers.tsx +14 -0
  48. package/dist/templates/demo-store/app/hooks/useIsHydrated.tsx +12 -0
  49. package/dist/templates/demo-store/app/lib/const.ts +10 -0
  50. package/dist/templates/demo-store/app/lib/placeholders.ts +242 -0
  51. package/dist/templates/demo-store/app/lib/seo/common.tsx +324 -0
  52. package/dist/templates/demo-store/app/lib/seo/debugger.tsx +175 -0
  53. package/dist/templates/demo-store/app/lib/seo/image.tsx +32 -0
  54. package/dist/templates/demo-store/app/lib/seo/index.ts +4 -0
  55. package/dist/templates/demo-store/app/lib/seo/seo.tsx +24 -0
  56. package/dist/templates/demo-store/app/lib/seo/types.ts +70 -0
  57. package/dist/templates/demo-store/app/lib/session.server.ts +57 -0
  58. package/dist/templates/demo-store/app/lib/type.ts +21 -0
  59. package/dist/templates/demo-store/app/lib/utils.ts +310 -0
  60. package/dist/templates/demo-store/app/root.tsx +282 -0
  61. package/dist/templates/demo-store/app/routes/$.tsx +7 -0
  62. package/dist/templates/demo-store/app/routes/$lang/$.tsx +1 -0
  63. package/dist/templates/demo-store/app/routes/$lang/[robots.txt].tsx +1 -0
  64. package/dist/templates/demo-store/app/routes/$lang/[sitemap.xml].tsx +1 -0
  65. package/dist/templates/demo-store/app/routes/$lang/account/__private/address/$id.tsx +1 -0
  66. package/dist/templates/demo-store/app/routes/$lang/account/__private/edit.tsx +1 -0
  67. package/dist/templates/demo-store/app/routes/$lang/account/__private/logout.ts +1 -0
  68. package/dist/templates/demo-store/app/routes/$lang/account/__private/orders.$id.tsx +1 -0
  69. package/dist/templates/demo-store/app/routes/$lang/account/__public/activate.$id.$activationToken.tsx +6 -0
  70. package/dist/templates/demo-store/app/routes/$lang/account/__public/login.tsx +7 -0
  71. package/dist/templates/demo-store/app/routes/$lang/account/__public/recover.tsx +1 -0
  72. package/dist/templates/demo-store/app/routes/$lang/account/__public/register.tsx +6 -0
  73. package/dist/templates/demo-store/app/routes/$lang/account/__public/reset.$id.$resetToken.tsx +5 -0
  74. package/dist/templates/demo-store/app/routes/$lang/account.tsx +1 -0
  75. package/dist/templates/demo-store/app/routes/$lang/api/countries.tsx +1 -0
  76. package/dist/templates/demo-store/app/routes/$lang/api/products.tsx +1 -0
  77. package/dist/templates/demo-store/app/routes/$lang/cart.tsx +1 -0
  78. package/dist/templates/demo-store/app/routes/$lang/collections/$collectionHandle.tsx +6 -0
  79. package/dist/templates/demo-store/app/routes/$lang/collections/all.tsx +1 -0
  80. package/dist/templates/demo-store/app/routes/$lang/collections/index.tsx +1 -0
  81. package/dist/templates/demo-store/app/routes/$lang/featured-products.tsx +1 -0
  82. package/dist/templates/demo-store/app/routes/$lang/index.tsx +7 -0
  83. package/dist/templates/demo-store/app/routes/$lang/journal/$journalHandle.tsx +7 -0
  84. package/dist/templates/demo-store/app/routes/$lang/journal/index.tsx +1 -0
  85. package/dist/templates/demo-store/app/routes/$lang/og-image.tsx +1 -0
  86. package/dist/templates/demo-store/app/routes/$lang/pages/$pageHandle.tsx +1 -0
  87. package/dist/templates/demo-store/app/routes/$lang/policies/$policyHandle.tsx +1 -0
  88. package/dist/templates/demo-store/app/routes/$lang/policies/index.tsx +1 -0
  89. package/dist/templates/demo-store/app/routes/$lang/products/$productHandle.tsx +6 -0
  90. package/dist/templates/demo-store/app/routes/$lang/products/index.tsx +1 -0
  91. package/dist/templates/demo-store/app/routes/$lang/search.tsx +6 -0
  92. package/dist/templates/demo-store/app/routes/[robots.txt].tsx +40 -0
  93. package/dist/templates/demo-store/app/routes/[sitemap.xml].tsx +198 -0
  94. package/dist/templates/demo-store/app/routes/account/__private/address/$id.tsx +320 -0
  95. package/dist/templates/demo-store/app/routes/account/__private/edit.tsx +273 -0
  96. package/dist/templates/demo-store/app/routes/account/__private/logout.ts +29 -0
  97. package/dist/templates/demo-store/app/routes/account/__private/orders.$id.tsx +324 -0
  98. package/dist/templates/demo-store/app/routes/account/__public/activate.$id.$activationToken.tsx +218 -0
  99. package/dist/templates/demo-store/app/routes/account/__public/login.tsx +197 -0
  100. package/dist/templates/demo-store/app/routes/account/__public/recover.tsx +144 -0
  101. package/dist/templates/demo-store/app/routes/account/__public/register.tsx +184 -0
  102. package/dist/templates/demo-store/app/routes/account/__public/reset.$id.$resetToken.tsx +214 -0
  103. package/dist/templates/demo-store/app/routes/account.tsx +191 -0
  104. package/dist/templates/demo-store/app/routes/api/countries.tsx +22 -0
  105. package/dist/templates/demo-store/app/routes/api/products.tsx +116 -0
  106. package/dist/templates/demo-store/app/routes/cart.tsx +498 -0
  107. package/dist/templates/demo-store/app/routes/collections/$collectionHandle.tsx +308 -0
  108. package/dist/templates/demo-store/app/routes/collections/all.tsx +5 -0
  109. package/dist/templates/demo-store/app/routes/collections/index.tsx +195 -0
  110. package/dist/templates/demo-store/app/routes/discounts.$code.tsx +60 -0
  111. package/dist/templates/demo-store/app/routes/featured-products.tsx +58 -0
  112. package/dist/templates/demo-store/app/routes/index.tsx +254 -0
  113. package/dist/templates/demo-store/app/routes/journal/$journalHandle.tsx +147 -0
  114. package/dist/templates/demo-store/app/routes/journal/index.tsx +150 -0
  115. package/dist/templates/demo-store/app/routes/og-image.tsx +19 -0
  116. package/dist/templates/demo-store/app/routes/pages/$pageHandle.tsx +82 -0
  117. package/dist/templates/demo-store/app/routes/policies/$policyHandle.tsx +117 -0
  118. package/dist/templates/demo-store/app/routes/policies/index.tsx +104 -0
  119. package/dist/templates/demo-store/app/routes/products/$productHandle.tsx +561 -0
  120. package/dist/templates/demo-store/app/routes/products/index.tsx +155 -0
  121. package/dist/templates/demo-store/app/routes/search.tsx +205 -0
  122. package/dist/templates/demo-store/app/styles/custom-font.css +13 -0
  123. package/dist/templates/demo-store/package-lock.json +25515 -0
  124. package/dist/templates/demo-store/package.json +67 -0
  125. package/dist/templates/demo-store/playwright.config.ts +109 -0
  126. package/dist/templates/demo-store/postcss.config.js +10 -0
  127. package/dist/templates/demo-store/public/favicon.svg +28 -0
  128. package/dist/templates/demo-store/public/fonts/IBMPlexSerif-Text.woff2 +0 -0
  129. package/dist/templates/demo-store/public/fonts/IBMPlexSerif-TextItalic.woff2 +0 -0
  130. package/dist/templates/demo-store/remix.config.js +12 -0
  131. package/dist/templates/demo-store/remix.env.d.ts +34 -0
  132. package/dist/templates/demo-store/remix.init/index.ts +15 -0
  133. package/dist/templates/demo-store/remix.init/package.json +7 -0
  134. package/dist/templates/demo-store/server.ts +87 -0
  135. package/dist/templates/demo-store/styles/app.css +182 -0
  136. package/dist/templates/demo-store/tailwind.config.js +70 -0
  137. package/dist/templates/demo-store/tests/cart.test.ts +70 -0
  138. package/dist/templates/demo-store/tests/seo.test.ts +36 -0
  139. package/dist/templates/demo-store/tests/utils.ts +100 -0
  140. package/dist/templates/demo-store/tsconfig.json +26 -0
  141. package/dist/templates/hello-world/.eslintignore +4 -0
  142. package/dist/templates/hello-world/.eslintrc.js +6 -0
  143. package/dist/templates/hello-world/.graphqlrc.yml +1 -0
  144. package/dist/templates/hello-world/.turbo/turbo-build.log +9 -0
  145. package/dist/templates/hello-world/README.md +20 -0
  146. package/dist/templates/hello-world/app/components/Layout.tsx +15 -0
  147. package/dist/templates/hello-world/app/components/index.ts +1 -0
  148. package/dist/templates/hello-world/app/entry.client.tsx +4 -0
  149. package/dist/templates/hello-world/app/entry.server.tsx +21 -0
  150. package/dist/templates/hello-world/app/root.tsx +212 -0
  151. package/dist/templates/hello-world/app/routes/index.tsx +7 -0
  152. package/dist/templates/hello-world/app/styles/app.css +38 -0
  153. package/dist/templates/hello-world/package-lock.json +27641 -0
  154. package/dist/templates/hello-world/package.json +41 -0
  155. package/dist/templates/hello-world/public/favicon.svg +28 -0
  156. package/dist/templates/hello-world/remix.env.d.ts +29 -0
  157. package/dist/templates/hello-world/server.ts +127 -0
  158. package/dist/templates/hello-world/tsconfig.json +25 -0
  159. package/dist/utils/config.js +81 -0
  160. package/dist/utils/flags.js +15 -0
  161. package/dist/utils/log.js +20 -0
  162. package/dist/utils/mini-oxygen.js +70 -0
  163. package/package.json +27 -64
  164. package/tmp-create-app.mjs +29 -0
  165. package/LICENSE +0 -8
  166. package/README.md +0 -63
  167. package/dist/cli/commands/hydrogen/add/eslint.d.ts +0 -11
  168. package/dist/cli/commands/hydrogen/add/eslint.js +0 -26
  169. package/dist/cli/commands/hydrogen/add/eslint.js.map +0 -1
  170. package/dist/cli/commands/hydrogen/add/tailwind.d.ts +0 -11
  171. package/dist/cli/commands/hydrogen/add/tailwind.js +0 -26
  172. package/dist/cli/commands/hydrogen/add/tailwind.js.map +0 -1
  173. package/dist/cli/commands/hydrogen/build.d.ts +0 -14
  174. package/dist/cli/commands/hydrogen/build.js +0 -49
  175. package/dist/cli/commands/hydrogen/build.js.map +0 -1
  176. package/dist/cli/commands/hydrogen/deploy.d.ts +0 -19
  177. package/dist/cli/commands/hydrogen/deploy.js +0 -58
  178. package/dist/cli/commands/hydrogen/deploy.js.map +0 -1
  179. package/dist/cli/commands/hydrogen/dev.d.ts +0 -13
  180. package/dist/cli/commands/hydrogen/dev.js +0 -31
  181. package/dist/cli/commands/hydrogen/dev.js.map +0 -1
  182. package/dist/cli/commands/hydrogen/info.d.ts +0 -12
  183. package/dist/cli/commands/hydrogen/info.js +0 -28
  184. package/dist/cli/commands/hydrogen/info.js.map +0 -1
  185. package/dist/cli/commands/hydrogen/preview.d.ts +0 -13
  186. package/dist/cli/commands/hydrogen/preview.js +0 -46
  187. package/dist/cli/commands/hydrogen/preview.js.map +0 -1
  188. package/dist/cli/constants.d.ts +0 -15
  189. package/dist/cli/constants.js +0 -16
  190. package/dist/cli/constants.js.map +0 -1
  191. package/dist/cli/flags.d.ts +0 -4
  192. package/dist/cli/flags.js +0 -16
  193. package/dist/cli/flags.js.map +0 -1
  194. package/dist/cli/models/hydrogen.d.ts +0 -22
  195. package/dist/cli/models/hydrogen.js +0 -82
  196. package/dist/cli/models/hydrogen.js.map +0 -1
  197. package/dist/cli/prompts/git-init.d.ts +0 -1
  198. package/dist/cli/prompts/git-init.js +0 -16
  199. package/dist/cli/prompts/git-init.js.map +0 -1
  200. package/dist/cli/services/build/check-lockfile.d.ts +0 -3
  201. package/dist/cli/services/build/check-lockfile.js +0 -80
  202. package/dist/cli/services/build/check-lockfile.js.map +0 -1
  203. package/dist/cli/services/build.d.ts +0 -14
  204. package/dist/cli/services/build.js +0 -44
  205. package/dist/cli/services/build.js.map +0 -1
  206. package/dist/cli/services/deploy/config.d.ts +0 -4
  207. package/dist/cli/services/deploy/config.js +0 -49
  208. package/dist/cli/services/deploy/config.js.map +0 -1
  209. package/dist/cli/services/deploy/error.d.ts +0 -4
  210. package/dist/cli/services/deploy/error.js +0 -11
  211. package/dist/cli/services/deploy/error.js.map +0 -1
  212. package/dist/cli/services/deploy/graphql/create_deployment.d.ts +0 -10
  213. package/dist/cli/services/deploy/graphql/create_deployment.js +0 -15
  214. package/dist/cli/services/deploy/graphql/create_deployment.js.map +0 -1
  215. package/dist/cli/services/deploy/graphql/upload_deployment.d.ts +0 -1
  216. package/dist/cli/services/deploy/graphql/upload_deployment.js +0 -16
  217. package/dist/cli/services/deploy/graphql/upload_deployment.js.map +0 -1
  218. package/dist/cli/services/deploy/types.d.ts +0 -37
  219. package/dist/cli/services/deploy/types.js +0 -2
  220. package/dist/cli/services/deploy/types.js.map +0 -1
  221. package/dist/cli/services/deploy/upload.d.ts +0 -5
  222. package/dist/cli/services/deploy/upload.js +0 -81
  223. package/dist/cli/services/deploy/upload.js.map +0 -1
  224. package/dist/cli/services/deploy.d.ts +0 -2
  225. package/dist/cli/services/deploy.js +0 -103
  226. package/dist/cli/services/deploy.js.map +0 -1
  227. package/dist/cli/services/dev/check-version.d.ts +0 -1
  228. package/dist/cli/services/dev/check-version.js +0 -30
  229. package/dist/cli/services/dev/check-version.js.map +0 -1
  230. package/dist/cli/services/dev.d.ts +0 -10
  231. package/dist/cli/services/dev.js +0 -36
  232. package/dist/cli/services/dev.js.map +0 -1
  233. package/dist/cli/services/eslint.d.ts +0 -8
  234. package/dist/cli/services/eslint.js +0 -74
  235. package/dist/cli/services/eslint.js.map +0 -1
  236. package/dist/cli/services/info.d.ts +0 -7
  237. package/dist/cli/services/info.js +0 -131
  238. package/dist/cli/services/info.js.map +0 -1
  239. package/dist/cli/services/preview.d.ts +0 -12
  240. package/dist/cli/services/preview.js +0 -63
  241. package/dist/cli/services/preview.js.map +0 -1
  242. package/dist/cli/services/tailwind.d.ts +0 -9
  243. package/dist/cli/services/tailwind.js +0 -103
  244. package/dist/cli/services/tailwind.js.map +0 -1
  245. package/dist/cli/utilities/load-config.d.ts +0 -5
  246. package/dist/cli/utilities/load-config.js +0 -6
  247. package/dist/cli/utilities/load-config.js.map +0 -1
  248. package/dist/tsconfig.tsbuildinfo +0 -1
  249. package/oclif.manifest.json +0 -1
@@ -0,0 +1,561 @@
1
+ import {type ReactNode, useRef, Suspense, useMemo} from 'react';
2
+ import {Disclosure, Listbox} from '@headlessui/react';
3
+ import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
4
+ import {notFoundMaybeRedirect, RESOURCE_TYPES} from '@shopify/hydrogen';
5
+ import {
6
+ useLoaderData,
7
+ Await,
8
+ useSearchParams,
9
+ useLocation,
10
+ useTransition,
11
+ useMatches,
12
+ useFetcher,
13
+ } from '@remix-run/react';
14
+ import {Money, ShopPayButton} from '@shopify/hydrogen-react';
15
+ import {
16
+ Heading,
17
+ IconCaret,
18
+ IconCheck,
19
+ IconClose,
20
+ ProductGallery,
21
+ ProductSwimlane,
22
+ Section,
23
+ Skeleton,
24
+ Text,
25
+ Link,
26
+ Button,
27
+ AddToCartButton,
28
+ } from '~/components';
29
+ import {getExcerpt} from '~/lib/utils';
30
+ import invariant from 'tiny-invariant';
31
+ import clsx from 'clsx';
32
+ import type {
33
+ ProductVariant,
34
+ SelectedOptionInput,
35
+ Product as ProductType,
36
+ Shop,
37
+ ProductConnection,
38
+ } from '@shopify/hydrogen-react/storefront-api-types';
39
+ import {
40
+ MEDIA_FRAGMENT,
41
+ PRODUCT_CARD_FRAGMENT,
42
+ PRODUCT_VARIANT_FRAGMENT,
43
+ } from '~/data'; /* @todo: we move these to app/graphql ? */
44
+
45
+ export async function loader({params, request, context}: LoaderArgs) {
46
+ const {productHandle} = params;
47
+ invariant(productHandle, 'Missing productHandle param, check route filename');
48
+
49
+ const searchParams = new URL(request.url).searchParams;
50
+
51
+ const selectedOptions: SelectedOptionInput[] = [];
52
+ searchParams.forEach((value, name) => {
53
+ selectedOptions.push({name, value});
54
+ });
55
+
56
+ const {shop, product} = await context.storefront.query<{
57
+ product: ProductType & {selectedVariant?: ProductVariant};
58
+ shop: Shop;
59
+ }>(PRODUCT_QUERY, {
60
+ variables: {
61
+ handle: productHandle,
62
+ selectedOptions,
63
+ country: context.storefront.i18n?.country,
64
+ language: context.storefront.i18n?.language,
65
+ },
66
+ });
67
+
68
+ if (!product?.id) {
69
+ throw await notFoundMaybeRedirect(request, context);
70
+ }
71
+
72
+ const recommended = getRecommendedProducts(context.storefront, product.id);
73
+
74
+ return defer({
75
+ product,
76
+ shop,
77
+ recommended,
78
+ });
79
+ }
80
+
81
+ export const handle = {
82
+ hydrogen: {
83
+ resourceType: RESOURCE_TYPES.PRODUCT,
84
+ },
85
+ };
86
+
87
+ export default function Product() {
88
+ const {product, shop, recommended} = useLoaderData<typeof loader>();
89
+ const {media, title, vendor, descriptionHtml} = product;
90
+ const {shippingPolicy, refundPolicy} = shop;
91
+
92
+ return (
93
+ <>
94
+ <Section padding="x" className="px-0">
95
+ <div className="grid items-start md:gap-6 lg:gap-20 md:grid-cols-2 lg:grid-cols-3">
96
+ <ProductGallery
97
+ media={media.nodes}
98
+ className="w-screen md:w-full lg:col-span-2"
99
+ />
100
+ <div className="sticky md:-mb-nav md:top-nav md:-translate-y-nav md:h-screen md:pt-nav hiddenScroll md:overflow-y-scroll">
101
+ <section className="flex flex-col w-full max-w-xl gap-8 p-6 md:mx-auto md:max-w-sm md:px-0">
102
+ <div className="grid gap-2">
103
+ <Heading as="h1" format className="whitespace-normal">
104
+ {title}
105
+ </Heading>
106
+ {vendor && (
107
+ <Text className={'opacity-50 font-medium'}>{vendor}</Text>
108
+ )}
109
+ </div>
110
+ <ProductForm />
111
+ <div className="grid gap-4 py-4">
112
+ {descriptionHtml && (
113
+ <ProductDetail
114
+ title="Product Details"
115
+ content={descriptionHtml}
116
+ />
117
+ )}
118
+ {shippingPolicy?.body && (
119
+ <ProductDetail
120
+ title="Shipping"
121
+ content={getExcerpt(shippingPolicy.body)}
122
+ learnMore={`/policies/${shippingPolicy.handle}`}
123
+ />
124
+ )}
125
+ {refundPolicy?.body && (
126
+ <ProductDetail
127
+ title="Returns"
128
+ content={getExcerpt(refundPolicy.body)}
129
+ learnMore={`/policies/${refundPolicy.handle}`}
130
+ />
131
+ )}
132
+ </div>
133
+ </section>
134
+ </div>
135
+ </div>
136
+ </Section>
137
+ <Suspense fallback={<Skeleton className="h-32" />}>
138
+ <Await
139
+ errorElement="There was a problem loading related products"
140
+ resolve={recommended}
141
+ >
142
+ {(products) => (
143
+ <ProductSwimlane title="Related Products" products={products} />
144
+ )}
145
+ </Await>
146
+ </Suspense>
147
+ </>
148
+ );
149
+ }
150
+
151
+ export function ProductForm() {
152
+ const {product} = useLoaderData<typeof loader>();
153
+
154
+ const [currentSearchParams] = useSearchParams();
155
+ const transition = useTransition();
156
+
157
+ /**
158
+ * We update `searchParams` with in-flight request data from `transition` (if available)
159
+ * to create an optimistic UI, e.g. check the product option before the
160
+ * request has completed.
161
+ */
162
+ const searchParams = useMemo(() => {
163
+ return transition.location
164
+ ? new URLSearchParams(transition.location.search)
165
+ : currentSearchParams;
166
+ }, [currentSearchParams, transition]);
167
+
168
+ const firstVariant = product.variants.nodes[0];
169
+
170
+ /**
171
+ * We're making an explicit choice here to display the product options
172
+ * UI with a default variant, rather than wait for the user to select
173
+ * options first. Developers are welcome to opt-out of this behavior.
174
+ * By default, the first variant's options are used.
175
+ */
176
+ const searchParamsWithDefaults = useMemo<URLSearchParams>(() => {
177
+ const clonedParams = new URLSearchParams(searchParams);
178
+
179
+ for (const {name, value} of firstVariant.selectedOptions) {
180
+ if (!searchParams.has(name)) {
181
+ clonedParams.set(name, value);
182
+ }
183
+ }
184
+
185
+ return clonedParams;
186
+ }, [searchParams, firstVariant.selectedOptions]);
187
+
188
+ /**
189
+ * Likewise, we're defaulting to the first variant for purposes
190
+ * of add to cart if there is none returned from the loader.
191
+ * A developer can opt out of this, too.
192
+ */
193
+ const selectedVariant = product.selectedVariant ?? firstVariant;
194
+ const isOutOfStock = !selectedVariant?.availableForSale;
195
+
196
+ const isOnSale =
197
+ selectedVariant?.price?.amount &&
198
+ selectedVariant?.compareAtPrice?.amount &&
199
+ selectedVariant?.price?.amount < selectedVariant?.compareAtPrice?.amount;
200
+
201
+ const lines = [
202
+ {
203
+ merchandiseId: selectedVariant.id,
204
+ quantity: 1,
205
+ },
206
+ ];
207
+
208
+ return (
209
+ <div className="grid gap-10">
210
+ <div className="grid gap-4">
211
+ <ProductOptions
212
+ options={product.options}
213
+ searchParamsWithDefaults={searchParamsWithDefaults}
214
+ />
215
+ {selectedVariant && (
216
+ <div className="grid items-stretch gap-4">
217
+ <AddToCartButton
218
+ lines={[
219
+ {
220
+ merchandiseId: selectedVariant.id,
221
+ quantity: 1,
222
+ },
223
+ ]}
224
+ variant={isOutOfStock ? 'secondary' : 'primary'}
225
+ data-test="add-to-cart"
226
+ >
227
+ {isOutOfStock ? (
228
+ <Text>Sold out</Text>
229
+ ) : (
230
+ <Text
231
+ as="span"
232
+ className="flex items-center justify-center gap-2"
233
+ >
234
+ <span>Add to Bag</span> <span>·</span>{' '}
235
+ <Money
236
+ withoutTrailingZeros
237
+ data={selectedVariant?.price!}
238
+ as="span"
239
+ />
240
+ {isOnSale && (
241
+ <Money
242
+ withoutTrailingZeros
243
+ data={selectedVariant?.compareAtPrice!}
244
+ as="span"
245
+ className="opacity-50 strike"
246
+ />
247
+ )}
248
+ </Text>
249
+ )}
250
+ </AddToCartButton>
251
+ {!isOutOfStock && (
252
+ <ShopPayButton variantIds={[selectedVariant?.id!]} />
253
+ )}
254
+ </div>
255
+ )}
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function ProductOptions({
262
+ options,
263
+ searchParamsWithDefaults,
264
+ }: {
265
+ options: ProductType['options'];
266
+ searchParamsWithDefaults: URLSearchParams;
267
+ }) {
268
+ const closeRef = useRef<HTMLButtonElement>(null);
269
+ return (
270
+ <>
271
+ {options
272
+ .filter((option) => option.values.length > 1)
273
+ .map((option) => (
274
+ <div
275
+ key={option.name}
276
+ className="flex flex-col flex-wrap mb-4 gap-y-2 last:mb-0"
277
+ >
278
+ <Heading as="legend" size="lead" className="min-w-[4rem]">
279
+ {option.name}
280
+ </Heading>
281
+ <div className="flex flex-wrap items-baseline gap-4">
282
+ {/**
283
+ * First, we render a bunch of <Link> elements for each option value.
284
+ * When the user clicks one of these buttons, it will hit the loader
285
+ * to get the new data.
286
+ *
287
+ * If there are more than 7 values, we render a dropdown.
288
+ * Otherwise, we just render plain links.
289
+ */}
290
+ {option.values.length > 7 ? (
291
+ <div className="relative w-full">
292
+ <Listbox>
293
+ {({open}) => (
294
+ <>
295
+ <Listbox.Button
296
+ ref={closeRef}
297
+ className={clsx(
298
+ 'flex items-center justify-between w-full py-3 px-4 border border-primary',
299
+ open
300
+ ? 'rounded-b md:rounded-t md:rounded-b-none'
301
+ : 'rounded',
302
+ )}
303
+ >
304
+ <span>
305
+ {searchParamsWithDefaults.get(option.name)}
306
+ </span>
307
+ <IconCaret direction={open ? 'up' : 'down'} />
308
+ </Listbox.Button>
309
+ <Listbox.Options
310
+ className={clsx(
311
+ 'border-primary bg-contrast absolute bottom-12 z-30 grid h-48 w-full overflow-y-scroll rounded-t border px-2 py-2 transition-[max-height] duration-150 sm:bottom-auto md:rounded-b md:rounded-t-none md:border-t-0 md:border-b',
312
+ open ? 'max-h-48' : 'max-h-0',
313
+ )}
314
+ >
315
+ {option.values.map((value) => (
316
+ <Listbox.Option
317
+ key={`option-${option.name}-${value}`}
318
+ value={value}
319
+ >
320
+ {({active}) => (
321
+ <ProductOptionLink
322
+ optionName={option.name}
323
+ optionValue={value}
324
+ className={clsx(
325
+ 'text-primary w-full p-2 transition rounded flex justify-start items-center text-left cursor-pointer',
326
+ active && 'bg-primary/10',
327
+ )}
328
+ searchParams={searchParamsWithDefaults}
329
+ onClick={() => {
330
+ if (!closeRef?.current) return;
331
+ closeRef.current.click();
332
+ }}
333
+ >
334
+ {value}
335
+ {searchParamsWithDefaults.get(option.name) ===
336
+ value && (
337
+ <span className="ml-2">
338
+ <IconCheck />
339
+ </span>
340
+ )}
341
+ </ProductOptionLink>
342
+ )}
343
+ </Listbox.Option>
344
+ ))}
345
+ </Listbox.Options>
346
+ </>
347
+ )}
348
+ </Listbox>
349
+ </div>
350
+ ) : (
351
+ <>
352
+ {option.values.map((value) => {
353
+ const checked =
354
+ searchParamsWithDefaults.get(option.name) === value;
355
+ const id = `option-${option.name}-${value}`;
356
+
357
+ return (
358
+ <Text key={id}>
359
+ <ProductOptionLink
360
+ optionName={option.name}
361
+ optionValue={value}
362
+ searchParams={searchParamsWithDefaults}
363
+ className={clsx(
364
+ 'leading-none py-1 border-b-[1.5px] cursor-pointer transition-all duration-200',
365
+ checked ? 'border-primary/50' : 'border-primary/0',
366
+ )}
367
+ />
368
+ </Text>
369
+ );
370
+ })}
371
+ </>
372
+ )}
373
+ </div>
374
+ </div>
375
+ ))}
376
+ </>
377
+ );
378
+ }
379
+
380
+ function ProductOptionLink({
381
+ optionName,
382
+ optionValue,
383
+ searchParams,
384
+ children,
385
+ ...props
386
+ }: {
387
+ optionName: string;
388
+ optionValue: string;
389
+ searchParams: URLSearchParams;
390
+ children?: ReactNode;
391
+ [key: string]: any;
392
+ }) {
393
+ const {pathname} = useLocation();
394
+ const isLangPathname = /\/[a-zA-Z]{2}-[a-zA-Z]{2}\//g.test(pathname);
395
+ // fixes internalized pathname
396
+ const path = isLangPathname
397
+ ? `/${pathname.split('/').slice(2).join('/')}`
398
+ : pathname;
399
+
400
+ const clonedSearchParams = new URLSearchParams(searchParams);
401
+ clonedSearchParams.set(optionName, optionValue);
402
+
403
+ return (
404
+ <Link
405
+ {...props}
406
+ prefetch="intent"
407
+ replace
408
+ to={`${path}?${clonedSearchParams.toString()}`}
409
+ >
410
+ {children ?? optionValue}
411
+ </Link>
412
+ );
413
+ }
414
+
415
+ function ProductDetail({
416
+ title,
417
+ content,
418
+ learnMore,
419
+ }: {
420
+ title: string;
421
+ content: string;
422
+ learnMore?: string;
423
+ }) {
424
+ return (
425
+ <Disclosure key={title} as="div" className="grid w-full gap-2">
426
+ {({open}) => (
427
+ <>
428
+ <Disclosure.Button className="text-left">
429
+ <div className="flex justify-between">
430
+ <Text size="lead" as="h4">
431
+ {title}
432
+ </Text>
433
+ <IconClose
434
+ className={clsx(
435
+ 'transition-transform transform-gpu duration-200',
436
+ !open && 'rotate-[45deg]',
437
+ )}
438
+ />
439
+ </div>
440
+ </Disclosure.Button>
441
+
442
+ <Disclosure.Panel className={'pb-4 pt-2 grid gap-2'}>
443
+ <div
444
+ className="prose dark:prose-invert"
445
+ dangerouslySetInnerHTML={{__html: content}}
446
+ />
447
+ {learnMore && (
448
+ <div className="">
449
+ <Link
450
+ className="pb-px border-b border-primary/30 text-primary/50"
451
+ to={learnMore}
452
+ >
453
+ Learn more
454
+ </Link>
455
+ </div>
456
+ )}
457
+ </Disclosure.Panel>
458
+ </>
459
+ )}
460
+ </Disclosure>
461
+ );
462
+ }
463
+
464
+ const PRODUCT_QUERY = `#graphql
465
+ ${MEDIA_FRAGMENT}
466
+ ${PRODUCT_VARIANT_FRAGMENT}
467
+ query Product(
468
+ $country: CountryCode
469
+ $language: LanguageCode
470
+ $handle: String!
471
+ $selectedOptions: [SelectedOptionInput!]!
472
+ ) @inContext(country: $country, language: $language) {
473
+ product(handle: $handle) {
474
+ id
475
+ title
476
+ vendor
477
+ handle
478
+ descriptionHtml
479
+ options {
480
+ name
481
+ values
482
+ }
483
+ selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {
484
+ ...ProductVariantFragment
485
+ }
486
+ media(first: 7) {
487
+ nodes {
488
+ ...Media
489
+ }
490
+ }
491
+ variants(first: 1) {
492
+ nodes {
493
+ ...ProductVariantFragment
494
+ }
495
+ }
496
+ seo {
497
+ description
498
+ title
499
+ }
500
+ }
501
+ shop {
502
+ name
503
+ shippingPolicy {
504
+ body
505
+ handle
506
+ }
507
+ refundPolicy {
508
+ body
509
+ handle
510
+ }
511
+ }
512
+ }
513
+ `;
514
+
515
+ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
516
+ ${PRODUCT_CARD_FRAGMENT}
517
+ query productRecommendations(
518
+ $productId: ID!
519
+ $count: Int
520
+ $country: CountryCode
521
+ $language: LanguageCode
522
+ ) @inContext(country: $country, language: $language) {
523
+ recommended: productRecommendations(productId: $productId) {
524
+ ...ProductCard
525
+ }
526
+ additional: products(first: $count, sortKey: BEST_SELLING) {
527
+ nodes {
528
+ ...ProductCard
529
+ }
530
+ }
531
+ }
532
+ `;
533
+
534
+ async function getRecommendedProducts(
535
+ storefront: LoaderArgs['context']['storefront'],
536
+ productId: string,
537
+ ) {
538
+ const products = await storefront.query<{
539
+ recommended: ProductType[];
540
+ additional: ProductConnection;
541
+ }>(RECOMMENDED_PRODUCTS_QUERY, {
542
+ variables: {productId, count: 12},
543
+ });
544
+
545
+ invariant(products, 'No data returned from Shopify API');
546
+
547
+ const mergedProducts = products.recommended
548
+ .concat(products.additional.nodes)
549
+ .filter(
550
+ (value, index, array) =>
551
+ array.findIndex((value2) => value2.id === value.id) === index,
552
+ );
553
+
554
+ const originalProduct = mergedProducts
555
+ .map((item: ProductType) => item.id)
556
+ .indexOf(productId);
557
+
558
+ mergedProducts.splice(originalProduct, 1);
559
+
560
+ return mergedProducts;
561
+ }
@@ -0,0 +1,155 @@
1
+ import {type MetaFunction, type LoaderArgs} from '@shopify/remix-oxygen';
2
+ import {RESOURCE_TYPES} from '@shopify/hydrogen';
3
+ import {useLoaderData} from '@remix-run/react';
4
+ import type {ProductConnection} from '@shopify/hydrogen-react/storefront-api-types';
5
+ import invariant from 'tiny-invariant';
6
+ import {
7
+ PageHeader,
8
+ Section,
9
+ ProductCard,
10
+ Grid,
11
+ Pagination,
12
+ getPaginationVariables,
13
+ Button,
14
+ } from '~/components';
15
+ import {PRODUCT_CARD_FRAGMENT} from '~/data';
16
+ import {getImageLoadingPriority} from '~/lib/const';
17
+
18
+ const PAGE_BY = 8;
19
+
20
+ export async function loader({request, context: {storefront}}: LoaderArgs) {
21
+ const variables = getPaginationVariables(request, PAGE_BY);
22
+
23
+ const data = await storefront.query<{
24
+ products: ProductConnection;
25
+ }>(ALL_PRODUCTS_QUERY, {
26
+ variables: {
27
+ ...variables,
28
+ country: storefront.i18n?.country,
29
+ language: storefront.i18n?.language,
30
+ },
31
+ });
32
+
33
+ invariant(data, 'No data returned from Shopify API');
34
+
35
+ return data.products;
36
+ }
37
+
38
+ export const meta: MetaFunction = () => {
39
+ return {
40
+ title: 'All Products',
41
+ description: 'All Products',
42
+ };
43
+ };
44
+
45
+ export const handle = {
46
+ hydrogen: {
47
+ resourceType: RESOURCE_TYPES.CATALOG,
48
+ },
49
+ };
50
+
51
+ export default function AllProducts() {
52
+ const products = useLoaderData<typeof loader>();
53
+
54
+ return (
55
+ <>
56
+ <PageHeader heading="All Products" variant="allCollections" />
57
+ <Section>
58
+ <Pagination connection={products}>
59
+ {({
60
+ endCursor,
61
+ hasNextPage,
62
+ hasPreviousPage,
63
+ nextPageUrl,
64
+ nodes,
65
+ prevPageUrl,
66
+ startCursor,
67
+ nextLinkRef,
68
+ isLoading,
69
+ }) => {
70
+ const itemsMarkup = nodes.map((product, i) => (
71
+ <ProductCard
72
+ key={product.id}
73
+ product={product}
74
+ loading={getImageLoadingPriority(i)}
75
+ />
76
+ ));
77
+
78
+ return (
79
+ <>
80
+ {hasPreviousPage && (
81
+ <div className="flex items-center justify-center mt-6">
82
+ <Button
83
+ to={prevPageUrl}
84
+ variant="secondary"
85
+ prefetch="intent"
86
+ width="full"
87
+ disabled={!isLoading}
88
+ state={{
89
+ pageInfo: {
90
+ endCursor,
91
+ hasNextPage,
92
+ startCursor,
93
+ },
94
+ nodes,
95
+ }}
96
+ >
97
+ {isLoading ? 'Loading...' : 'Previous'}
98
+ </Button>
99
+ </div>
100
+ )}
101
+ <Grid data-test="product-grid">{itemsMarkup}</Grid>
102
+ {hasNextPage && (
103
+ <div className="flex items-center justify-center mt-6">
104
+ <Button
105
+ ref={nextLinkRef}
106
+ to={nextPageUrl}
107
+ variant="secondary"
108
+ prefetch="intent"
109
+ width="full"
110
+ disabled={!isLoading}
111
+ state={{
112
+ pageInfo: {
113
+ endCursor,
114
+ hasPreviousPage,
115
+ startCursor,
116
+ },
117
+ nodes,
118
+ }}
119
+ >
120
+ {isLoading ? 'Loading...' : 'Next'}
121
+ </Button>
122
+ </div>
123
+ )}
124
+ </>
125
+ );
126
+ }}
127
+ </Pagination>
128
+ </Section>
129
+ </>
130
+ );
131
+ }
132
+
133
+ const ALL_PRODUCTS_QUERY = `#graphql
134
+ ${PRODUCT_CARD_FRAGMENT}
135
+ query AllProducts(
136
+ $country: CountryCode
137
+ $language: LanguageCode
138
+ $first: Int
139
+ $last: Int
140
+ $startCursor: String
141
+ $endCursor: String
142
+ ) @inContext(country: $country, language: $language) {
143
+ products(first: $first, last: $last, before: $startCursor, after: $endCursor) {
144
+ nodes {
145
+ ...ProductCard
146
+ }
147
+ pageInfo {
148
+ hasPreviousPage
149
+ hasNextPage
150
+ startCursor
151
+ endCursor
152
+ }
153
+ }
154
+ }
155
+ `;