@shopify/cli 3.83.1 → 3.83.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hydrogen/starter/.cursor/rules/hydrogen-react-router.mdc +52 -0
- package/dist/assets/hydrogen/starter/CHANGELOG.md +35 -0
- package/dist/assets/hydrogen/starter/app/components/AddToCartButton.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/CartLineItem.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/CartMain.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/CartSummary.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/Footer.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/Header.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/PageLayout.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/ProductForm.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/ProductItem.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/SearchForm.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/SearchFormPredictive.tsx +1 -6
- package/dist/assets/hydrogen/starter/app/components/SearchResults.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/components/SearchResultsPredictive.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/entry.client.tsx +2 -2
- package/dist/assets/hydrogen/starter/app/entry.server.tsx +9 -4
- package/dist/assets/hydrogen/starter/app/lib/variants.ts +1 -1
- package/dist/assets/hydrogen/starter/app/root.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/_index.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/account.addresses.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/account.orders.$id.tsx +4 -4
- package/dist/assets/hydrogen/starter/app/routes/account.orders._index.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/account.profile.tsx +1 -7
- package/dist/assets/hydrogen/starter/app/routes/account.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
- package/dist/assets/hydrogen/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/blogs.$blogHandle._index.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/blogs._index.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/cart.tsx +7 -2
- package/dist/assets/hydrogen/starter/app/routes/collections.$handle.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/collections._index.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/collections.all.tsx +2 -2
- package/dist/assets/hydrogen/starter/app/routes/pages.$handle.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/policies.$handle.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/policies._index.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/products.$handle.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes/search.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/routes.ts +2 -2
- package/dist/assets/hydrogen/starter/env.d.ts +12 -2
- package/dist/assets/hydrogen/starter/eslint.config.js +2 -0
- package/dist/assets/hydrogen/starter/package.json +11 -12
- package/dist/assets/hydrogen/starter/react-router.config.ts +7 -0
- package/dist/assets/hydrogen/starter/server.ts +2 -5
- package/dist/assets/hydrogen/starter/tsconfig.json +18 -6
- package/dist/assets/hydrogen/starter/vite.config.ts +3 -24
- package/dist/assets/hydrogen/tailwind/package.json +4 -1
- package/dist/assets/hydrogen/virtual-routes/components/RequestDetails.jsx +1 -1
- package/dist/assets/hydrogen/virtual-routes/layout.jsx +1 -7
- package/dist/assets/hydrogen/virtual-routes/routes/index.jsx +1 -1
- package/dist/assets/hydrogen/virtual-routes/routes/subrequest-profiler.jsx +1 -1
- package/dist/assets/hydrogen/virtual-routes/virtual-root-with-layout.jsx +1 -1
- package/dist/assets/hydrogen/virtual-routes/virtual-root.jsx +1 -1
- package/dist/assets/hydrogen/vite/vite.config.js +1 -1
- package/dist/{chunk-SJYNVNUB.js → chunk-2MEZBZGZ.js} +2 -2
- package/dist/{chunk-TLYRIQK7.js → chunk-3EURRQ7K.js} +6 -6
- package/dist/{chunk-7KLV4YSY.js → chunk-45OTXKBK.js} +2 -2
- package/dist/{chunk-XI6AXLZO.js → chunk-6QJUUGM2.js} +4 -4
- package/dist/{chunk-RDAZS4FZ.js → chunk-AQDVU7OK.js} +7 -7
- package/dist/{chunk-DVGCJLCA.js → chunk-BXBJI6XD.js} +6 -6
- package/dist/{chunk-5BJYF6FG.js → chunk-CC4H7OAW.js} +3 -3
- package/dist/{chunk-DFABOPSU.js → chunk-DRATBB26.js} +4 -4
- package/dist/{chunk-DDU22ZPL.js → chunk-EG4X7G4K.js} +3 -3
- package/dist/{chunk-Y2JP6WFP.js → chunk-EG6MBBEN.js} +2 -2
- package/dist/{chunk-FNYLJESL.js → chunk-EYZZIYQJ.js} +4 -4
- package/dist/{chunk-ARXCCKJT.js → chunk-FE35FSRC.js} +8 -8
- package/dist/{chunk-TB443RFG.js → chunk-FHOKC4TU.js} +5 -5
- package/dist/{chunk-A6DFWCO7.js → chunk-GGCRQOED.js} +4 -4
- package/dist/{chunk-4BPSIIGV.js → chunk-HFQX4JZY.js} +3 -3
- package/dist/{chunk-7EV36FNO.js → chunk-JEQ7HZCS.js} +306 -474
- package/dist/{chunk-ZQMRVJXG.js → chunk-JWJ2ZR5V.js} +3 -3
- package/dist/{chunk-UZIJLLPI.js → chunk-MGI52UZC.js} +3 -3
- package/dist/{chunk-XOIMWH77.js → chunk-Q6VEUT5Q.js} +2 -2
- package/dist/{chunk-FEMALH67.js → chunk-RBL7EMA5.js} +4 -4
- package/dist/{chunk-57SXE5DE.js → chunk-S5JEIN5Q.js} +2 -2
- package/dist/{chunk-ASWHWSRF.js → chunk-SBU6S5G7.js} +2 -2
- package/dist/{chunk-K5GF4S7D.js → chunk-U7OT774M.js} +2 -2
- package/dist/{chunk-KPFBSWRK.js → chunk-UNQM4BV5.js} +3 -3
- package/dist/{chunk-2ZA3MHM5.js → chunk-VCN6UBNT.js} +3 -3
- package/dist/{chunk-5QFLNCVR.js → chunk-VQ6DWYP7.js} +6 -6
- package/dist/{chunk-KSLA767C.js → chunk-VRECY72K.js} +2 -2
- package/dist/{chunk-QNMWKLS4.js → chunk-WGMFODN6.js} +3 -3
- package/dist/{chunk-BZFP2O66.js → chunk-WHCXZSKH.js} +4 -4
- package/dist/{chunk-JEW6HZHJ.js → chunk-WROAVXGY.js} +3 -3
- package/dist/{chunk-SUA462JY.js → chunk-XFK3CKIN.js} +5 -5
- package/dist/{chunk-XVZQHSFP.js → chunk-YYAMP36W.js} +5 -5
- package/dist/{chunk-NJN55E4K.js → chunk-ZE4NKLTB.js} +4 -4
- package/dist/{chunk-PPDGMVCL.js → chunk-ZLYK26OR.js} +4 -4
- package/dist/{chunk-BZBO3V3A.js → chunk-ZZGNTCPD.js} +3 -3
- package/dist/cli/commands/auth/logout.js +12 -12
- package/dist/cli/commands/auth/logout.test.js +13 -13
- package/dist/cli/commands/cache/clear.js +11 -11
- package/dist/cli/commands/debug/command-flags.js +11 -11
- package/dist/cli/commands/docs/generate.js +11 -11
- package/dist/cli/commands/docs/generate.test.js +11 -11
- package/dist/cli/commands/help.js +11 -11
- package/dist/cli/commands/kitchen-sink/async.js +12 -12
- package/dist/cli/commands/kitchen-sink/async.test.js +12 -12
- package/dist/cli/commands/kitchen-sink/index.js +14 -14
- package/dist/cli/commands/kitchen-sink/index.test.js +14 -14
- package/dist/cli/commands/kitchen-sink/prompts.js +12 -12
- package/dist/cli/commands/kitchen-sink/prompts.test.js +12 -12
- package/dist/cli/commands/kitchen-sink/static.js +12 -12
- package/dist/cli/commands/kitchen-sink/static.test.js +12 -12
- package/dist/cli/commands/notifications/generate.js +12 -12
- package/dist/cli/commands/notifications/list.js +12 -12
- package/dist/cli/commands/search.js +12 -12
- package/dist/cli/commands/upgrade.js +12 -12
- package/dist/cli/commands/version.js +12 -12
- package/dist/cli/commands/version.test.js +12 -12
- package/dist/cli/services/commands/notifications.js +7 -7
- package/dist/cli/services/commands/search.js +3 -3
- package/dist/cli/services/commands/search.test.js +3 -3
- package/dist/cli/services/commands/version.js +4 -4
- package/dist/cli/services/commands/version.test.js +5 -5
- package/dist/cli/services/kitchen-sink/async.js +3 -3
- package/dist/cli/services/kitchen-sink/prompts.js +3 -3
- package/dist/cli/services/kitchen-sink/static.js +3 -3
- package/dist/cli/services/upgrade.js +4 -4
- package/dist/cli/services/upgrade.test.js +5 -5
- package/dist/{custom-oclif-loader-XASCYJ7O.js → custom-oclif-loader-FOBJVOKU.js} +3 -3
- package/dist/{error-handler-HQBVO3GI.js → error-handler-VBQPBASZ.js} +9 -9
- package/dist/hooks/postrun.js +7 -7
- package/dist/hooks/prerun.js +8 -8
- package/dist/index.js +826 -1144
- package/dist/{local-PI2REOYY.js → local-3V2H6DEQ.js} +3 -3
- package/dist/{morph-5LWJDWYB.js → morph-TYIH255V.js} +9 -9
- package/dist/{node-package-manager-RVDETLBX.js → node-package-manager-3VOZDWRH.js} +4 -4
- package/dist/{path-COZT77T2.js → path-GB4VIEM6.js} +2 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/{ui-RDFVKEJS.js → ui-MKD65UA4.js} +3 -3
- package/dist/{workerd-XLT4ZWI5.js → workerd-EQ7HXYPK.js} +14 -14
- package/oclif.manifest.json +3 -23
- package/package.json +8 -8
- package/dist/assets/hydrogen/starter/.cursor/rules/cookbook-recipe-subscriptions.mdc +0 -921
- package/dist/chunk-V67D4V7T.js +0 -107
- package/dist/node-NAYZANSZ.js +0 -162
|
@@ -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>
|