@shopify/cli-hydrogen 10.0.1 → 10.0.2

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.
@@ -1,5 +1,32 @@
1
1
  # skeleton
2
2
 
3
+ ## 2025.4.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Moved the Cursor rules into more generic LLM prompt files. If you were using the Cursor rules, you will find the prompts in the `cookbook/llms` folder and they can be put into your `.cursor/rules` folder manually. LLM prompt files will be maintained moving forward, while previous Cursor rules will not be updated anymore. ([#2936](https://github.com/Shopify/hydrogen/pull/2936)) by [@ruggishop](https://github.com/ruggishop)
8
+
9
+ - Added bundles recipe ([#2915](https://github.com/Shopify/hydrogen/pull/2915)) by [@ruggishop](https://github.com/ruggishop)
10
+
11
+ - Update copy for subscriptions, combined listings, bundles recipes ([#2924](https://github.com/Shopify/hydrogen/pull/2924)) by [@ruggishop](https://github.com/ruggishop)
12
+
13
+ - Bump skeleton @shopify/cli and @shopify/mini-oxygen ([#2883](https://github.com/Shopify/hydrogen/pull/2883)) by [@juanpprieto](https://github.com/juanpprieto)
14
+
15
+ - Remove rules from the template. ([#2931](https://github.com/Shopify/hydrogen/pull/2931)) by [@ruggishop](https://github.com/ruggishop)
16
+
17
+ - Update SFAPI and CAAPI versions to 2025.04 ([#2886](https://github.com/Shopify/hydrogen/pull/2886)) by [@juanpprieto](https://github.com/juanpprieto)
18
+
19
+ - Updated recipes: subscriptions, bundles, combined-listings. New recipe: markets. ([#2930](https://github.com/Shopify/hydrogen/pull/2930)) by [@ruggishop](https://github.com/ruggishop)
20
+
21
+ - Updated the subscriptions recipe to better display the purchase options. ([#2912](https://github.com/Shopify/hydrogen/pull/2912)) by [@ruggishop](https://github.com/ruggishop)
22
+
23
+ - Bump recipes with copy adjustments ([#2935](https://github.com/Shopify/hydrogen/pull/2935)) by [@ruggishop](https://github.com/ruggishop)
24
+
25
+ - Added a Combined Listings recipe. ([#2876](https://github.com/Shopify/hydrogen/pull/2876)) by [@ruggishop](https://github.com/ruggishop)
26
+
27
+ - Updated dependencies [[`af23e710`](https://github.com/Shopify/hydrogen/commit/af23e710dac83bb57498d9c2ef1d8bcf9df55d34), [`9d8a6644`](https://github.com/Shopify/hydrogen/commit/9d8a6644a5b67dca890c6687df390aee78fc85c3)]:
28
+ - @shopify/hydrogen@2025.4.0
29
+
3
30
  ## 2025.1.7
4
31
 
5
32
  ### Patch Changes
@@ -137,13 +164,13 @@
137
164
  1. Add a routes.ts file. This is your new Remix route configuration file.
138
165
 
139
166
  ```ts
140
- import { flatRoutes } from "@remix-run/fs-routes";
141
- import { layout, type RouteConfig } from "@remix-run/route-config";
142
- import { hydrogenRoutes } from "@shopify/hydrogen";
167
+ import {flatRoutes} from '@remix-run/fs-routes';
168
+ import {layout, type RouteConfig} from '@remix-run/route-config';
169
+ import {hydrogenRoutes} from '@shopify/hydrogen';
143
170
 
144
171
  export default hydrogenRoutes([
145
172
  // Your entire app reading from routes folder using Layout from layout.tsx
146
- layout("./layout.tsx", await flatRoutes()),
173
+ layout('./layout.tsx', await flatRoutes()),
147
174
  ]) satisfies RouteConfig;
148
175
  ```
149
176
 
@@ -734,25 +761,25 @@
734
761
  8. Update the `ProductForm` component.
735
762
 
736
763
  ```tsx
737
- import { Link, useNavigate } from "@remix-run/react";
738
- import { type MappedProductOptions } from "@shopify/hydrogen";
764
+ import {Link, useNavigate} from '@remix-run/react';
765
+ import {type MappedProductOptions} from '@shopify/hydrogen';
739
766
  import type {
740
767
  Maybe,
741
768
  ProductOptionValueSwatch,
742
- } from "@shopify/hydrogen/storefront-api-types";
743
- import { AddToCartButton } from "./AddToCartButton";
744
- import { useAside } from "./Aside";
745
- import type { ProductFragment } from "storefrontapi.generated";
769
+ } from '@shopify/hydrogen/storefront-api-types';
770
+ import {AddToCartButton} from './AddToCartButton';
771
+ import {useAside} from './Aside';
772
+ import type {ProductFragment} from 'storefrontapi.generated';
746
773
 
747
774
  export function ProductForm({
748
775
  productOptions,
749
776
  selectedVariant,
750
777
  }: {
751
778
  productOptions: MappedProductOptions[];
752
- selectedVariant: ProductFragment["selectedOrFirstAvailableVariant"];
779
+ selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
753
780
  }) {
754
781
  const navigate = useNavigate();
755
- const { open } = useAside();
782
+ const {open} = useAside();
756
783
  return (
757
784
  <div className="product-form">
758
785
  {productOptions.map((option) => (
@@ -786,8 +813,8 @@
786
813
  to={`/products/${handle}?${variantUriQuery}`}
787
814
  style={{
788
815
  border: selected
789
- ? "1px solid black"
790
- : "1px solid transparent",
816
+ ? '1px solid black'
817
+ : '1px solid transparent',
791
818
  opacity: available ? 1 : 0.3,
792
819
  }}
793
820
  >
@@ -804,13 +831,13 @@
804
831
  <button
805
832
  type="button"
806
833
  className={`product-options-item${
807
- exists && !selected ? " link" : ""
834
+ exists && !selected ? ' link' : ''
808
835
  }`}
809
836
  key={option.name + name}
810
837
  style={{
811
838
  border: selected
812
- ? "1px solid black"
813
- : "1px solid transparent",
839
+ ? '1px solid black'
840
+ : '1px solid transparent',
814
841
  opacity: available ? 1 : 0.3,
815
842
  }}
816
843
  disabled={!exists}
@@ -834,7 +861,7 @@
834
861
  <AddToCartButton
835
862
  disabled={!selectedVariant || !selectedVariant.availableForSale}
836
863
  onClick={() => {
837
- open("cart");
864
+ open('cart');
838
865
  }}
839
866
  lines={
840
867
  selectedVariant
@@ -848,7 +875,7 @@
848
875
  : []
849
876
  }
850
877
  >
851
- {selectedVariant?.availableForSale ? "Add to cart" : "Sold out"}
878
+ {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
852
879
  </AddToCartButton>
853
880
  </div>
854
881
  );
@@ -871,7 +898,7 @@
871
898
  aria-label={name}
872
899
  className="product-option-label-swatch"
873
900
  style={{
874
- backgroundColor: color || "transparent",
901
+ backgroundColor: color || 'transparent',
875
902
  }}
876
903
  >
877
904
  {!!image && <img src={image} alt={name} />}
@@ -1372,21 +1399,21 @@
1372
1399
  New `withCache.fetch` is for caching simple fetch requests. This method caches the responses if they are OK responses, and you can pass `shouldCacheResponse`, `cacheKey`, etc. to modify behavior. `data` is the consumed body of the response (we need to consume to cache it).
1373
1400
 
1374
1401
  ```ts
1375
- const withCache = createWithCache({ cache, waitUntil, request });
1402
+ const withCache = createWithCache({cache, waitUntil, request});
1376
1403
 
1377
- const { data, response } = await withCache.fetch<{ data: T; error: string }>(
1378
- "my-cms.com/api",
1404
+ const {data, response} = await withCache.fetch<{data: T; error: string}>(
1405
+ 'my-cms.com/api',
1379
1406
  {
1380
- method: "POST",
1381
- headers: { "Content-type": "application/json" },
1407
+ method: 'POST',
1408
+ headers: {'Content-type': 'application/json'},
1382
1409
  body,
1383
1410
  },
1384
1411
  {
1385
1412
  cacheStrategy: CacheLong(),
1386
1413
  // Cache if there are no data errors or a specific data that make this result not suited for caching
1387
1414
  shouldCacheResponse: (result) => !result?.error,
1388
- cacheKey: ["my-cms", body],
1389
- displayName: "My CMS query",
1415
+ cacheKey: ['my-cms', body],
1416
+ displayName: 'My CMS query',
1390
1417
  },
1391
1418
  );
1392
1419
  ```
@@ -1962,9 +1989,9 @@
1962
1989
 
1963
1990
  ```tsx
1964
1991
  // app/lib/root-data.ts
1965
- import { useMatches } from "@remix-run/react";
1966
- import type { SerializeFrom } from "@shopify/remix-oxygen";
1967
- import type { loader } from "~/root";
1992
+ import {useMatches} from '@remix-run/react';
1993
+ import type {SerializeFrom} from '@shopify/remix-oxygen';
1994
+ import type {loader} from '~/root';
1968
1995
 
1969
1996
  /**
1970
1997
  * Access the result of the root loader from a React component.
@@ -2126,10 +2153,10 @@
2126
2153
  - This is an important fix to a bug with 404 routes and path-based i18n projects where some unknown routes would not properly render a 404. This fixes all new projects, but to fix existing projects, add a `($locale).tsx` route with the following contents: ([#1732](https://github.com/Shopify/hydrogen/pull/1732)) by [@blittle](https://github.com/blittle)
2127
2154
 
2128
2155
  ```ts
2129
- import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
2156
+ import {type LoaderFunctionArgs} from '@remix-run/server-runtime';
2130
2157
 
2131
- export async function loader({ params, context }: LoaderFunctionArgs) {
2132
- const { language, country } = context.storefront.i18n;
2158
+ export async function loader({params, context}: LoaderFunctionArgs) {
2159
+ const {language, country} = context.storefront.i18n;
2133
2160
 
2134
2161
  if (
2135
2162
  params.locale &&
@@ -2137,7 +2164,7 @@
2137
2164
  ) {
2138
2165
  // If the locale URL param is defined, yet we still are still at the default locale
2139
2166
  // then the the locale param must be invalid, send to the 404 page
2140
- throw new Response(null, { status: 404 });
2167
+ throw new Response(null, {status: 404});
2141
2168
  }
2142
2169
 
2143
2170
  return null;
@@ -2193,11 +2220,11 @@
2193
2220
  ```yaml
2194
2221
  projects:
2195
2222
  default:
2196
- schema: "node_modules/@shopify/hydrogen/storefront.schema.json"
2223
+ schema: 'node_modules/@shopify/hydrogen/storefront.schema.json'
2197
2224
  documents:
2198
- - "!*.d.ts"
2199
- - "*.{ts,tsx,js,jsx}"
2200
- - "app/**/*.{ts,tsx,js,jsx}"
2225
+ - '!*.d.ts'
2226
+ - '*.{ts,tsx,js,jsx}'
2227
+ - 'app/**/*.{ts,tsx,js,jsx}'
2201
2228
  ```
2202
2229
 
2203
2230
  - Improve resiliency of `HydrogenSession` ([#1583](https://github.com/Shopify/hydrogen/pull/1583)) by [@blittle](https://github.com/blittle)
@@ -2412,8 +2439,8 @@
2412
2439
  ```ts
2413
2440
  // root.tsx
2414
2441
 
2415
- import { useMatches } from "@remix-run/react";
2416
- import { type SerializeFrom } from "@shopify/remix-oxygen";
2442
+ import {useMatches} from '@remix-run/react';
2443
+ import {type SerializeFrom} from '@shopify/remix-oxygen';
2417
2444
 
2418
2445
  export const useRootLoaderData = () => {
2419
2446
  const [root] = useMatches();
@@ -2,7 +2,7 @@
2
2
  "name": "skeleton",
3
3
  "private": true,
4
4
  "sideEffects": false,
5
- "version": "2025.1.7",
5
+ "version": "2025.4.0",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "build": "shopify hydrogen build --codegen",
@@ -17,7 +17,7 @@
17
17
  "@remix-run/react": "^2.16.1",
18
18
  "@remix-run/server-runtime": "^2.16.1",
19
19
  "graphql": "^16.10.0",
20
- "@shopify/hydrogen": "2025.1.4",
20
+ "@shopify/hydrogen": "2025.4.0",
21
21
  "@shopify/remix-oxygen": "^2.0.12",
22
22
  "graphql-tag": "^2.12.6",
23
23
  "isbot": "^5.1.22",
@@ -32,7 +32,7 @@
32
32
  "@remix-run/dev": "^2.16.1",
33
33
  "@remix-run/fs-routes": "^2.16.1",
34
34
  "@remix-run/route-config": "^2.16.1",
35
- "@shopify/cli": "~3.78.1",
35
+ "@shopify/cli": "~3.79.2",
36
36
  "@shopify/hydrogen-codegen": "^0.3.3",
37
37
  "@shopify/mini-oxygen": "^3.2.1",
38
38
  "@shopify/oxygen-workers-types": "^4.1.6",
@@ -2,7 +2,10 @@
2
2
  "browserslist": [
3
3
  "defaults"
4
4
  ],
5
+ "dependencies": {
6
+ "tailwindcss": "^4.1.6"
7
+ },
5
8
  "devDependencies": {
6
- "@tailwindcss/vite": "4.0.14"
9
+ "@tailwindcss/vite": "^4.1.6"
7
10
  }
8
11
  }
@@ -1748,5 +1748,5 @@
1748
1748
  ]
1749
1749
  }
1750
1750
  },
1751
- "version": "10.0.1"
1751
+ "version": "10.0.2"
1752
1752
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "@shopify:registry": "https://registry.npmjs.org"
6
6
  },
7
- "version": "10.0.1",
7
+ "version": "10.0.2",
8
8
  "license": "MIT",
9
9
  "type": "module",
10
10
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@ast-grep/napi": "0.11.0",
41
41
  "@oclif/core": "3.26.5",
42
- "@shopify/cli-kit": "^3.78.1",
42
+ "@shopify/cli-kit": "^3.79.2",
43
43
  "@shopify/oxygen-cli": "4.6.18",
44
44
  "@shopify/plugin-cloudflare": "^3.78.1",
45
45
  "ansi-escapes": "^6.2.0",
@@ -1,921 +0,0 @@
1
- ---
2
- description: Recipe for implementing "Subscriptions (subscriptions)" in a Hydrogen storefront. Add subscription-based products to your Hydrogen storefront.
3
- globs: *
4
- alwaysApply: false
5
- ---
6
-
7
- # Overview
8
-
9
- This rule describes how to implement "Subscriptions" in a Hydrogen storefront. Below is a "recipe" that contains the steps to apply to a basic Hydrogen skeleton template to achieve the desired outcome.
10
- The same logic can be applied to any other Hydrogen storefront project, adapting the implementation details to the specific needs/structure/conventions of the project, but it's up to the developer to do so.
11
- If there are any prerequisites, the recipe below will explain them; if the user is trying to implement the feature described in this recipe, make sure to prominently mention the prerequisites and any other preliminary instructions, as well as followups.
12
- If the user is asking on how to implement the feature from scratch, please first describe the feature in a general way before jumping into the implementation details.
13
- Please note that the recipe steps below are not necessarily ordered in the way they should be executed, as it depends on the user's needs and the specific details of the project. The recipe steps descriptions should allow you to understand what is required to be done in a certain order and what is not. Remember that file names in the recipe are related to the Hydrogen skeleton template, not the user's project, so make sure to adapt the file names to the user's project.
14
-
15
- # AI model verification steps
16
-
17
- - Never edit generated files (ending with .d.ts) directly; instead, run the `npm run codegen` command to update them.
18
-
19
- # Summary
20
-
21
- Add subscription-based products to your Hydrogen storefront.
22
-
23
- # User Intent Recognition
24
-
25
- <user_queries>
26
- - How do I add subscriptions to my Hydrogen storefront?
27
- - How do I add selling plans to my Hydrogen storefront?
28
- - How do I display subscription details on applicable line items in the cart?
29
- </user_queries>
30
-
31
- # Troubleshooting
32
-
33
- <troubleshooting>
34
- - **Issue**: I'm getting an error when I try to add a subscription to my storefront.
35
- **Solution**: Make sure you have the Shopify Subscriptions app installed and configured correctly.
36
- - **Issue**: I'm not seeing the subscription options on my product pages.
37
- **Solution**: Make sure you have the Shopify Subscriptions app installed and configured correctly.
38
- - **Issue**: I'm not seeing the subscription details on my cart line items.
39
- **Solution**: Make sure you have the Shopify Subscriptions app installed and configured correctly.
40
- </troubleshooting>
41
-
42
- # Recipe Implementation
43
-
44
- Here's the subscriptions recipe for the base Hydrogen skeleton template:
45
-
46
- <recipe_implementation>
47
-
48
- ## Description
49
-
50
- This recipe lets you sell subscription-based products on your Hydrogen storefront by implementing [selling plan groups](https://shopify.dev/docs/api/storefront/latest/objects/SellingPlanGroup). Your customers will be able to choose between one-time purchases or recurring subscriptions for any products with available selling plans.
51
-
52
-
53
- In this recipe you'll make the following changes:
54
-
55
-
56
- 1. Set up a subscriptions app in your Shopify admin and add selling plans to any products that will be sold as subscriptions.
57
- 2. Modify product detail pages to display subscription options with accurate pricing using the `SellingPlanSelector` component.
58
- 3. Enhance GraphQL fragments to fetch all necessary selling plan data.
59
- 4. Display subscription details on applicable line items in the cart.
60
-
61
-
62
- ## Requirements
63
-
64
- To implement subscriptions in your own store, you need to install a subscriptions app in your Shopify admin. In this recipe, we'll use the [Shopify Subscriptions app](https://apps.shopify.com/shopify-subscriptions).
65
-
66
-
67
- ## New files added to the template by this recipe
68
-
69
- ### templates/skeleton/app/components/SellingPlanSelector.tsx
70
-
71
- The `SellingPlanSelector` component is used to display the available subscription options on product pages.
72
-
73
- ```tsx
74
- import type {
75
- ProductFragment,
76
- SellingPlanGroupFragment,
77
- SellingPlanFragment,
78
- } from 'storefrontapi.generated';
79
- import {useMemo} from 'react';
80
- import {useLocation} from '@remix-run/react';
81
-
82
- /* Enriched sellingPlan type including isSelected and url */
83
- export type SellingPlan = SellingPlanFragment & {
84
- isSelected: boolean;
85
- url: string;
86
- };
87
-
88
- /* Enriched sellingPlanGroup type including enriched SellingPlan nodes */
89
- export type SellingPlanGroup = Omit<
90
- SellingPlanGroupFragment,
91
- 'sellingPlans'
92
- > & {
93
- sellingPlans: {
94
- nodes: SellingPlan[];
95
- };
96
- };
97
-
98
- /**
99
- * A component that simplifies selecting sellingPlans subscription options
100
- * @example Example use
101
- * ```ts
102
- * <SellingPlanSelector
103
- * sellingPlanGroups={sellingPlanGroups}
104
- * selectedSellingPlanId={selectedSellingPlanId}
105
- * >
106
- * {({sellingPlanGroup}) => ( ...your sellingPlanGroup component )}
107
- * </SellingPlanSelector>
108
- * ```
109
- **/
110
- export function SellingPlanSelector({
111
- sellingPlanGroups,
112
- selectedSellingPlan,
113
- children,
114
- paramKey = 'selling_plan',
115
- }: {
116
- sellingPlanGroups: ProductFragment['sellingPlanGroups'];
117
- selectedSellingPlan: SellingPlanFragment | null;
118
- paramKey?: string;
119
- children: (params: {
120
- sellingPlanGroup: SellingPlanGroup;
121
- selectedSellingPlan: SellingPlanFragment | null;
122
- }) => React.ReactNode;
123
- }) {
124
- const {search, pathname} = useLocation();
125
- const params = new URLSearchParams(search);
126
-
127
- return useMemo(
128
- () =>
129
- (sellingPlanGroups.nodes as SellingPlanGroup[]).map(
130
- (sellingPlanGroup) => {
131
- // Augmnet each sellingPlan node with isSelected and url
132
- const sellingPlans = sellingPlanGroup.sellingPlans.nodes
133
- .map((sellingPlan: SellingPlan) => {
134
- if (!sellingPlan?.id) {
135
- //eslint-disable-next-line no-console
136
- console.warn(
137
- 'SellingPlanSelector: sellingPlan.id is missing in the product query',
138
- );
139
- return null;
140
- }
141
- if (!sellingPlan.id) return null;
142
- params.set(paramKey, sellingPlan.id);
143
- sellingPlan.isSelected =
144
- selectedSellingPlan?.id === sellingPlan.id;
145
- sellingPlan.url = `${pathname}?${params.toString()}`;
146
- return sellingPlan;
147
- })
148
- .filter(Boolean) as SellingPlan[];
149
- sellingPlanGroup.sellingPlans.nodes = sellingPlans;
150
- return children({sellingPlanGroup, selectedSellingPlan});
151
- },
152
- ),
153
- // eslint-disable-next-line react-hooks/exhaustive-deps
154
- [sellingPlanGroups, children, selectedSellingPlan, paramKey, pathname],
155
- );
156
- }
157
-
158
- ```
159
-
160
- ### templates/skeleton/app/styles/selling-plan.css
161
-
162
- The `selling-plan.css` file is used to style the `SellingPlanSelector` component.
163
-
164
- ```css
165
- .selling-plan-group {
166
- margin-bottom: 1rem;
167
- }
168
-
169
- .selling-plan-group-title {
170
- font-weight: 500;
171
- margin-bottom: 0.5rem;
172
- }
173
-
174
- .selling-plan {
175
- border: 1px solid;
176
- display: inline-block;
177
- padding: 1rem;
178
- margin-right: 0.5rem;
179
- line-height: 1;
180
- padding-top: 0.25rem;
181
- padding-bottom: 0.25rem;
182
- border-bottom-width: 1.5px;
183
- cursor: pointer;
184
- transition: all 0.2s;
185
- }
186
-
187
- .selling-plan:hover {
188
- text-decoration: none;
189
- }
190
-
191
- .selling-plan.selected {
192
- border-color: #6b7280; /* Equivalent to 'border-gray-500' */
193
- }
194
-
195
- .selling-plan.unselected {
196
- border-color: #fafafa; /* Equivalent to 'border-neutral-50' */
197
- }
198
-
199
- ```
200
-
201
- ## Steps
202
-
203
- ### Step 1: Set up the Shopify Subscriptions app
204
-
205
- 1. Install the [Shopify Subscriptions app](https://apps.shopify.com/shopify-subscriptions).
206
- 2. In your Shopify admin, [use the Subscriptions app](https://admin.shopify.com/apps/subscriptions-remix/app) to create one or more subscription plans.
207
- 3. On the [Products](https://admin.shopify.com/products) page, open any products that will be sold as subscriptions and add the relevant subscription plans in the **Purchase options** section.
208
- The Hydrogen demo storefront comes pre-configured with an example subscription product with the handle `shopify-wax`.
209
-
210
-
211
- ### Step 2: Render the selling plan in the cart
212
-
213
- 1. Update `CartLineItem` to show subscription details when they're available.
214
- 2. Extract `sellingPlanAllocation` from cart line data, display the plan name, and standardize component import paths.
215
-
216
-
217
- #### File: /app/components/CartLineItem.tsx
218
-
219
- ```diff
220
- @@ -3,8 +3,8 @@ import type {CartLayout} from '~/components/CartMain';
221
- import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen';
222
- import {useVariantUrl} from '~/lib/variants';
223
- import {Link} from '@remix-run/react';
224
- -import {ProductPrice} from './ProductPrice';
225
- -import {useAside} from './Aside';
226
- +import {ProductPrice} from '~/components/ProductPrice';
227
- +import {useAside} from '~/components/Aside';
228
- import type {CartApiQueryFragment} from 'storefrontapi.generated';
229
-
230
- type CartLine = OptimisticCartLine<CartApiQueryFragment>;
231
- @@ -20,7 +20,9 @@ export function CartLineItem({
232
- layout: CartLayout;
233
- line: CartLine;
234
- }) {
235
- - const {id, merchandise} = line;
236
- + // Get the selling plan allocation
237
- + const {id, merchandise, sellingPlanAllocation} = line;
238
- +
239
- const {product, title, image, selectedOptions} = merchandise;
240
- const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
241
- const {close} = useAside();
242
- @@ -54,6 +56,12 @@ export function CartLineItem({
243
- </Link>
244
- <ProductPrice price={line?.cost?.totalAmount} />
245
- <ul>
246
- + {/* Optionally render the selling plan name if available */}
247
- + {sellingPlanAllocation && (
248
- + <li key={sellingPlanAllocation.sellingPlan.name}>
249
- + <small>{sellingPlanAllocation.sellingPlan.name}</small>
250
- + </li>
251
- + )}
252
- {selectedOptions.map((option) => (
253
- <li key={option.name}>
254
- <small>
255
- ```
256
-
257
- ### Step 3: Update `ProductForm` to support subscriptions
258
-
259
- 1. Add conditional rendering to display either subscription options or standard variant selectors.
260
- 2. Implement `SellingPlanSelector` and `SellingPlanGroup` components to handle subscription plan selection.
261
- 3. Update `AddToCartButton` to include selling plan data when subscriptions are selected.
262
-
263
-
264
- #### File: /app/components/ProductForm.tsx
265
-
266
- ```diff
267
- @@ -6,120 +6,169 @@ import type {
268
- } from '@shopify/hydrogen/storefront-api-types';
269
- import {AddToCartButton} from './AddToCartButton';
270
- import {useAside} from './Aside';
271
- -import type {ProductFragment} from 'storefrontapi.generated';
272
- +import type {
273
- + ProductFragment,
274
- + SellingPlanFragment,
275
- +} from 'storefrontapi.generated';
276
- +import {
277
- + SellingPlanSelector,
278
- + type SellingPlanGroup,
279
- +} from '~/components/SellingPlanSelector';
280
-
281
- export function ProductForm({
282
- productOptions,
283
- selectedVariant,
284
- + sellingPlanGroups,
285
- + selectedSellingPlan,
286
- }: {
287
- productOptions: MappedProductOptions[];
288
- selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
289
- + selectedSellingPlan: SellingPlanFragment | null;
290
- + sellingPlanGroups: ProductFragment['sellingPlanGroups'];
291
- }) {
292
- const navigate = useNavigate();
293
- const {open} = useAside();
294
- return (
295
- <div className="product-form">
296
- - {productOptions.map((option) => {
297
- - // If there is only a single value in the option values, don't display the option
298
- - if (option.optionValues.length === 1) return null;
299
- + {sellingPlanGroups.nodes.length > 0 ? (
300
- + <>
301
- + <SellingPlanSelector
302
- + sellingPlanGroups={sellingPlanGroups}
303
- + selectedSellingPlan={selectedSellingPlan}
304
- + >
305
- + {({sellingPlanGroup}) => (
306
- + <SellingPlanGroup
307
- + key={sellingPlanGroup.name}
308
- + sellingPlanGroup={sellingPlanGroup}
309
- + />
310
- + )}
311
- + </SellingPlanSelector>
312
- + <br />
313
- + <AddToCartButton
314
- + disabled={!selectedSellingPlan}
315
- + onClick={() => {
316
- + open('cart');
317
- + }}
318
- + lines={
319
- + selectedSellingPlan && selectedVariant
320
- + ? [
321
- + {
322
- + quantity: 1,
323
- + selectedVariant,
324
- + sellingPlanId: selectedSellingPlan.id,
325
- + merchandiseId: selectedVariant.id,
326
- + },
327
- + ]
328
- + : []
329
- + }
330
- + >
331
- + {selectedSellingPlan ? 'Subscribe' : 'Select Subscription'}
332
- + </AddToCartButton>
333
- + </>
334
- + ) : (
335
- + productOptions.map((option) => {
336
- + // If there is only a single value in the option values, don't display the option
337
- + if (option.optionValues.length === 1) return null;
338
-
339
- - return (
340
- - <div className="product-options" key={option.name}>
341
- - <h5>{option.name}</h5>
342
- - <div className="product-options-grid">
343
- - {option.optionValues.map((value) => {
344
- - const {
345
- - name,
346
- - handle,
347
- - variantUriQuery,
348
- - selected,
349
- - available,
350
- - exists,
351
- - isDifferentProduct,
352
- - swatch,
353
- - } = value;
354
- + return (
355
- + <div className="product-options" key={option.name}>
356
- + <h5>{option.name}</h5>
357
- + <div className="product-options-grid">
358
- + {option.optionValues.map((value) => {
359
- + const {
360
- + name,
361
- + handle,
362
- + variantUriQuery,
363
- + selected,
364
- + available,
365
- + exists,
366
- + isDifferentProduct,
367
- + swatch,
368
- + } = value;
369
-
370
- - if (isDifferentProduct) {
371
- - // SEO
372
- - // When the variant is a combined listing child product
373
- - // that leads to a different url, we need to render it
374
- - // as an anchor tag
375
- - return (
376
- - <Link
377
- - className="product-options-item"
378
- - key={option.name + name}
379
- - prefetch="intent"
380
- - preventScrollReset
381
- - replace
382
- - to={`/products/${handle}?${variantUriQuery}`}
383
- - style={{
384
- - border: selected
385
- - ? '1px solid black'
386
- - : '1px solid transparent',
387
- - opacity: available ? 1 : 0.3,
388
- - }}
389
- - >
390
- - <ProductOptionSwatch swatch={swatch} name={name} />
391
- - </Link>
392
- - );
393
- - } else {
394
- - // SEO
395
- - // When the variant is an update to the search param,
396
- - // render it as a button with javascript navigating to
397
- - // the variant so that SEO bots do not index these as
398
- - // duplicated links
399
- - return (
400
- - <button
401
- - type="button"
402
- - className={`product-options-item${
403
- - exists && !selected ? ' link' : ''
404
- - }`}
405
- - key={option.name + name}
406
- - style={{
407
- - border: selected
408
- - ? '1px solid black'
409
- - : '1px solid transparent',
410
- - opacity: available ? 1 : 0.3,
411
- - }}
412
- - disabled={!exists}
413
- - onClick={() => {
414
- - if (!selected) {
415
- - navigate(`?${variantUriQuery}`, {
416
- - replace: true,
417
- - preventScrollReset: true,
418
- - });
419
- - }
420
- - }}
421
- - >
422
- - <ProductOptionSwatch swatch={swatch} name={name} />
423
- - </button>
424
- - );
425
- + if (isDifferentProduct) {
426
- + // SEO
427
- + // When the variant is a combined listing child product
428
- + // that leads to a different url, we need to render it
429
- + // as an anchor tag
430
- + return (
431
- + <Link
432
- + className="product-options-item"
433
- + key={option.name + name}
434
- + prefetch="intent"
435
- + preventScrollReset
436
- + replace
437
- + to={`/products/${handle}?${variantUriQuery}`}
438
- + style={{
439
- + border: selected
440
- + ? '1px solid black'
441
- + : '1px solid transparent',
442
- + opacity: available ? 1 : 0.3,
443
- + }}
444
- + >
445
- + <ProductOptionSwatch swatch={swatch} name={name} />
446
- + </Link>
447
- + );
448
- + } else {
449
- + // SEO
450
- + // When the variant is an update to the search param,
451
- + // render it as a button with javascript navigating to
452
- + // the variant so that SEO bots do not index these as
453
- + // duplicated links
454
- + return (
455
- + <button
456
- + type="button"
457
- + className={`product-options-item${
458
- + exists && !selected ? ' link' : ''
459
- + }`}
460
- + key={option.name + name}
461
- + style={{
462
- + border: selected
463
- + ? '1px solid black'
464
- + : '1px solid transparent',
465
- + opacity: available ? 1 : 0.3,
466
- + }}
467
- + disabled={!exists}
468
- + onClick={() => {
469
- + if (!selected) {
470
- + navigate(`?${variantUriQuery}`, {
471
- + replace: true,
472
- + preventScrollReset: true,
473
- + });
474
- + }
475
- + }}
476
- + >
477
- + <ProductOptionSwatch swatch={swatch} name={name} />
478
- + </button>
479
- + );
480
- + }
481
- + })}
482
- + </div>
483
- + <AddToCartButton
484
- + disabled={!selectedVariant || !selectedVariant.availableForSale}
485
- + onClick={() => {
486
- + open('cart');
487
- + }}
488
- + lines={
489
- + selectedVariant
490
- + ? [
491
- + {
492
- + merchandiseId: selectedVariant.id,
493
- + quantity: 1,
494
- + selectedVariant,
495
- + },
496
- + ]
497
- + : []
498
- }
499
- - })}
500
- + >
501
- + {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
502
- + </AddToCartButton>
503
- +
504
- + <br />
505
- </div>
506
- - <br />
507
- - </div>
508
- - );
509
- - })}
510
- - <AddToCartButton
511
- - disabled={!selectedVariant || !selectedVariant.availableForSale}
512
- - onClick={() => {
513
- - open('cart');
514
- - }}
515
- - lines={
516
- - selectedVariant
517
- - ? [
518
- - {
519
- - merchandiseId: selectedVariant.id,
520
- - quantity: 1,
521
- - selectedVariant,
522
- - },
523
- - ]
524
- - : []
525
- - }
526
- - >
527
- - {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
528
- - </AddToCartButton>
529
- + );
530
- + })
531
- + )}
532
- </div>
533
- );
534
- }
535
- @@ -148,3 +197,38 @@ function ProductOptionSwatch({
536
- </div>
537
- );
538
- }
539
- +
540
- +// Update as you see fit to match your design and requirements
541
- +function SellingPlanGroup({
542
- + sellingPlanGroup,
543
- +}: {
544
- + sellingPlanGroup: SellingPlanGroup;
545
- +}) {
546
- + return (
547
- + <div className="selling-plan-group" key={sellingPlanGroup.name}>
548
- + <p className="selling-plan-group-title">
549
- + <strong>{sellingPlanGroup.name}:</strong>
550
- + </p>
551
- + {sellingPlanGroup.sellingPlans.nodes.map((sellingPlan) => {
552
- + return (
553
- + <Link
554
- + key={sellingPlan.id}
555
- + prefetch="intent"
556
- + to={sellingPlan.url}
557
- + className={`selling-plan ${
558
- + sellingPlan.isSelected ? 'selected' : 'unselected'
559
- + }`}
560
- + preventScrollReset
561
- + replace
562
- + >
563
- + <p>
564
- + {sellingPlan.options.map(
565
- + (option) => `${option.name} ${option.value}`,
566
- + )}
567
- + </p>
568
- + </Link>
569
- + );
570
- + })}
571
- + </div>
572
- + );
573
- +}
574
- ```
575
-
576
- ### Step 4: Update `ProductPrice` to display subscription pricing
577
-
578
- 1. Add a `SellingPlanPrice` function to calculate adjusted prices based on subscription plan type (fixed amount, fixed price, or percentage).
579
- 2. Add logic to handle different price adjustment types and render the appropriate subscription price when a selling plan is selected.
580
-
581
-
582
- #### File: /app/components/ProductPrice.tsx
583
-
584
- ```diff
585
- @@ -1,13 +1,31 @@
586
- +import type {CurrencyCode} from '@shopify/hydrogen/customer-account-api-types';
587
- +import type {
588
- + ProductFragment,
589
- + SellingPlanFragment,
590
- +} from 'storefrontapi.generated';
591
- import {Money} from '@shopify/hydrogen';
592
- import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
593
-
594
- export function ProductPrice({
595
- price,
596
- compareAtPrice,
597
- + selectedSellingPlan,
598
- + selectedVariant,
599
- }: {
600
- price?: MoneyV2;
601
- compareAtPrice?: MoneyV2 | null;
602
- + selectedVariant?: ProductFragment['selectedOrFirstAvailableVariant'];
603
- + selectedSellingPlan?: SellingPlanFragment | null;
604
- }) {
605
- + if (selectedSellingPlan) {
606
- + return (
607
- + <SellingPlanPrice
608
- + selectedSellingPlan={selectedSellingPlan}
609
- + selectedVariant={selectedVariant}
610
- + />
611
- + );
612
- + }
613
- +
614
- return (
615
- <div className="product-price">
616
- {compareAtPrice ? (
617
- @@ -25,3 +43,74 @@ export function ProductPrice({
618
- </div>
619
- );
620
- }
621
- +
622
- +type SellingPlanPrice = {
623
- + amount: number;
624
- + currencyCode: CurrencyCode;
625
- +};
626
- +
627
- +/*
628
- + Render the selected selling plan price is available
629
- +*/
630
- +function SellingPlanPrice({
631
- + selectedSellingPlan,
632
- + selectedVariant,
633
- +}: {
634
- + selectedSellingPlan: SellingPlanFragment;
635
- + selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
636
- +}) {
637
- + if (!selectedVariant) {
638
- + return null;
639
- + }
640
- +
641
- + const sellingPlanPriceAdjustments = selectedSellingPlan?.priceAdjustments;
642
- +
643
- + if (!sellingPlanPriceAdjustments?.length) {
644
- + return selectedVariant ? <Money data={selectedVariant.price} /> : null;
645
- + }
646
- +
647
- + const selectedVariantPrice: SellingPlanPrice = {
648
- + amount: parseFloat(selectedVariant.price.amount),
649
- + currencyCode: selectedVariant.price.currencyCode,
650
- + };
651
- +
652
- + const sellingPlanPrice: SellingPlanPrice = sellingPlanPriceAdjustments.reduce(
653
- + (acc, adjustment) => {
654
- + switch (adjustment.adjustmentValue.__typename) {
655
- + case 'SellingPlanFixedAmountPriceAdjustment':
656
- + return {
657
- + amount:
658
- + acc.amount +
659
- + parseFloat(adjustment.adjustmentValue.adjustmentAmount.amount),
660
- + currencyCode: acc.currencyCode,
661
- + };
662
- + case 'SellingPlanFixedPriceAdjustment':
663
- + return {
664
- + amount: parseFloat(adjustment.adjustmentValue.price.amount),
665
- + currencyCode: acc.currencyCode,
666
- + };
667
- + case 'SellingPlanPercentagePriceAdjustment':
668
- + return {
669
- + amount:
670
- + acc.amount *
671
- + (1 - adjustment.adjustmentValue.adjustmentPercentage / 100),
672
- + currencyCode: acc.currencyCode,
673
- + };
674
- + default:
675
- + return acc;
676
- + }
677
- + },
678
- + selectedVariantPrice,
679
- + );
680
- +
681
- + return (
682
- + <div className="selling-plan-price">
683
- + <Money
684
- + data={{
685
- + amount: `${sellingPlanPrice.amount}`,
686
- + currencyCode: sellingPlanPrice.currencyCode,
687
- + }}
688
- + />
689
- + </div>
690
- + );
691
- +}
692
- ```
693
-
694
- ### Step 5: Add selling plan data to cart queries
695
-
696
- Add a `sellingPlanAllocation` field with the plan name to the standard and componentizable cart line GraphQL fragments. This displays subscription details in the cart.
697
-
698
-
699
- #### File: /app/lib/fragments.ts
700
-
701
- ```diff
702
- @@ -54,6 +54,11 @@ export const CART_QUERY_FRAGMENT = `#graphql
703
- }
704
- }
705
- }
706
- + sellingPlanAllocation {
707
- + sellingPlan {
708
- + name
709
- + }
710
- + }
711
- }
712
- fragment CartLineComponent on ComponentizableCartLine {
713
- id
714
- @@ -104,6 +109,11 @@ export const CART_QUERY_FRAGMENT = `#graphql
715
- }
716
- }
717
- }
718
- + sellingPlanAllocation {
719
- + sellingPlan {
720
- + name
721
- + }
722
- + }
723
- }
724
- fragment CartApiQuery on Cart {
725
- updatedAt
726
- ```
727
-
728
- ### Step 6: Add `SellingPlanSelector` to product pages
729
-
730
- 1. Add the `SellingPlanSelector` component to display subscription options on product pages.
731
- 2. Add logic to handle pricing adjustments, maintain selection state using URL parameters, and update the add-to-cart functionality.
732
- 3. Fetch subscription data through the updated cart GraphQL fragments.
733
-
734
-
735
- #### File: /app/routes/products.$handle.tsx
736
-
737
- ```diff
738
- @@ -1,3 +1,5 @@
739
- +import type {SellingPlanFragment} from 'storefrontapi.generated';
740
- +import type {LinksFunction} from '@remix-run/node';
741
- import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
742
- import {useLoaderData, type MetaFunction} from '@remix-run/react';
743
- import {
744
- @@ -13,6 +15,12 @@ import {ProductImage} from '~/components/ProductImage';
745
- import {ProductForm} from '~/components/ProductForm';
746
- import {redirectIfHandleIsLocalized} from '~/lib/redirect';
747
-
748
- +import sellingPanStyle from '~/styles/selling-plan.css?url';
749
- +
750
- +export const links: LinksFunction = () => [
751
- + {rel: 'stylesheet', href: sellingPanStyle},
752
- +];
753
- +
754
- export const meta: MetaFunction<typeof loader> = ({data}) => {
755
- return [
756
- {title: `Hydrogen | ${data?.product.title ?? ''}`},
757
- @@ -63,8 +71,34 @@ async function loadCriticalData({
758
- // The API handle might be localized, so redirect to the localized handle
759
- redirectIfHandleIsLocalized(request, {handle, data: product});
760
-
761
- + // Initialize the selectedSellingPlan to null
762
- + let selectedSellingPlan = null;
763
- +
764
- + // Get the selected selling plan id from the request url
765
- + const selectedSellingPlanId =
766
- + new URL(request.url).searchParams.get('selling_plan') ?? null;
767
- +
768
- + // Get the selected selling plan bsed on the selectedSellingPlanId
769
- + if (selectedSellingPlanId) {
770
- + const selectedSellingPlanGroup =
771
- + product.sellingPlanGroups.nodes?.find((sellingPlanGroup) => {
772
- + return sellingPlanGroup.sellingPlans.nodes?.find(
773
- + (sellingPlan: SellingPlanFragment) =>
774
- + sellingPlan.id === selectedSellingPlanId,
775
- + );
776
- + }) ?? null;
777
- +
778
- + if (selectedSellingPlanGroup) {
779
- + selectedSellingPlan =
780
- + selectedSellingPlanGroup.sellingPlans.nodes.find((sellingPlan) => {
781
- + return sellingPlan.id === selectedSellingPlanId;
782
- + }) ?? null;
783
- + }
784
- + }
785
- +
786
- return {
787
- product,
788
- + selectedSellingPlan,
789
- };
790
- }
791
-
792
- @@ -81,7 +115,7 @@ function loadDeferredData({context, params}: LoaderFunctionArgs) {
793
- }
794
-
795
- export default function Product() {
796
- - const {product} = useLoaderData<typeof loader>();
797
- + const {product, selectedSellingPlan} = useLoaderData<typeof loader>();
798
-
799
- // Optimistically selects a variant with given available variant information
800
- const selectedVariant = useOptimisticVariant(
801
- @@ -99,7 +133,7 @@ export default function Product() {
802
- selectedOrFirstAvailableVariant: selectedVariant,
803
- });
804
-
805
- - const {title, descriptionHtml} = product;
806
- + const {title, descriptionHtml, sellingPlanGroups} = product;
807
-
808
- return (
809
- <div className="product">
810
- @@ -109,11 +143,15 @@ export default function Product() {
811
- <ProductPrice
812
- price={selectedVariant?.price}
813
- compareAtPrice={selectedVariant?.compareAtPrice}
814
- + selectedSellingPlan={selectedSellingPlan}
815
- + selectedVariant={selectedVariant}
816
- />
817
- <br />
818
- <ProductForm
819
- productOptions={productOptions}
820
- selectedVariant={selectedVariant}
821
- + selectedSellingPlan={selectedSellingPlan}
822
- + sellingPlanGroups={sellingPlanGroups}
823
- />
824
- <br />
825
- <br />
826
- @@ -180,6 +218,73 @@ const PRODUCT_VARIANT_FRAGMENT = `#graphql
827
- }
828
- ` as const;
829
-
830
- +const SELLING_PLAN_FRAGMENT = `#graphql
831
- + fragment SellingPlanMoney on MoneyV2 {
832
- + amount
833
- + currencyCode
834
- + }
835
- + fragment SellingPlan on SellingPlan {
836
- + id
837
- + options {
838
- + name
839
- + value
840
- + }
841
- + priceAdjustments {
842
- + adjustmentValue {
843
- + ... on SellingPlanFixedAmountPriceAdjustment {
844
- + __typename
845
- + adjustmentAmount {
846
- + ... on MoneyV2 {
847
- + ...SellingPlanMoney
848
- + }
849
- + }
850
- + }
851
- + ... on SellingPlanFixedPriceAdjustment {
852
- + __typename
853
- + price {
854
- + ... on MoneyV2 {
855
- + ...SellingPlanMoney
856
- + }
857
- + }
858
- + }
859
- + ... on SellingPlanPercentagePriceAdjustment {
860
- + __typename
861
- + adjustmentPercentage
862
- + }
863
- + }
864
- + orderCount
865
- + }
866
- + recurringDeliveries
867
- + checkoutCharge {
868
- + type
869
- + value {
870
- + ... on MoneyV2 {
871
- + ...SellingPlanMoney
872
- + }
873
- + ... on SellingPlanCheckoutChargePercentageValue {
874
- + percentage
875
- + }
876
- + }
877
- + }
878
- + }
879
- +` as const;
880
- +
881
- +const SELLING_PLAN_GROUP_FRAGMENT = `#graphql
882
- + fragment SellingPlanGroup on SellingPlanGroup {
883
- + name
884
- + options {
885
- + name
886
- + values
887
- + }
888
- + sellingPlans(first:10) {
889
- + nodes {
890
- + ...SellingPlan
891
- + }
892
- + }
893
- + }
894
- + ${SELLING_PLAN_FRAGMENT}
895
- +` as const;
896
- +
897
- const PRODUCT_FRAGMENT = `#graphql
898
- fragment Product on Product {
899
- id
900
- @@ -207,6 +312,11 @@ const PRODUCT_FRAGMENT = `#graphql
901
- }
902
- }
903
- }
904
- + sellingPlanGroups(first:10) {
905
- + nodes {
906
- + ...SellingPlanGroup
907
- + }
908
- + }
909
- selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
910
- ...ProductVariant
911
- }
912
- @@ -218,6 +328,7 @@ const PRODUCT_FRAGMENT = `#graphql
913
- title
914
- }
915
- }
916
- + ${SELLING_PLAN_GROUP_FRAGMENT}
917
- ${PRODUCT_VARIANT_FRAGMENT}
918
- ` as const;
919
- ```
920
-
921
- </recipe_implementation>