@rebuy/rebuy-hydrogen 2.0.0 → 2.1.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
+ useMoney,
8
+ } from '@shopify/hydrogen';
9
+ import clsx from 'clsx';
10
+ import { useState } from 'react';
11
+ import { Section, Text } from '~/components';
12
+ import { Button } from '~/components/elements';
13
+ import { isDiscounted } from '~/lib/utils';
14
+
15
+ const CompareAtPrice = ({ data, className }) => {
16
+ const { currencyNarrowSymbol, withoutTrailingZerosAndCurrency } =
17
+ useMoney(data);
18
+
19
+ const styles = clsx('strike', className);
20
+
21
+ return (
22
+ <span className={styles}>
23
+ {currencyNarrowSymbol}
24
+ {withoutTrailingZerosAndCurrency}
25
+ </span>
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: '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,87 @@
1
+ import { Image, Link, Money } from '@shopify/hydrogen';
2
+ import clsx from 'clsx';
3
+ import { Text } from '~/components';
4
+ import { isDiscounted } from '~/lib/utils';
5
+
6
+ const CompareAtPrice = ({ data: compareAtPrice, className }) => {
7
+ const styles = clsx('strike', className);
8
+
9
+ return (
10
+ <Money
11
+ withoutTrailingZeros
12
+ data={compareAtPrice}
13
+ as="span"
14
+ className={styles}
15
+ />
16
+ );
17
+ };
18
+
19
+ const RebuyProductPrice = ({ selectedVariant = {} }) => {
20
+ const { priceV2: price, compareAtPriceV2: compareAtPrice } =
21
+ selectedVariant;
22
+
23
+ return (
24
+ price && (
25
+ <div className="gap-4">
26
+ <Text className="flex gap-2">
27
+ <Money data={price} withoutTrailingZeros as="span" />
28
+ {isDiscounted(price, compareAtPrice) && (
29
+ <CompareAtPrice
30
+ data={compareAtPrice}
31
+ className={'opacity-50'}
32
+ />
33
+ )}
34
+ </Text>
35
+ </div>
36
+ )
37
+ );
38
+ };
39
+
40
+ export const RebuyProductAddOnCard = ({
41
+ product = {},
42
+ selectedVariant = product.variants.nodes[0],
43
+ }) => {
44
+ if (!selectedVariant) {
45
+ return null;
46
+ }
47
+
48
+ const { image, availableForSale } = selectedVariant;
49
+ const isOutOfStock = !availableForSale;
50
+
51
+ return (
52
+ <div className="mb-4 flex">
53
+ <div className="flex items-center justify-center overflow-hidden mx-auto">
54
+ {image && (
55
+ <Image
56
+ className="fadeIn"
57
+ data={image}
58
+ alt={image.altText || `Picture of ${product.title}`}
59
+ width={80}
60
+ height={80}
61
+ loaderOptions={{ scale: 2 }}
62
+ />
63
+ )}
64
+ {isOutOfStock && (
65
+ <div className="absolute top-3 left-3 rounded text-xs bg-primary/60 text-contrast py-3 px-4">
66
+ <Text>Out of stock</Text>
67
+ </div>
68
+ )}
69
+ </div>
70
+
71
+ <div className="grid grid-rows-3 ml-3">
72
+ <Text className="font-medium">{product.title}</Text>
73
+
74
+ <RebuyProductPrice selectedVariant={selectedVariant} />
75
+
76
+ <Link
77
+ to={`/products/${product?.handle}`}
78
+ className="text-xs underline text-primary/50 hover:underline hover:text-primary"
79
+ >
80
+ Learn More
81
+ </Link>
82
+ </div>
83
+ </div>
84
+ );
85
+ };
86
+
87
+ export default RebuyProductAddOnCard;
@@ -0,0 +1,228 @@
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 = '',
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 =
30
+ title ?? language?.title ?? `Popular Add-Ons for ${product.title}`;
31
+ const styles = clsx('grid gap-4 py-6 md:py-8 lg:py-12', className);
32
+
33
+ const AddToCartMarkup = ({ items, money, selectedVariant }) => {
34
+ return (
35
+ <>
36
+ <Button
37
+ onClick={() => addLineItemsToCart(items, selectedVariant)}
38
+ variant="primary"
39
+ as="button"
40
+ >
41
+ <Text
42
+ as="span"
43
+ className="flex items-center justify-center gap-2"
44
+ >
45
+ <span>Add to bag</span> <span>·</span>{' '}
46
+ <Money withoutTrailingZeros data={money} />
47
+ </Text>
48
+ </Button>
49
+ </>
50
+ );
51
+ };
52
+
53
+ const addLineItemsToCart = useCallback(
54
+ (items, selectedVariant) => {
55
+ const lineItemsToAdd = items.map((item) => ({
56
+ quantity: 1,
57
+ merchandiseId: item.variantId,
58
+ attributes: [
59
+ { key: '_source', value: 'Rebuy' },
60
+ { key: '_attribution', value: 'Product AddOn' },
61
+ ],
62
+ }));
63
+
64
+ // Add the selected variant of the main product
65
+ lineItemsToAdd.push({
66
+ quantity: 1,
67
+ merchandiseId: selectedVariant.id,
68
+ });
69
+
70
+ // Add additional cart lines
71
+ linesAdd(lineItemsToAdd);
72
+
73
+ return;
74
+ },
75
+ [linesAdd]
76
+ );
77
+
78
+ // reference object for line data
79
+ const refItems = useMemo(() => {
80
+ const items = {};
81
+
82
+ products.forEach((product) => {
83
+ // NOTE: More work must be done if the add-ons will have variant selectors of their own
84
+ const singleVariant = product.variants?.nodes[0];
85
+
86
+ items[product.id] = {
87
+ id: product.id,
88
+ variantId: singleVariant.id,
89
+ moneyPrice: singleVariant.priceV2, // full MoneyV2 object
90
+ };
91
+ });
92
+
93
+ return items;
94
+ }, [products]);
95
+
96
+ const setTotalMoney = useCallback(
97
+ (items) => {
98
+ // get base product price
99
+ const newTotalMoney = { ...selectedVariant.priceV2 };
100
+
101
+ if (items.length === 0) {
102
+ _setTotalMoney(newTotalMoney);
103
+ return;
104
+ }
105
+
106
+ // cast amount to number for calculation
107
+ let totalAmount = Number(newTotalMoney.amount);
108
+ // sum up the prices of the added items
109
+ items.forEach((item) => {
110
+ totalAmount += Number(item.moneyPrice.amount);
111
+ });
112
+
113
+ // cast amount back to string for MoneyV2
114
+ newTotalMoney.amount = String(totalAmount);
115
+
116
+ _setTotalMoney(newTotalMoney);
117
+ return;
118
+ },
119
+ [selectedVariant]
120
+ );
121
+
122
+ // listen for selected variant change from parent
123
+ useEffect(() => {
124
+ setTotalMoney(addedItems);
125
+ }, [addedItems, setTotalMoney]);
126
+
127
+ const handleChange = useCallback(
128
+ (event, product) => {
129
+ // const mainPrice = Number(selectedVariant.priceV2.amount);
130
+ let newAddedItems = [...addedItems];
131
+ if (event.target.checked) {
132
+ newAddedItems = [...addedItems, refItems[product.id]];
133
+ } else {
134
+ newAddedItems = addedItems.filter(
135
+ (item) => item.id !== product.id
136
+ );
137
+ }
138
+
139
+ setAddedItems(newAddedItems);
140
+ setTotalMoney(newAddedItems);
141
+ },
142
+ [addedItems, refItems, setTotalMoney]
143
+ );
144
+
145
+ return (
146
+ /* Render Product AddOns with AddToCart Button */
147
+ product &&
148
+ products.length > 0 &&
149
+ !isOutOfStock && (
150
+ <div className={styles}>
151
+ <Heading size="lead" className="px-6 md:px-8 lg:px-12">
152
+ {componentTitle}
153
+ </Heading>
154
+ {/* Product Add-Ons */}
155
+ <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 px-6 md:px-8 lg:px-12">
156
+ {products.map((product) => (
157
+ <li key={product.id}>
158
+ <ProductOptionsProvider data={product}>
159
+ <div className="mt-2">
160
+ <label
161
+ htmlFor={product.id}
162
+ className="cursor-pointer flex items-center hover:bg-slate-200 p-2 gap-2"
163
+ >
164
+ <input
165
+ type="checkbox"
166
+ value=""
167
+ disabled={
168
+ !product.variants.nodes[0]
169
+ .availableForSale
170
+ }
171
+ name={product.title}
172
+ id={product.id}
173
+ checked={
174
+ addedItems.filter(
175
+ (item) =>
176
+ item.id === product.id
177
+ ).length > 0
178
+ }
179
+ className="accent-black rounded-sm cursor-pointer"
180
+ onChange={(event) =>
181
+ handleChange(event, product)
182
+ }
183
+ />
184
+ <RebuyProductAddOnCard
185
+ product={product}
186
+ />
187
+ </label>
188
+ </div>
189
+ </ProductOptionsProvider>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+
194
+ <div className="px-6 md:px-8 lg:px-12">
195
+ {/* Subtotal */}
196
+ {Number(totalMoney.amount) >
197
+ Number(selectedVariant.priceV2.amount) && (
198
+ <Text
199
+ as="div"
200
+ className="flex gap-1 pb-4 text-gray-600"
201
+ >
202
+ Subtotal:{' '}
203
+ <Money
204
+ data={{
205
+ ...totalMoney,
206
+ amount: String(
207
+ totalMoney.amount -
208
+ selectedVariant.priceV2.amount
209
+ ),
210
+ }}
211
+ withoutTrailingZeros
212
+ />
213
+ </Text>
214
+ )}
215
+
216
+ {/* AddToCart Button */}
217
+ <AddToCartMarkup
218
+ items={addedItems}
219
+ money={totalMoney}
220
+ selectedVariant={selectedVariant}
221
+ />
222
+ </div>
223
+ </div>
224
+ )
225
+ );
226
+ };
227
+
228
+ export default RebuyProductAddOns;
@@ -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.1.0",
5
5
  "license": "MIT",
6
6
  "author": "Rebuy, Inc.",
7
7
  "type": "module",