@rebuy/rebuy-hydrogen 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RebuyCompleteTheLook.client.jsx +188 -0
- package/RebuyDynamicBundleProducts.client.jsx +415 -0
- package/RebuyProductAddOnCard.client.jsx +89 -0
- package/RebuyProductAddOns.client.jsx +227 -0
- package/RebuyProductRecommendations.client.jsx +10 -5
- package/RebuyRecentlyViewedProducts.client.jsx +5 -2
- package/RebuyWidgetContainer.client.jsx +2 -2
- package/package.json +1 -1
@@ -0,0 +1,188 @@
|
|
1
|
+
import {
|
2
|
+
AddToCartButton,
|
3
|
+
Image,
|
4
|
+
Link,
|
5
|
+
Money,
|
6
|
+
ProductOptionsProvider,
|
7
|
+
} from '@shopify/hydrogen';
|
8
|
+
import clsx from 'clsx';
|
9
|
+
import { useState } from 'react';
|
10
|
+
import { Section, Text } from '~/components';
|
11
|
+
import { Button } from '~/components/elements';
|
12
|
+
|
13
|
+
const isDiscounted = (price, compareAtPrice) =>
|
14
|
+
Number(compareAtPrice?.amount) > Number(price?.amount);
|
15
|
+
|
16
|
+
const CompareAtPrice = ({ data: compareAtPrice, className }) => {
|
17
|
+
const styles = clsx('strike', className);
|
18
|
+
|
19
|
+
return (
|
20
|
+
<Money
|
21
|
+
withoutTrailingZeros
|
22
|
+
data={compareAtPrice}
|
23
|
+
as="span"
|
24
|
+
className={styles}
|
25
|
+
/>
|
26
|
+
);
|
27
|
+
};
|
28
|
+
|
29
|
+
const AddToCartMarkup = ({
|
30
|
+
product,
|
31
|
+
selectedVariant = product.variants.nodes[0],
|
32
|
+
}) => {
|
33
|
+
const isOutOfStock = !selectedVariant.availableForSale;
|
34
|
+
|
35
|
+
return (
|
36
|
+
<AddToCartButton
|
37
|
+
disabled={isOutOfStock}
|
38
|
+
variantId={selectedVariant?.id}
|
39
|
+
quantity={1}
|
40
|
+
accessibleAddingToCartLabel="Adding item to your cart"
|
41
|
+
type="button"
|
42
|
+
attributes={[
|
43
|
+
{ key: '_source', value: 'Rebuy' },
|
44
|
+
{ key: '_attribution', value: 'Rebuy Product Recommendations' },
|
45
|
+
]}
|
46
|
+
>
|
47
|
+
<Button
|
48
|
+
width="full"
|
49
|
+
variant={isOutOfStock ? 'secondary' : 'primary'}
|
50
|
+
as="span"
|
51
|
+
className="px-0"
|
52
|
+
>
|
53
|
+
{isOutOfStock ? 'Out of stock' : 'Add'}
|
54
|
+
</Button>
|
55
|
+
</AddToCartButton>
|
56
|
+
);
|
57
|
+
};
|
58
|
+
|
59
|
+
const RebuyProductPrice = ({ selectedVariant = {} }) => {
|
60
|
+
const { priceV2: price, compareAtPriceV2: compareAtPrice } =
|
61
|
+
selectedVariant;
|
62
|
+
|
63
|
+
return (
|
64
|
+
price && (
|
65
|
+
<div className="gap-4">
|
66
|
+
<Text className="flex gap-2">
|
67
|
+
<Money withoutTrailingZeros data={price} />
|
68
|
+
{isDiscounted(price, compareAtPrice) && (
|
69
|
+
<CompareAtPrice
|
70
|
+
className={'opacity-50'}
|
71
|
+
data={compareAtPrice}
|
72
|
+
/>
|
73
|
+
)}
|
74
|
+
</Text>
|
75
|
+
</div>
|
76
|
+
)
|
77
|
+
);
|
78
|
+
};
|
79
|
+
|
80
|
+
const VariantSelector = ({ product, handleSelectedVariant }) => {
|
81
|
+
return (
|
82
|
+
product?.variants.nodes.length > 1 && (
|
83
|
+
<div className="">
|
84
|
+
<select
|
85
|
+
className="w-full py-1 rounded"
|
86
|
+
name=""
|
87
|
+
onChange={(e) =>
|
88
|
+
handleSelectedVariant(product, e.target.value)
|
89
|
+
}
|
90
|
+
>
|
91
|
+
<optgroup label={getOptionsLabel(product)}>
|
92
|
+
{product.variants.nodes.map(({ id, title }) => (
|
93
|
+
<option key={id + '-variant'} value={id}>
|
94
|
+
{title}
|
95
|
+
</option>
|
96
|
+
))}
|
97
|
+
</optgroup>
|
98
|
+
</select>
|
99
|
+
</div>
|
100
|
+
)
|
101
|
+
);
|
102
|
+
};
|
103
|
+
|
104
|
+
const getOptionsLabel = (product) => {
|
105
|
+
const options = product.variants.nodes[0].selectedOptions;
|
106
|
+
const optionsFromKeys = Object.keys(options[0]);
|
107
|
+
const optionsFromValues = options.map((option) => option.name);
|
108
|
+
const useValues = optionsFromKeys.every((key) =>
|
109
|
+
['name', 'value'].includes(key)
|
110
|
+
);
|
111
|
+
|
112
|
+
// Return delimited label for available option(s) e.g. Color / Size, Scent, etc
|
113
|
+
return (useValues ? optionsFromValues : optionsFromKeys).join(' / ');
|
114
|
+
};
|
115
|
+
|
116
|
+
const RebuyProductCard = ({ product }) => {
|
117
|
+
const [selectedVariant, setSelectedVariant] = useState(
|
118
|
+
product.variants.nodes[0]
|
119
|
+
);
|
120
|
+
const { image } = selectedVariant;
|
121
|
+
const handleSelectedVariant = (product, variant_id) => {
|
122
|
+
const updatedVariant = product.variants.nodes.find(
|
123
|
+
(variant) => variant.id === variant_id
|
124
|
+
);
|
125
|
+
|
126
|
+
setSelectedVariant(updatedVariant);
|
127
|
+
};
|
128
|
+
|
129
|
+
return (
|
130
|
+
<div className="grid grid-cols-2 grid-rows-1 gap-6">
|
131
|
+
<Link to={`/products/${product.handle}`}>
|
132
|
+
{image && (
|
133
|
+
<Image
|
134
|
+
className="w-full object-cover fadeIn"
|
135
|
+
data={image}
|
136
|
+
alt={image.altText || `Picture of ${product.title}`}
|
137
|
+
/>
|
138
|
+
)}
|
139
|
+
</Link>
|
140
|
+
<div className="grid gap-1 items-start">
|
141
|
+
<Link to={`/products/${product.handle}`}>
|
142
|
+
<Text>{product.title}</Text>
|
143
|
+
</Link>
|
144
|
+
<RebuyProductPrice selectedVariant={selectedVariant} />
|
145
|
+
<VariantSelector
|
146
|
+
product={product}
|
147
|
+
handleSelectedVariant={handleSelectedVariant}
|
148
|
+
/>
|
149
|
+
<AddToCartMarkup
|
150
|
+
product={product}
|
151
|
+
selectedVariant={selectedVariant}
|
152
|
+
/>
|
153
|
+
</div>
|
154
|
+
</div>
|
155
|
+
);
|
156
|
+
};
|
157
|
+
|
158
|
+
export const RebuyCompleteTheLook = ({
|
159
|
+
product = {},
|
160
|
+
products = [],
|
161
|
+
// eslint-disable-next-line no-unused-vars
|
162
|
+
metadata = {},
|
163
|
+
title = `These pair with ${product.title}`,
|
164
|
+
className = '',
|
165
|
+
}) => {
|
166
|
+
const styles = clsx('', className);
|
167
|
+
|
168
|
+
return (
|
169
|
+
products.length > 0 && (
|
170
|
+
<Section heading={title} padding="n" className={styles}>
|
171
|
+
<ul className="grid gap-8">
|
172
|
+
{products.map((product) => (
|
173
|
+
<li key={product.id}>
|
174
|
+
<ProductOptionsProvider
|
175
|
+
data={product}
|
176
|
+
initialVariantId={product.variants.nodes[0].id}
|
177
|
+
>
|
178
|
+
<RebuyProductCard product={product} />
|
179
|
+
</ProductOptionsProvider>
|
180
|
+
</li>
|
181
|
+
))}
|
182
|
+
</ul>
|
183
|
+
</Section>
|
184
|
+
)
|
185
|
+
);
|
186
|
+
};
|
187
|
+
|
188
|
+
export default RebuyCompleteTheLook;
|
@@ -0,0 +1,415 @@
|
|
1
|
+
import {
|
2
|
+
Image,
|
3
|
+
Link,
|
4
|
+
Money,
|
5
|
+
useCart,
|
6
|
+
useProductOptions,
|
7
|
+
} from '@shopify/hydrogen';
|
8
|
+
import clsx from 'clsx';
|
9
|
+
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
10
|
+
import { Heading, Text } from '~/components';
|
11
|
+
import { Button } from '~/components/elements';
|
12
|
+
|
13
|
+
const isDiscounted = (price, compareAtPrice) =>
|
14
|
+
Number(compareAtPrice?.amount) > Number(price?.amount);
|
15
|
+
|
16
|
+
const CompareAtPrice = ({ data: compareAtPrice, className }) => {
|
17
|
+
const styles = clsx('strike', className);
|
18
|
+
|
19
|
+
return (
|
20
|
+
<Money
|
21
|
+
withoutTrailingZeros
|
22
|
+
data={compareAtPrice}
|
23
|
+
as="span"
|
24
|
+
className={styles}
|
25
|
+
/>
|
26
|
+
);
|
27
|
+
};
|
28
|
+
|
29
|
+
const RebuyProductPrice = ({ selectedVariant = {} }) => {
|
30
|
+
const { priceV2: price, compareAtPriceV2: compareAtPrice } =
|
31
|
+
selectedVariant;
|
32
|
+
|
33
|
+
return (
|
34
|
+
price && (
|
35
|
+
<div className="gap-4">
|
36
|
+
<Text className="flex gap-2">
|
37
|
+
<Money data={price} withoutTrailingZeros as="span" />
|
38
|
+
{isDiscounted(price, compareAtPrice) && (
|
39
|
+
<CompareAtPrice
|
40
|
+
data={compareAtPrice}
|
41
|
+
className={'opacity-50'}
|
42
|
+
/>
|
43
|
+
)}
|
44
|
+
</Text>
|
45
|
+
</div>
|
46
|
+
)
|
47
|
+
);
|
48
|
+
};
|
49
|
+
|
50
|
+
const VariantSelector = ({ product, onSelectVariant, className = '' }) => {
|
51
|
+
const styles = clsx('rounded py-1', className);
|
52
|
+
|
53
|
+
return (
|
54
|
+
product && (
|
55
|
+
<select
|
56
|
+
onChange={(e) => onSelectVariant(product, e.target.value)}
|
57
|
+
aria-label="select variant"
|
58
|
+
className={styles}
|
59
|
+
value={product.selectedVariant.id}
|
60
|
+
>
|
61
|
+
<optgroup label={getOptionsLabel(product)}>
|
62
|
+
{product.variants.nodes.map(({ id, title }) => (
|
63
|
+
<option key={id + '-BundleVariant'} value={id}>
|
64
|
+
{title}
|
65
|
+
</option>
|
66
|
+
))}
|
67
|
+
</optgroup>
|
68
|
+
</select>
|
69
|
+
)
|
70
|
+
);
|
71
|
+
};
|
72
|
+
|
73
|
+
const getOptionsLabel = (product) => {
|
74
|
+
const options = product.variants.nodes[0].selectedOptions;
|
75
|
+
const optionsFromKeys = Object.keys(options[0]);
|
76
|
+
const optionsFromValues = options.map((option) => option.name);
|
77
|
+
const useValues = optionsFromKeys.every((key) =>
|
78
|
+
['name', 'value'].includes(key)
|
79
|
+
);
|
80
|
+
|
81
|
+
// Return delimited label for available option(s) e.g. Color / Size, Scent, etc
|
82
|
+
return (useValues ? optionsFromValues : optionsFromKeys).join(' / ');
|
83
|
+
};
|
84
|
+
|
85
|
+
const AddToCartPriceMarkup = ({ products, addLineItemsToCart }) => {
|
86
|
+
const isDisabled =
|
87
|
+
products.filter((product) => product.selected).length < 1;
|
88
|
+
|
89
|
+
const totalBundlePrice = () => {
|
90
|
+
let total = 0;
|
91
|
+
let currencyCode = 'USD';
|
92
|
+
|
93
|
+
for (const product of products) {
|
94
|
+
if (product.selected && product.selectedVariant) {
|
95
|
+
const { priceV2: price } = product.selectedVariant;
|
96
|
+
|
97
|
+
total += Number(price.amount);
|
98
|
+
currencyCode = price.currencyCode;
|
99
|
+
}
|
100
|
+
}
|
101
|
+
|
102
|
+
return {
|
103
|
+
amount: String(total),
|
104
|
+
currencyCode,
|
105
|
+
};
|
106
|
+
};
|
107
|
+
|
108
|
+
const totalBundleCompareAtPrice = () => {
|
109
|
+
let compareAtTotal = 0;
|
110
|
+
let currencyCode = 'USD';
|
111
|
+
|
112
|
+
for (const product of products) {
|
113
|
+
if (product.selected && product.selectedVariant) {
|
114
|
+
const { priceV2: price, compareAtPriceV2: compareAtPrice } =
|
115
|
+
product.selectedVariant;
|
116
|
+
|
117
|
+
currencyCode = price.currencyCode;
|
118
|
+
compareAtTotal += Number((compareAtPrice || price).amount);
|
119
|
+
}
|
120
|
+
}
|
121
|
+
|
122
|
+
return {
|
123
|
+
amount: String(compareAtTotal),
|
124
|
+
currencyCode,
|
125
|
+
};
|
126
|
+
};
|
127
|
+
|
128
|
+
const price = totalBundlePrice();
|
129
|
+
const compareAtPrice = totalBundleCompareAtPrice();
|
130
|
+
|
131
|
+
return (
|
132
|
+
products.length > 0 && (
|
133
|
+
<div className="flex items-center flex-col">
|
134
|
+
{!isDisabled && (
|
135
|
+
<Text className="flex items-center gap-2 mb-2">
|
136
|
+
<span>Total Price:</span>
|
137
|
+
<Money data={price} withoutTrailingZeros as="span" />
|
138
|
+
{isDiscounted(price, compareAtPrice) && (
|
139
|
+
<CompareAtPrice
|
140
|
+
data={compareAtPrice}
|
141
|
+
className={'opacity-50'}
|
142
|
+
/>
|
143
|
+
)}
|
144
|
+
</Text>
|
145
|
+
)}
|
146
|
+
|
147
|
+
<Button
|
148
|
+
as="button"
|
149
|
+
variant={isDisabled ? 'secondary' : 'primary'}
|
150
|
+
disabled={isDisabled}
|
151
|
+
onClick={addLineItemsToCart}
|
152
|
+
>
|
153
|
+
<Text as="span">
|
154
|
+
{isDisabled ? 'Create your bundle below' : 'Add to bag'}
|
155
|
+
</Text>
|
156
|
+
</Button>
|
157
|
+
</div>
|
158
|
+
)
|
159
|
+
);
|
160
|
+
};
|
161
|
+
|
162
|
+
const BundleImages = ({ products }) => {
|
163
|
+
const selected = products.filter((product) => product.selected);
|
164
|
+
|
165
|
+
return (
|
166
|
+
<ul className="flex flex-nowrap justify-center gap-1 mb-4">
|
167
|
+
{products.map((product, index) => {
|
168
|
+
const image = product.selectedVariant.image;
|
169
|
+
const productImage = image ? (
|
170
|
+
<Image
|
171
|
+
className="fadeIn"
|
172
|
+
data={image}
|
173
|
+
alt={image.altText || `Picture of ${product.title}`}
|
174
|
+
title={product.title}
|
175
|
+
width={80}
|
176
|
+
height={80}
|
177
|
+
loaderOptions={{ scale: 2 }}
|
178
|
+
/>
|
179
|
+
) : (
|
180
|
+
// No image defined
|
181
|
+
product.title
|
182
|
+
);
|
183
|
+
// Hide delimiter for first selected item
|
184
|
+
const showDelimiter = selected[0]?.id !== product.id;
|
185
|
+
|
186
|
+
return (
|
187
|
+
<Fragment key={product.id + '-BundleImages-' + index}>
|
188
|
+
{product.selected && (
|
189
|
+
<>
|
190
|
+
{showDelimiter && (
|
191
|
+
<li className="flex items-center">
|
192
|
+
<span className="font-semibold">+</span>
|
193
|
+
</li>
|
194
|
+
)}
|
195
|
+
<li className="flex items-center">
|
196
|
+
{product.default ? (
|
197
|
+
// Already on product page
|
198
|
+
productImage
|
199
|
+
) : (
|
200
|
+
// Link to product
|
201
|
+
<Link
|
202
|
+
to={`/products/${product.handle}`}
|
203
|
+
title={product.title}
|
204
|
+
>
|
205
|
+
{productImage}
|
206
|
+
</Link>
|
207
|
+
)}
|
208
|
+
</li>
|
209
|
+
</>
|
210
|
+
)}
|
211
|
+
</Fragment>
|
212
|
+
);
|
213
|
+
})}
|
214
|
+
</ul>
|
215
|
+
);
|
216
|
+
};
|
217
|
+
|
218
|
+
const BundleSelection = ({ products, onToggleBundleItem, onSelectVariant }) => {
|
219
|
+
return (
|
220
|
+
<ul>
|
221
|
+
{products.map((product, index) => {
|
222
|
+
const bundleItemRowStyle = clsx(
|
223
|
+
'mb-4',
|
224
|
+
!product.selected ? 'opacity-50' : ''
|
225
|
+
);
|
226
|
+
const bundleItemLabelStyle = clsx(
|
227
|
+
'flex flex-row gap-2 mb-1 cursor-pointer'
|
228
|
+
);
|
229
|
+
const { availableForSale } = product.selectedVariant;
|
230
|
+
const isOutOfStock = !availableForSale;
|
231
|
+
|
232
|
+
return (
|
233
|
+
<li
|
234
|
+
key={product.id + '-BundleSelection' + index}
|
235
|
+
className={bundleItemRowStyle}
|
236
|
+
>
|
237
|
+
<div className="flex flex-row items-start gap-2">
|
238
|
+
<input
|
239
|
+
className="mt-1 rounded-sm accent-black cursor-pointer"
|
240
|
+
type="checkbox"
|
241
|
+
value={product.id}
|
242
|
+
id={`${product.id}-toggle`}
|
243
|
+
checked={product.selected && availableForSale}
|
244
|
+
disabled={isOutOfStock}
|
245
|
+
onChange={() => onToggleBundleItem(product)}
|
246
|
+
/>
|
247
|
+
<div className="flex-grow">
|
248
|
+
<label
|
249
|
+
htmlFor={`${product.id}-toggle`}
|
250
|
+
className={bundleItemLabelStyle}
|
251
|
+
>
|
252
|
+
{isOutOfStock && <b>SOLD OUT</b>}
|
253
|
+
{product.default && <b>This item:</b>}
|
254
|
+
{product.title}
|
255
|
+
|
256
|
+
<RebuyProductPrice
|
257
|
+
selectedVariant={
|
258
|
+
product.selectedVariant
|
259
|
+
}
|
260
|
+
/>
|
261
|
+
</label>
|
262
|
+
|
263
|
+
{product.variants?.nodes.length > 1 && (
|
264
|
+
<VariantSelector
|
265
|
+
product={product}
|
266
|
+
onSelectVariant={onSelectVariant}
|
267
|
+
className="w-full"
|
268
|
+
/>
|
269
|
+
)}
|
270
|
+
</div>
|
271
|
+
</div>
|
272
|
+
</li>
|
273
|
+
);
|
274
|
+
})}
|
275
|
+
</ul>
|
276
|
+
);
|
277
|
+
};
|
278
|
+
|
279
|
+
export const RebuyDynamicBundleProducts = ({
|
280
|
+
product = {},
|
281
|
+
products = [],
|
282
|
+
metadata = {},
|
283
|
+
title = 'Frequently purchased together',
|
284
|
+
className = '',
|
285
|
+
}) => {
|
286
|
+
// Hooks
|
287
|
+
const { linesAdd } = useCart();
|
288
|
+
const { selectedVariant } = useProductOptions();
|
289
|
+
|
290
|
+
// State
|
291
|
+
const [bundleProducts, setBundleProduct] = useState([]);
|
292
|
+
const language = metadata.widget?.language ?? {};
|
293
|
+
const componentTitle = language?.title || title;
|
294
|
+
const styles = clsx('grid gap-4 py-6 md:py-8 lg:py-12', className);
|
295
|
+
|
296
|
+
const rebuyAttributes = useMemo(() => {
|
297
|
+
return [
|
298
|
+
{ key: '_source', value: 'Rebuy' },
|
299
|
+
{
|
300
|
+
key: '_attribution',
|
301
|
+
value: 'Rebuy Dynamic Bundle Recommendations',
|
302
|
+
},
|
303
|
+
];
|
304
|
+
}, []);
|
305
|
+
|
306
|
+
// Initializiation
|
307
|
+
useEffect(() => {
|
308
|
+
const formatProducts = (products = []) => {
|
309
|
+
for (let product of products) {
|
310
|
+
product.selectedVariant = { ...product.variants.nodes[0] };
|
311
|
+
product.selected = product.selectedVariant.availableForSale;
|
312
|
+
}
|
313
|
+
|
314
|
+
return products;
|
315
|
+
};
|
316
|
+
|
317
|
+
// Flag the main product (PDP)
|
318
|
+
product.default = true;
|
319
|
+
|
320
|
+
setBundleProduct(formatProducts([product, ...products]));
|
321
|
+
}, [product, products]);
|
322
|
+
|
323
|
+
// Mirror selectedVariant from PDP + allow override
|
324
|
+
useEffect(() => {
|
325
|
+
const bundleWithVariantMatch = (currentBundle) => {
|
326
|
+
for (let product of currentBundle) {
|
327
|
+
if (product.default) {
|
328
|
+
product.selectedVariant = selectedVariant;
|
329
|
+
product.selected = product.selectedVariant.availableForSale;
|
330
|
+
break;
|
331
|
+
}
|
332
|
+
}
|
333
|
+
|
334
|
+
return [...currentBundle];
|
335
|
+
};
|
336
|
+
|
337
|
+
setBundleProduct(bundleWithVariantMatch);
|
338
|
+
}, [selectedVariant]);
|
339
|
+
|
340
|
+
const onToggleBundleItem = useCallback(
|
341
|
+
(product) => {
|
342
|
+
product.selected = !product.selected;
|
343
|
+
|
344
|
+
setBundleProduct([...bundleProducts]);
|
345
|
+
},
|
346
|
+
[bundleProducts]
|
347
|
+
);
|
348
|
+
|
349
|
+
const onSelectVariant = useCallback(
|
350
|
+
(product, variantId) => {
|
351
|
+
const variant = product.variants.nodes.find(
|
352
|
+
({ id }) => id === variantId
|
353
|
+
);
|
354
|
+
|
355
|
+
if (variant) {
|
356
|
+
product.selectedVariant = variant;
|
357
|
+
product.selected = variant.availableForSale;
|
358
|
+
|
359
|
+
setBundleProduct([...bundleProducts]);
|
360
|
+
}
|
361
|
+
},
|
362
|
+
[bundleProducts]
|
363
|
+
);
|
364
|
+
|
365
|
+
const addLineItemsToCart = useCallback(() => {
|
366
|
+
const itemsToAdd = [];
|
367
|
+
|
368
|
+
for (let product of bundleProducts) {
|
369
|
+
if (product.selected && product.selectedVariant) {
|
370
|
+
itemsToAdd.push({
|
371
|
+
quantity: 1,
|
372
|
+
merchandiseId: product.selectedVariant.id,
|
373
|
+
attributes: rebuyAttributes,
|
374
|
+
});
|
375
|
+
}
|
376
|
+
}
|
377
|
+
|
378
|
+
linesAdd(itemsToAdd);
|
379
|
+
}, [bundleProducts, linesAdd, rebuyAttributes]);
|
380
|
+
|
381
|
+
return (
|
382
|
+
bundleProducts.length > 0 && (
|
383
|
+
<div className={styles}>
|
384
|
+
{componentTitle && (
|
385
|
+
<Heading
|
386
|
+
size="lead"
|
387
|
+
width="full"
|
388
|
+
className="px-6 md:px-8 lg:px-12"
|
389
|
+
>
|
390
|
+
{componentTitle}
|
391
|
+
</Heading>
|
392
|
+
)}
|
393
|
+
|
394
|
+
<div className="px-6 md:px-8 lg:px-12">
|
395
|
+
<div className="flex flex-col lg:flex-row mb-4">
|
396
|
+
<BundleImages products={bundleProducts} />
|
397
|
+
|
398
|
+
<AddToCartPriceMarkup
|
399
|
+
addLineItemsToCart={addLineItemsToCart}
|
400
|
+
products={bundleProducts}
|
401
|
+
/>
|
402
|
+
</div>
|
403
|
+
|
404
|
+
<BundleSelection
|
405
|
+
products={bundleProducts}
|
406
|
+
onToggleBundleItem={onToggleBundleItem}
|
407
|
+
onSelectVariant={onSelectVariant}
|
408
|
+
/>
|
409
|
+
</div>
|
410
|
+
</div>
|
411
|
+
)
|
412
|
+
);
|
413
|
+
};
|
414
|
+
|
415
|
+
export default RebuyDynamicBundleProducts;
|
@@ -0,0 +1,89 @@
|
|
1
|
+
import { Image, Link, Money } from '@shopify/hydrogen';
|
2
|
+
import clsx from 'clsx';
|
3
|
+
import { Text } from '~/components';
|
4
|
+
|
5
|
+
const isDiscounted = (price, compareAtPrice) =>
|
6
|
+
Number(compareAtPrice?.amount) > Number(price?.amount);
|
7
|
+
|
8
|
+
const CompareAtPrice = ({ data: compareAtPrice, className }) => {
|
9
|
+
const styles = clsx('strike', className);
|
10
|
+
|
11
|
+
return (
|
12
|
+
<Money
|
13
|
+
withoutTrailingZeros
|
14
|
+
data={compareAtPrice}
|
15
|
+
as="span"
|
16
|
+
className={styles}
|
17
|
+
/>
|
18
|
+
);
|
19
|
+
};
|
20
|
+
|
21
|
+
const RebuyProductPrice = ({ selectedVariant = {} }) => {
|
22
|
+
const { priceV2: price, compareAtPriceV2: compareAtPrice } =
|
23
|
+
selectedVariant;
|
24
|
+
|
25
|
+
return (
|
26
|
+
price && (
|
27
|
+
<div className="gap-4">
|
28
|
+
<Text className="flex gap-2">
|
29
|
+
<Money data={price} withoutTrailingZeros as="span" />
|
30
|
+
{isDiscounted(price, compareAtPrice) && (
|
31
|
+
<CompareAtPrice
|
32
|
+
data={compareAtPrice}
|
33
|
+
className={'opacity-50'}
|
34
|
+
/>
|
35
|
+
)}
|
36
|
+
</Text>
|
37
|
+
</div>
|
38
|
+
)
|
39
|
+
);
|
40
|
+
};
|
41
|
+
|
42
|
+
export const RebuyProductAddOnCard = ({
|
43
|
+
product = {},
|
44
|
+
selectedVariant = product.variants.nodes[0],
|
45
|
+
}) => {
|
46
|
+
if (!selectedVariant) {
|
47
|
+
return null;
|
48
|
+
}
|
49
|
+
|
50
|
+
const { image, availableForSale } = selectedVariant;
|
51
|
+
const isOutOfStock = !availableForSale;
|
52
|
+
|
53
|
+
return (
|
54
|
+
<div className="mb-4 flex">
|
55
|
+
<div className="flex items-center justify-center overflow-hidden mx-auto">
|
56
|
+
{image && (
|
57
|
+
<Image
|
58
|
+
className="fadeIn"
|
59
|
+
data={image}
|
60
|
+
alt={image.altText || `Picture of ${product.title}`}
|
61
|
+
width={80}
|
62
|
+
height={80}
|
63
|
+
loaderOptions={{ scale: 2 }}
|
64
|
+
/>
|
65
|
+
)}
|
66
|
+
{isOutOfStock && (
|
67
|
+
<div className="absolute top-3 left-3 rounded text-xs bg-primary/60 text-contrast py-3 px-4">
|
68
|
+
<Text>Out of stock</Text>
|
69
|
+
</div>
|
70
|
+
)}
|
71
|
+
</div>
|
72
|
+
|
73
|
+
<div className="grid grid-rows-3 ml-3">
|
74
|
+
<Text className="font-medium">{product.title}</Text>
|
75
|
+
|
76
|
+
<RebuyProductPrice selectedVariant={selectedVariant} />
|
77
|
+
|
78
|
+
<Link
|
79
|
+
to={`/products/${product?.handle}`}
|
80
|
+
className="text-xs underline text-primary/50 hover:underline hover:text-primary"
|
81
|
+
>
|
82
|
+
Learn More
|
83
|
+
</Link>
|
84
|
+
</div>
|
85
|
+
</div>
|
86
|
+
);
|
87
|
+
};
|
88
|
+
|
89
|
+
export default RebuyProductAddOnCard;
|
@@ -0,0 +1,227 @@
|
|
1
|
+
import {
|
2
|
+
Money,
|
3
|
+
ProductOptionsProvider,
|
4
|
+
useCart,
|
5
|
+
useProductOptions,
|
6
|
+
} from '@shopify/hydrogen';
|
7
|
+
import clsx from 'clsx';
|
8
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
9
|
+
import { Heading, Text } from '~/components';
|
10
|
+
import { Button } from '~/components/elements';
|
11
|
+
import { RebuyProductAddOnCard } from './RebuyProductAddOnCard.client';
|
12
|
+
|
13
|
+
export const RebuyProductAddOns = ({
|
14
|
+
product = {},
|
15
|
+
products = [],
|
16
|
+
metadata = {},
|
17
|
+
title = `Popular Add-Ons for ${product.title}`,
|
18
|
+
className = '',
|
19
|
+
}) => {
|
20
|
+
// Hooks
|
21
|
+
const { linesAdd } = useCart();
|
22
|
+
const { selectedVariant } = useProductOptions();
|
23
|
+
|
24
|
+
// State
|
25
|
+
const [addedItems, setAddedItems] = useState([]);
|
26
|
+
const [totalMoney, _setTotalMoney] = useState(selectedVariant.priceV2);
|
27
|
+
const isOutOfStock = !selectedVariant.availableForSale;
|
28
|
+
const language = metadata.widget?.language ?? {};
|
29
|
+
const componentTitle = language?.title || title;
|
30
|
+
const styles = clsx('grid gap-4 py-6 md:py-8 lg:py-12', className);
|
31
|
+
|
32
|
+
const AddToCartMarkup = ({ items, money, selectedVariant }) => {
|
33
|
+
return (
|
34
|
+
<>
|
35
|
+
<Button
|
36
|
+
onClick={() => addLineItemsToCart(items, selectedVariant)}
|
37
|
+
variant="primary"
|
38
|
+
as="button"
|
39
|
+
>
|
40
|
+
<Text
|
41
|
+
as="span"
|
42
|
+
className="flex items-center justify-center gap-2"
|
43
|
+
>
|
44
|
+
<span>Add to bag</span> <span>·</span>{' '}
|
45
|
+
<Money withoutTrailingZeros data={money} />
|
46
|
+
</Text>
|
47
|
+
</Button>
|
48
|
+
</>
|
49
|
+
);
|
50
|
+
};
|
51
|
+
|
52
|
+
const addLineItemsToCart = useCallback(
|
53
|
+
(items, selectedVariant) => {
|
54
|
+
const lineItemsToAdd = items.map((item) => ({
|
55
|
+
quantity: 1,
|
56
|
+
merchandiseId: item.variantId,
|
57
|
+
attributes: [
|
58
|
+
{ key: '_source', value: 'Rebuy' },
|
59
|
+
{ key: '_attribution', value: 'Rebuy Product AddOn' },
|
60
|
+
],
|
61
|
+
}));
|
62
|
+
|
63
|
+
// Add the selected variant of the main product
|
64
|
+
lineItemsToAdd.push({
|
65
|
+
quantity: 1,
|
66
|
+
merchandiseId: selectedVariant.id,
|
67
|
+
});
|
68
|
+
|
69
|
+
// Add additional cart lines
|
70
|
+
linesAdd(lineItemsToAdd);
|
71
|
+
|
72
|
+
return;
|
73
|
+
},
|
74
|
+
[linesAdd]
|
75
|
+
);
|
76
|
+
|
77
|
+
// reference object for line data
|
78
|
+
const refItems = useMemo(() => {
|
79
|
+
const items = {};
|
80
|
+
|
81
|
+
products.forEach((product) => {
|
82
|
+
// NOTE: More work must be done if the add-ons will have variant selectors of their own
|
83
|
+
const singleVariant = product.variants?.nodes[0];
|
84
|
+
|
85
|
+
items[product.id] = {
|
86
|
+
id: product.id,
|
87
|
+
variantId: singleVariant.id,
|
88
|
+
moneyPrice: singleVariant.priceV2, // full MoneyV2 object
|
89
|
+
};
|
90
|
+
});
|
91
|
+
|
92
|
+
return items;
|
93
|
+
}, [products]);
|
94
|
+
|
95
|
+
const setTotalMoney = useCallback(
|
96
|
+
(items) => {
|
97
|
+
// get base product price
|
98
|
+
const newTotalMoney = { ...selectedVariant.priceV2 };
|
99
|
+
|
100
|
+
if (items.length === 0) {
|
101
|
+
_setTotalMoney(newTotalMoney);
|
102
|
+
return;
|
103
|
+
}
|
104
|
+
|
105
|
+
// cast amount to number for calculation
|
106
|
+
let totalAmount = Number(newTotalMoney.amount);
|
107
|
+
// sum up the prices of the added items
|
108
|
+
items.forEach((item) => {
|
109
|
+
totalAmount += Number(item.moneyPrice.amount);
|
110
|
+
});
|
111
|
+
|
112
|
+
// cast amount back to string for MoneyV2
|
113
|
+
newTotalMoney.amount = String(totalAmount);
|
114
|
+
|
115
|
+
_setTotalMoney(newTotalMoney);
|
116
|
+
return;
|
117
|
+
},
|
118
|
+
[selectedVariant]
|
119
|
+
);
|
120
|
+
|
121
|
+
// listen for selected variant change from parent
|
122
|
+
useEffect(() => {
|
123
|
+
setTotalMoney(addedItems);
|
124
|
+
}, [addedItems, setTotalMoney]);
|
125
|
+
|
126
|
+
const handleChange = useCallback(
|
127
|
+
(event, product) => {
|
128
|
+
// const mainPrice = Number(selectedVariant.priceV2.amount);
|
129
|
+
let newAddedItems = [...addedItems];
|
130
|
+
if (event.target.checked) {
|
131
|
+
newAddedItems = [...addedItems, refItems[product.id]];
|
132
|
+
} else {
|
133
|
+
newAddedItems = addedItems.filter(
|
134
|
+
(item) => item.id !== product.id
|
135
|
+
);
|
136
|
+
}
|
137
|
+
|
138
|
+
setAddedItems(newAddedItems);
|
139
|
+
setTotalMoney(newAddedItems);
|
140
|
+
},
|
141
|
+
[addedItems, refItems, setTotalMoney]
|
142
|
+
);
|
143
|
+
|
144
|
+
return (
|
145
|
+
/* Render Product AddOns with AddToCart Button */
|
146
|
+
product &&
|
147
|
+
products.length > 0 &&
|
148
|
+
!isOutOfStock && (
|
149
|
+
<div className={styles}>
|
150
|
+
<Heading size="lead" className="px-6 md:px-8 lg:px-12">
|
151
|
+
{componentTitle}
|
152
|
+
</Heading>
|
153
|
+
{/* Product Add-Ons */}
|
154
|
+
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 px-6 md:px-8 lg:px-12">
|
155
|
+
{products.map((product) => (
|
156
|
+
<li key={product.id}>
|
157
|
+
<ProductOptionsProvider data={product}>
|
158
|
+
<div className="mt-2">
|
159
|
+
<label
|
160
|
+
htmlFor={product.id}
|
161
|
+
className="cursor-pointer flex items-center hover:bg-slate-200 p-2 gap-2"
|
162
|
+
>
|
163
|
+
<input
|
164
|
+
type="checkbox"
|
165
|
+
value=""
|
166
|
+
disabled={
|
167
|
+
!product.variants.nodes[0]
|
168
|
+
.availableForSale
|
169
|
+
}
|
170
|
+
name={product.title}
|
171
|
+
id={product.id}
|
172
|
+
checked={
|
173
|
+
addedItems.filter(
|
174
|
+
(item) =>
|
175
|
+
item.id === product.id
|
176
|
+
).length > 0
|
177
|
+
}
|
178
|
+
className="accent-black rounded-sm cursor-pointer"
|
179
|
+
onChange={(event) =>
|
180
|
+
handleChange(event, product)
|
181
|
+
}
|
182
|
+
/>
|
183
|
+
<RebuyProductAddOnCard
|
184
|
+
product={product}
|
185
|
+
/>
|
186
|
+
</label>
|
187
|
+
</div>
|
188
|
+
</ProductOptionsProvider>
|
189
|
+
</li>
|
190
|
+
))}
|
191
|
+
</ul>
|
192
|
+
|
193
|
+
<div className="px-6 md:px-8 lg:px-12">
|
194
|
+
{/* Subtotal */}
|
195
|
+
{Number(totalMoney.amount) >
|
196
|
+
Number(selectedVariant.priceV2.amount) && (
|
197
|
+
<Text
|
198
|
+
as="div"
|
199
|
+
className="flex gap-1 pb-4 text-gray-600"
|
200
|
+
>
|
201
|
+
Subtotal:{' '}
|
202
|
+
<Money
|
203
|
+
data={{
|
204
|
+
...totalMoney,
|
205
|
+
amount: String(
|
206
|
+
totalMoney.amount -
|
207
|
+
selectedVariant.priceV2.amount
|
208
|
+
),
|
209
|
+
}}
|
210
|
+
withoutTrailingZeros
|
211
|
+
/>
|
212
|
+
</Text>
|
213
|
+
)}
|
214
|
+
|
215
|
+
{/* AddToCart Button */}
|
216
|
+
<AddToCartMarkup
|
217
|
+
items={addedItems}
|
218
|
+
money={totalMoney}
|
219
|
+
selectedVariant={selectedVariant}
|
220
|
+
/>
|
221
|
+
</div>
|
222
|
+
</div>
|
223
|
+
)
|
224
|
+
);
|
225
|
+
};
|
226
|
+
|
227
|
+
export default RebuyProductAddOns;
|
@@ -8,7 +8,7 @@ const AddToCartMarkup = ({ product }) => {
|
|
8
8
|
const isOutOfStock = !selectedVariant.availableForSale;
|
9
9
|
|
10
10
|
return (
|
11
|
-
<div className="
|
11
|
+
<div className="py-2">
|
12
12
|
<AddToCartButton
|
13
13
|
disabled={isOutOfStock}
|
14
14
|
product={product}
|
@@ -18,7 +18,10 @@ const AddToCartMarkup = ({ product }) => {
|
|
18
18
|
type="button"
|
19
19
|
attributes={[
|
20
20
|
{ key: '_source', value: 'Rebuy' },
|
21
|
-
{
|
21
|
+
{
|
22
|
+
key: '_attribution',
|
23
|
+
value: 'Rebuy Product Recommendations',
|
24
|
+
},
|
22
25
|
]}
|
23
26
|
>
|
24
27
|
<Button
|
@@ -33,9 +36,11 @@ const AddToCartMarkup = ({ product }) => {
|
|
33
36
|
);
|
34
37
|
};
|
35
38
|
|
36
|
-
export const RebuyProductRecommendations = ({
|
37
|
-
|
38
|
-
|
39
|
+
export const RebuyProductRecommendations = ({
|
40
|
+
product,
|
41
|
+
products,
|
42
|
+
title = `Like ${product.title}? You may also like:`,
|
43
|
+
}) => {
|
39
44
|
return (
|
40
45
|
products.length > 0 && (
|
41
46
|
<Section heading={title} padding="y">
|
@@ -8,7 +8,7 @@ const AddToCartMarkup = ({ product }) => {
|
|
8
8
|
const isOutOfStock = !selectedVariant.availableForSale;
|
9
9
|
|
10
10
|
return (
|
11
|
-
<div className="
|
11
|
+
<div className="py-2">
|
12
12
|
<AddToCartButton
|
13
13
|
disabled={isOutOfStock}
|
14
14
|
product={product}
|
@@ -18,7 +18,10 @@ const AddToCartMarkup = ({ product }) => {
|
|
18
18
|
type="button"
|
19
19
|
attributes={[
|
20
20
|
{ key: '_source', value: 'Rebuy' },
|
21
|
-
{
|
21
|
+
{
|
22
|
+
key: '_attribution',
|
23
|
+
value: 'Rebuy Recently Viewed Product',
|
24
|
+
},
|
22
25
|
]}
|
23
26
|
>
|
24
27
|
<Button
|
@@ -22,7 +22,7 @@ export const RebuyWidgetContainer = ({ children, ...props }) => {
|
|
22
22
|
const cart = useCart();
|
23
23
|
const { contextParameters } = useContext(RebuyContext);
|
24
24
|
const [products, setProducts] = useState([]);
|
25
|
-
const [metadata, setMetadata] = useState(
|
25
|
+
const [metadata, setMetadata] = useState({});
|
26
26
|
const [Rebuy, setRebuy] = useState(null);
|
27
27
|
const [initialized, setInitialized] = useState(false);
|
28
28
|
const isCartReady = ['uninitialized', 'idle'].includes(cart.status); // uninitialized, creating, fetching, updating, idle
|
@@ -128,7 +128,7 @@ export const RebuyWidgetContainer = ({ children, ...props }) => {
|
|
128
128
|
: child
|
129
129
|
);
|
130
130
|
|
131
|
-
const childProps = { ...props, products, metadata };
|
131
|
+
const childProps = { ...props, products, metadata, key: product.id };
|
132
132
|
|
133
133
|
return childrenWithProps(childProps);
|
134
134
|
};
|