@rebuy/rebuy-hydrogen 2.1.0 → 2.3.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/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Rebuy + Hydrogen package is a web development framework used for building Shopify custom storefronts. It includes providers, components, and tooling you need to get started so you can spend your time creating intelligent shopping experiences.
4
4
 
5
+ ## Rebuy + Hydrogen 2
6
+
7
+ Rebuy makes it incredibly easy to bring powerful personalized product recommendations and smart search to your Shopify Hydrogen 2.0 Storefront. For more details on this, please check out our [Rebuy + Shopify Hydrogen 2.0 documentation](https://developers.rebuyengine.com/reference/rebuy-shopify-hydrogen-2).
8
+
9
+ Components and more coming soon.
10
+
5
11
  ## How Rebuy + Hydrogen Works
6
12
 
7
13
  Rebuy + Hydrogen is a lightweight framework for creating personalized shopping experiences that are lightening fast. The framework is composed of:
@@ -4,25 +4,25 @@ import {
4
4
  Link,
5
5
  Money,
6
6
  ProductOptionsProvider,
7
- useMoney,
8
7
  } from '@shopify/hydrogen';
9
8
  import clsx from 'clsx';
10
9
  import { useState } from 'react';
11
10
  import { Section, Text } from '~/components';
12
11
  import { Button } from '~/components/elements';
13
- import { isDiscounted } from '~/lib/utils';
14
12
 
15
- const CompareAtPrice = ({ data, className }) => {
16
- const { currencyNarrowSymbol, withoutTrailingZerosAndCurrency } =
17
- useMoney(data);
13
+ const isDiscounted = (price, compareAtPrice) =>
14
+ Number(compareAtPrice?.amount) > Number(price?.amount);
18
15
 
16
+ const CompareAtPrice = ({ data: compareAtPrice, className }) => {
19
17
  const styles = clsx('strike', className);
20
18
 
21
19
  return (
22
- <span className={styles}>
23
- {currencyNarrowSymbol}
24
- {withoutTrailingZerosAndCurrency}
25
- </span>
20
+ <Money
21
+ withoutTrailingZeros
22
+ data={compareAtPrice}
23
+ as="span"
24
+ className={styles}
25
+ />
26
26
  );
27
27
  };
28
28
 
@@ -41,7 +41,7 @@ const AddToCartMarkup = ({
41
41
  type="button"
42
42
  attributes={[
43
43
  { key: '_source', value: 'Rebuy' },
44
- { key: '_attribution', value: 'Product Recommendations' },
44
+ { key: '_attribution', value: 'Rebuy Product Recommendations' },
45
45
  ]}
46
46
  >
47
47
  <Button
@@ -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;
@@ -1,7 +1,9 @@
1
1
  import { Image, Link, Money } from '@shopify/hydrogen';
2
2
  import clsx from 'clsx';
3
3
  import { Text } from '~/components';
4
- import { isDiscounted } from '~/lib/utils';
4
+
5
+ const isDiscounted = (price, compareAtPrice) =>
6
+ Number(compareAtPrice?.amount) > Number(price?.amount);
5
7
 
6
8
  const CompareAtPrice = ({ data: compareAtPrice, className }) => {
7
9
  const styles = clsx('strike', className);
@@ -14,7 +14,7 @@ export const RebuyProductAddOns = ({
14
14
  product = {},
15
15
  products = [],
16
16
  metadata = {},
17
- title = '',
17
+ title = `Popular Add-Ons for ${product.title}`,
18
18
  className = '',
19
19
  }) => {
20
20
  // Hooks
@@ -26,8 +26,7 @@ export const RebuyProductAddOns = ({
26
26
  const [totalMoney, _setTotalMoney] = useState(selectedVariant.priceV2);
27
27
  const isOutOfStock = !selectedVariant.availableForSale;
28
28
  const language = metadata.widget?.language ?? {};
29
- const componentTitle =
30
- title ?? language?.title ?? `Popular Add-Ons for ${product.title}`;
29
+ const componentTitle = language?.title || title;
31
30
  const styles = clsx('grid gap-4 py-6 md:py-8 lg:py-12', className);
32
31
 
33
32
  const AddToCartMarkup = ({ items, money, selectedVariant }) => {
@@ -57,7 +56,7 @@ export const RebuyProductAddOns = ({
57
56
  merchandiseId: item.variantId,
58
57
  attributes: [
59
58
  { key: '_source', value: 'Rebuy' },
60
- { key: '_attribution', value: 'Product AddOn' },
59
+ { key: '_attribution', value: 'Rebuy Product AddOn' },
61
60
  ],
62
61
  }));
63
62
 
@@ -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
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.1.0",
4
+ "version": "2.3.0",
5
5
  "license": "MIT",
6
6
  "author": "Rebuy, Inc.",
7
7
  "type": "module",