@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.
@@ -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="space-y-2 mb-8">
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
- { key: '_attribution', value: 'Product Recommendations' },
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 = ({ product, products }) => {
37
- const title = `Like ${product.title}? You may also like:`;
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="space-y-2 mb-8">
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
- { key: '_attribution', value: 'Recently Viewed Product' },
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
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebuy/rebuy-hydrogen",
3
3
  "description": "This is the default library for Rebuy + Shopify Hydrogen",
4
- "version": "2.0.0",
4
+ "version": "2.2.0",
5
5
  "license": "MIT",
6
6
  "author": "Rebuy, Inc.",
7
7
  "type": "module",