@rebuy/rebuy-hydrogen 1.0.3 → 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.
@@ -1,13 +1,33 @@
1
- import { useUrl, useCart } from '@shopify/hydrogen';
2
-
3
- import React, { createContext, useMemo } from 'react';
4
-
1
+ import { RebuyClient } from '@rebuy/rebuy';
2
+ import { RebuyContext } from '@rebuy/rebuy-hydrogen/RebuyContexts.client';
5
3
  import * as Utilities from '@rebuy/rebuy/utilities';
4
+ import { useCart, useShop, useUrl } from '@shopify/hydrogen';
5
+ import { useEffect, useMemo, useState } from 'react';
6
+
7
+ const REBUY_API_KEY = import.meta.env.PUBLIC_REBUY_API_KEY;
8
+ const API = '/api/v1';
9
+
10
+ const getEncodedAttributes = (attributes) =>
11
+ encodeURIComponent(
12
+ JSON.stringify(
13
+ attributes.reduce(
14
+ (merged, { key, value }) => ({ ...merged, [key]: value }),
15
+ {}
16
+ )
17
+ )
18
+ );
6
19
 
7
- export const RebuyContext = createContext(null);
8
-
9
- export function RebuyContextProvider({ children }) {
20
+ export const RebuyContextProvider = ({ children }) => {
21
+ // Shopify
22
+ const cart = useCart();
23
+ const shop = useShop();
10
24
  const url = useUrl();
25
+
26
+ // Default state
27
+ const [initialized, setInitialized] = useState(false);
28
+ const [rebuyConfig, setRebuyConfig] = useState(null);
29
+ const [config, setConfig] = useState({ shop: null });
30
+ // const [Rebuy, setRebuy] = useState(null);
11
31
  const queryObject = Utilities.queryStringToObject(url.search);
12
32
  const utmObject = Utilities.utmObjectFromString(url);
13
33
 
@@ -17,114 +37,186 @@ export function RebuyContextProvider({ children }) {
17
37
  }
18
38
  }
19
39
 
20
- const cart = useCart();
21
-
22
- const contextParameters = {};
23
-
24
- // Set URL
25
- contextParameters.url = url.href;
40
+ // Initialization
41
+ useEffect(() => {
42
+ const getRebuyConfig = async () => {
43
+ try {
44
+ const request = {
45
+ url: `${API}/user/config`,
46
+ parameters: { shop: shop.storeDomain },
47
+ };
48
+
49
+ const { data: rebuy, ...response } = await new RebuyClient(
50
+ REBUY_API_KEY
51
+ ).getDataFromCDN(request.url, request.parameters);
52
+
53
+ // Missing Rebuy shop data?
54
+ if (!rebuy?.shop) {
55
+ throw new Error(
56
+ 'Rebuy configuration is not properly set up - missing shop',
57
+ { cause: response }
58
+ );
59
+ }
26
60
 
27
- // Set time
28
- if (queryObject.hasOwnProperty('time')) {
29
- contextParameters.time = queryObject.time;
30
- }
61
+ setRebuyConfig(rebuy);
62
+ } catch (err) {
63
+ console.warn('Error fetching Rebuy shop config');
64
+ console.error(err, err.cause);
65
+ }
66
+ };
31
67
 
32
- // Cart object
33
- const cartContext = {};
68
+ const onBeforeReady = async () => {
69
+ // Initializing...
70
+ if (!rebuyConfig?.shop) {
71
+ return await getRebuyConfig();
72
+ }
73
+ };
74
+
75
+ onBeforeReady();
76
+ }, [rebuyConfig, shop]);
77
+
78
+ useEffect(() => {
79
+ const applyConfig = async () => {
80
+ // Still fetching Rebuy config OR config already applied. Abort!
81
+ if (!rebuyConfig || config.shop) return;
82
+
83
+ // Bring it all together
84
+ const appConfig = { ...config, ...rebuyConfig };
85
+
86
+ setConfig(appConfig);
87
+ };
88
+
89
+ applyConfig();
90
+ }, [config, rebuyConfig]);
91
+
92
+ useEffect(() => {
93
+ const onReady = () => {
94
+ // Still initializing... Abort!
95
+ if (!config.shop) return;
96
+
97
+ // Initialized!
98
+ setInitialized(true);
99
+ // const { api_key, cache_key } = config.shop;
100
+ // setRebuy(new RebuyClient(api_key, { cache_key }));
101
+ };
102
+
103
+ onReady();
104
+ }, [config]);
105
+
106
+ const contextParameters = useMemo(() => {
107
+ // Still initializing... Abort!
108
+ if (!config.shop) return null;
109
+
110
+ const { cache_key } = config.shop;
111
+ const contextParameters = {
112
+ url: url.href,
113
+ cache_key,
114
+ };
115
+ // Cart object
116
+ const cartContext = {};
117
+
118
+ // Set time
119
+ if (Object.prototype.hasOwnProperty.call(queryObject, 'time')) {
120
+ contextParameters.time = queryObject.time;
121
+ }
34
122
 
35
- // Set Cart: token
36
- if (cart.id) {
37
- cartContext.token = Utilities.getIdFromGraphUrl(cart.id, 'Cart');
38
- contextParameters.cart_token = Utilities.getIdFromGraphUrl(cart.id, 'Cart');
39
- }
123
+ // Set Cart: token
124
+ if (cart.id) {
125
+ cartContext.token = Utilities.getIdFromGraphUrl(cart.id, 'Cart');
126
+ contextParameters.cart_token = Utilities.getIdFromGraphUrl(
127
+ cart.id,
128
+ 'Cart'
129
+ );
130
+ }
40
131
 
41
- // Set Cart: subtotal
42
- if (
43
- cart.estimatedCost &&
44
- cart.estimatedCost.subtotalAmount &&
45
- cart.estimatedCost.subtotalAmount &&
46
- cart.estimatedCost.subtotalAmount.amount
47
- ) {
48
- cartContext.subtotal = Utilities.amountToCents(cart.estimatedCost.subtotalAmount.amount);
49
- contextParameters.cart_subtotal = Utilities.amountToCents(cart.estimatedCost.subtotalAmount.amount);
50
- }
132
+ // Set Cart: subtotal
133
+ if (cart.cost?.subtotalAmount?.amount) {
134
+ const { amount } = cart.cost.subtotalAmount;
51
135
 
52
- // Set Cart: line count
53
- if (typeof cart.lines != 'undefined') {
54
- cartContext.line_count = cart.lines.length;
55
- contextParameters.cart_count = cart.lines.length;
56
- contextParameters.cart_line_count = cart.lines.length;
57
- }
136
+ cartContext.subtotal = Utilities.amountToCents(amount);
137
+ contextParameters.cart_subtotal = Utilities.amountToCents(amount);
138
+ }
58
139
 
59
- // Set Cart: item count
60
- if (typeof cart.totalQuantity != 'undefined') {
61
- cartContext.item_count = cart.totalQuantity;
62
- contextParameters.cart_item_count = cart.totalQuantity;
63
- }
140
+ // Set Cart: line count
141
+ if (typeof cart.lines != 'undefined') {
142
+ const totalLines = cart.lines.length;
64
143
 
65
- // Set Cart: line items
66
- if (typeof cart.lines != 'undefined') {
67
- cartContext.items = [];
68
- for (let i = 0; i < cart.lines.length; i++) {
69
- const cartItem = cart.lines[i];
144
+ cartContext.line_count = totalLines;
145
+ contextParameters.cart_count = totalLines;
146
+ contextParameters.cart_line_count = totalLines;
147
+ }
70
148
 
71
- const item = {};
149
+ // Set Cart: item count
150
+ if (typeof cart.totalQuantity != 'undefined') {
151
+ const { totalQuantity } = cart;
72
152
 
73
- if (cartItem.product?.id) {
74
- item.product_id = Utilities.getIdFromGraphUrl(cartItem.product.id, 'Product');
75
- }
153
+ cartContext.item_count = totalQuantity;
154
+ contextParameters.cart_item_count = totalQuantity;
155
+ }
76
156
 
77
- if (cartItem.merchandise?.id) {
78
- item.variant_id = Utilities.getIdFromGraphUrl(cartItem.merchandise.id, 'ProductVariant');
79
- }
157
+ // Set Cart: line items
158
+ if (typeof cart.lines != 'undefined') {
159
+ cartContext.items = [];
80
160
 
81
- item.quantity = cartItem.quantity;
161
+ for (const cartItem of cart.lines) {
162
+ const item = {
163
+ quantity: cartItem.quantity,
164
+ };
82
165
 
83
- if (cartItem.attributes) {
84
- const attributes = {};
166
+ if (cartItem.product?.id) {
167
+ item.product_id = Utilities.getIdFromGraphUrl(
168
+ cartItem.product.id,
169
+ 'Product'
170
+ );
171
+ }
85
172
 
86
- for (let i = 0; i < cartItem.attributes.length; i++) {
87
- const key = cartItem.attributes[i].key;
88
- const value = cartItem.attributes[i].value;
89
- attributes[key] = value;
173
+ if (cartItem.merchandise?.id) {
174
+ item.variant_id = Utilities.getIdFromGraphUrl(
175
+ cartItem.merchandise.id,
176
+ 'ProductVariant'
177
+ );
90
178
  }
91
179
 
92
- item.properties = JSON.stringify(attributes);
93
- }
180
+ if (cartItem.attributes) {
181
+ item.properties = getEncodedAttributes(cartItem.attributes);
182
+ }
94
183
 
95
- // TBD: item.selling_plan
184
+ // TBD: item.selling_plan
96
185
 
97
- cartContext.items.push(item);
186
+ cartContext.items.push(item);
187
+ }
98
188
  }
99
- }
100
189
 
101
- // Set Cart: attributes
102
- if (cart.attributes) {
103
- const attributes = {};
104
-
105
- for (let i = 0; i < cart.attributes.length; i++) {
106
- const key = cart.attributes[i].key;
107
- const value = cart.attributes[i].value;
108
- attributes[key] = value;
190
+ // Set Cart: attributes
191
+ if (cart.attributes) {
192
+ cartContext.attributes = getEncodedAttributes(cart.attributes);
109
193
  }
110
194
 
111
- cartContext.attributes = JSON.stringify(attributes);
112
- }
195
+ // Set Cart: notes
196
+ if (cart.note) {
197
+ cartContext.note = cart.note;
198
+ }
113
199
 
114
- // Set Cart: notes
115
- if (cart.note) {
116
- cartContext.note = cart.note;
117
- }
200
+ // Set cart
201
+ contextParameters.cart = cartContext;
118
202
 
119
- // Set cart
120
- contextParameters.cart = cartContext;
203
+ return contextParameters;
204
+ }, [cart, config, queryObject, url]);
121
205
 
122
- const value = useMemo(
123
- () => ({
124
- ...contextParameters,
125
- }),
126
- [contextParameters]
206
+ // Static reference (JSON) + memoization
207
+ // ^ prevent re-rendering children when context params are unchanged
208
+ const contextParametersJSON = JSON.stringify(contextParameters);
209
+ const contextValue = useMemo(
210
+ () => ({ contextParameters: JSON.parse(contextParametersJSON) }),
211
+ [contextParametersJSON]
127
212
  );
128
213
 
129
- return React.createElement(RebuyContext.Provider, { value }, children);
130
- }
214
+ // Still initializing...
215
+ if (!initialized) return null;
216
+
217
+ return (
218
+ <RebuyContext.Provider value={contextValue}>
219
+ {children}
220
+ </RebuyContext.Provider>
221
+ );
222
+ };
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+
3
+ export const RebuyContext = createContext(null);
@@ -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;