@springmicro/cart 0.2.1 → 0.3.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@springmicro/cart",
3
3
  "private": false,
4
- "version": "0.2.1",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  "@nanostores/persistent": "^0.10.1",
24
24
  "@nanostores/query": "^0.3.3",
25
25
  "@nanostores/react": "^0.7.2",
26
- "@springmicro/utils": "0.2.1",
26
+ "@springmicro/utils": "0.3.2",
27
27
  "dotenv": "^16.4.5",
28
28
  "nanostores": "^0.10.3",
29
29
  "react": "^18.2.0",
@@ -47,5 +47,5 @@
47
47
  "vite-plugin-css-injected-by-js": "^3.5.1",
48
48
  "yup": "^1.4.0"
49
49
  },
50
- "gitHead": "5060bfe477b5be4d15de32991fb8ebc839143430"
50
+ "gitHead": "f4cc159c6a21ff9eb0ae892311a0c606601db463"
51
51
  }
Binary file
@@ -1,7 +1,8 @@
1
+ import { CartProduct } from "./types";
1
2
  import { addToCart } from "./utils/storage";
2
3
 
3
4
  type Props = {
4
- item: any;
5
+ item: CartProduct;
5
6
  children: any;
6
7
  };
7
8
 
@@ -23,11 +23,15 @@ export default function CartButton({
23
23
  apiBaseUrl,
24
24
  color,
25
25
  modalYOffset,
26
+ checkout,
27
+ float,
26
28
  }: {
27
- user: ({ id: string | number } & any) | undefined;
29
+ user?: { id: string | number } & any;
28
30
  apiBaseUrl: string;
29
- color: string | undefined;
30
- modalYOffset: string | number | undefined;
31
+ color?: string;
32
+ modalYOffset?: string | number;
33
+ checkout: () => void;
34
+ float?: boolean;
31
35
  }) {
32
36
  const [modalOpen, setModalOpen] = React.useState(false);
33
37
 
@@ -39,13 +43,17 @@ export default function CartButton({
39
43
  }, [user]);
40
44
 
41
45
  const cartStorage = useStore(cartStore);
46
+ const cartData = JSON.parse(cartStorage);
42
47
 
43
48
  return (
44
49
  <Cart
45
- cart={JSON.parse(cartStorage)}
50
+ cart={cartData}
46
51
  modalState={[modalOpen, setModalOpen]}
47
52
  color={color}
48
53
  modalYOffset={modalYOffset}
54
+ checkout={checkout}
55
+ apiBaseUrl={apiBaseUrl}
56
+ float={float}
49
57
  />
50
58
  );
51
59
  }
@@ -55,11 +63,17 @@ function Cart({
55
63
  cart,
56
64
  color,
57
65
  modalYOffset,
66
+ checkout,
67
+ apiBaseUrl,
68
+ float,
58
69
  }: {
59
70
  modalState: [boolean, React.Dispatch<React.SetStateAction<boolean>>];
60
71
  cart: Cart;
61
72
  color?: string;
62
73
  modalYOffset?: string | number;
74
+ checkout: () => void;
75
+ apiBaseUrl: string;
76
+ float?: boolean;
63
77
  }) {
64
78
  const [modalOpen, setModalOpen] = modalState;
65
79
 
@@ -77,7 +91,7 @@ function Cart({
77
91
  setModalOpen((t) => !t);
78
92
  }}
79
93
  >
80
- {cart.cart.length > 0 ? (
94
+ {cart.items.length > 0 ? (
81
95
  <ShoppingCartIcon />
82
96
  ) : (
83
97
  <ShoppingCartOutlinedIcon />
@@ -87,8 +101,10 @@ function Cart({
87
101
  open={modalOpen}
88
102
  cart={cart}
89
103
  clearCart={clearCart}
90
- removeFromCart={removeFromCart}
91
104
  modalYOffset={modalYOffset}
105
+ checkout={checkout}
106
+ apiBaseUrl={apiBaseUrl}
107
+ float={float}
92
108
  />
93
109
  </Box>
94
110
  </ClickAwayListener>
@@ -99,31 +115,66 @@ function Cart({
99
115
  function CartModal({
100
116
  open,
101
117
  cart,
102
- removeFromCart,
103
118
  clearCart,
104
119
  modalYOffset,
120
+ checkout,
121
+ apiBaseUrl,
122
+ float,
105
123
  }: {
106
124
  open?: boolean;
107
125
  cart: Cart;
108
- removeFromCart: (i: number) => void;
109
126
  clearCart: () => void;
110
127
  modalYOffset?: string | number;
128
+ checkout: () => void;
129
+ apiBaseUrl: string;
130
+ float?: boolean;
111
131
  }) {
132
+ const [prices, setPrices] = React.useState({});
133
+ // build pricing list
134
+ React.useEffect(() => {
135
+ // filter out prices that have already been queried
136
+ const pricesToGet = cart.items
137
+ .map((c) => c.price_id)
138
+ .filter(
139
+ (pId) => Object.keys(prices).findIndex((pKey) => pKey == pId) === -1
140
+ );
141
+ if (pricesToGet.length === 0) return;
142
+
143
+ const url = `${apiBaseUrl}/api/ecommerce/price?filter={'ids':[${pricesToGet.join(",")}]}`;
144
+ fetch(url, {
145
+ method: "GET",
146
+ headers: {
147
+ "Content-Type": "application/json",
148
+ },
149
+ })
150
+ .then((res) =>
151
+ res.json().then((data) => {
152
+ const pricingData = { ...prices };
153
+
154
+ data.forEach((p) => {
155
+ pricingData[p.id] = p;
156
+ });
157
+ setPrices(pricingData);
158
+ })
159
+ )
160
+ .catch(() => {
161
+ setPrices(null);
162
+ });
163
+ }, [cart]);
164
+
112
165
  if (!open) return null;
113
166
  return (
114
167
  <Box
115
168
  sx={{
116
169
  marginTop: modalYOffset,
117
- position: "fixed",
118
- width: 275,
119
- minHeight: 300,
170
+ position: float ? "fixed" : "absolute",
171
+ width: 325 + 16,
172
+ minHeight: 450,
120
173
  maxHeight: "calc(90vh - 8rem)",
121
- borderLeft: "2px solid #999",
122
- borderBottom: "2px solid #999",
123
- pl: 4,
174
+ boxShadow: "-4px 8px 5px -7px rgba(0,0,0,0.75)",
175
+ py: 1,
176
+ right: -16,
124
177
  pr: 2,
125
- py: 2,
126
- right: 0,
127
178
  display: "grid",
128
179
  gridTemplateRows: "1fr 40px",
129
180
  color: "black",
@@ -133,45 +184,65 @@ function CartModal({
133
184
  zIndex: 1,
134
185
  }}
135
186
  >
136
- <Box sx={{ overflow: "auto" }}>
137
- {cart.cart.length === 0 ? <Box>Your cart is empty.</Box> : null}
138
- {cart.cart.map((product, i) => (
139
- <Box
140
- key={product.id}
141
- sx={{
142
- display: "flex",
143
- width: "100%",
144
- borderBottom: "2px solid #ddd",
145
- py: 1,
146
- "&>*": {
147
- m: 0,
148
- },
149
- justifyContent: "space-between",
150
- }}
151
- >
152
- <h2>{product.name}</h2>
153
- <Tooltip title="Remove from cart">
154
- <IconButton
155
- sx={{ color: "red" }}
156
- onClick={() => {
157
- removeFromCart(i);
158
- }}
159
- >
160
- <CloseIcon />
161
- </IconButton>
162
- </Tooltip>
163
- </Box>
164
- ))}
187
+ <Box sx={{ overflow: "auto", pl: 2 }}>
188
+ {cart.items.length === 0 ? <Box>Your cart is empty.</Box> : null}
189
+ {cart.items.map((product, i) => {
190
+ const price = prices[product.price_id];
191
+ return (
192
+ <Box
193
+ key={product.product_id}
194
+ sx={{
195
+ display: "flex",
196
+ width: "100%",
197
+ borderBottom: "2px solid #ddd",
198
+ boxSizing: "border-box",
199
+ pr: 1,
200
+ py: 1.5,
201
+ ">*": {
202
+ m: 0,
203
+ },
204
+ justifyContent: "space-between",
205
+ alignItems: "center",
206
+ }}
207
+ >
208
+ <Box sx={{ display: "flex", flexDirection: "column" }}>
209
+ <h2>{product.name}</h2>
210
+
211
+ <h3 style={{ fontSize: 13 }}>
212
+ {prices === null
213
+ ? "Price not found."
214
+ : price
215
+ ? `$${(price.unit_amount / 100).toFixed(2)}${
216
+ price.recurring
217
+ ? `/${price.recurring.interval_count > 1 ? `${price.recurring.interval_count} ` : ""}${price.recurring.interval}${price.recurring.interval_count > 1 ? "s" : ""}`
218
+ : ""
219
+ }`
220
+ : "Loading price..."}
221
+ </h3>
222
+ </Box>
223
+ <Tooltip title="Remove from cart">
224
+ <IconButton
225
+ sx={{ color: "red" }}
226
+ onClick={() => {
227
+ removeFromCart(i);
228
+ }}
229
+ >
230
+ <CloseIcon />
231
+ </IconButton>
232
+ </Tooltip>
233
+ </Box>
234
+ );
235
+ })}
165
236
  </Box>
166
- <Box sx={{ display: "flex", justifyContent: "space-between" }}>
237
+ <Box sx={{ display: "flex", justifyContent: "space-between", mx: 2 }}>
167
238
  <Button
168
239
  sx={{ color: "red" }}
169
240
  onClick={clearCart}
170
- disabled={cart.cart.length === 0}
241
+ disabled={cart.items.length === 0}
171
242
  >
172
243
  Clear cart
173
244
  </Button>
174
- <Button>Checkout →</Button>
245
+ <Button onClick={checkout}>Checkout →</Button>
175
246
  </Box>
176
247
  </Box>
177
248
  );
@@ -0,0 +1,106 @@
1
+ .card {
2
+ display: flex;
3
+ gap: 0.5rem;
4
+ flex-direction: column;
5
+ border-radius: 8px;
6
+ padding: 1rem;
7
+ box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0);
8
+ transition: all 0.3s 0.1s;
9
+ cursor: pointer;
10
+ }
11
+
12
+ .card:hover {
13
+ box-shadow: 0px 2px 5px 2px #dfdfdfff;
14
+ transition: all 0.3s 0s;
15
+ }
16
+
17
+ .card-section {
18
+ display: flex;
19
+ gap: 8px;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ }
23
+
24
+ .image-container {
25
+ width: 100%;
26
+ background-color: #ccc;
27
+ border-radius: 4px;
28
+ aspect-ratio: 1.5;
29
+ }
30
+
31
+ .button-section {
32
+ display: flex;
33
+ justify-content: space-around;
34
+ flex-direction: row;
35
+ flex-wrap: wrap;
36
+ margin: 4px 2rem 0px;
37
+ gap: 0.5rem;
38
+ }
39
+
40
+ .addtocartbutton {
41
+ color: white;
42
+ background-color: rgb(39, 138, 230);
43
+ padding: 4px 16px 6px;
44
+ border-radius: 8px;
45
+ outline: none;
46
+ transition: all 0.3s;
47
+ }
48
+
49
+ .addtocartbutton.red {
50
+ background-color: rgb(230, 39, 39);
51
+ }
52
+
53
+ .addtocartbutton.disabled {
54
+ background-color: rgb(170, 170, 170);
55
+ cursor: not-allowed;
56
+ }
57
+ .addtocartbutton.disabled:hover {
58
+ background-color: rgb(170, 170, 170);
59
+ }
60
+
61
+ .addtocartbutton:hover {
62
+ background-color: rgb(36, 124, 207);
63
+ }
64
+
65
+ .hover-button > .hover-section {
66
+ display: none;
67
+ position: absolute;
68
+ width: 325px;
69
+ margin-left: calc(-113.75px);
70
+ padding: 1rem;
71
+ color: black;
72
+ }
73
+
74
+ .hover-button:hover > .hover-section {
75
+ display: flex;
76
+ justify-content: center;
77
+ }
78
+
79
+ .hover-data {
80
+ border-radius: 8px;
81
+ padding: 1rem;
82
+ box-shadow: 0px 2px 5px 2px rgb(173, 173, 173);
83
+ background-color: white;
84
+ }
85
+
86
+ .hover-data > div {
87
+ display: flex;
88
+ flex-direction: column;
89
+ background-color: rgb(203, 203, 203);
90
+ gap: 1px;
91
+ }
92
+
93
+ .tieredpricingbutton {
94
+ display: flex;
95
+ flex-direction: column;
96
+ justify-content: flex-start;
97
+ align-items: flex-start;
98
+ text-align: left;
99
+ padding: 4px 0.5rem 8px;
100
+ background-color: white;
101
+ transition: all 0.3s;
102
+ }
103
+
104
+ .tieredpricingbutton:hover {
105
+ background-color: rgb(233, 233, 233);
106
+ }
@@ -0,0 +1,165 @@
1
+ import "./ProductCard.css";
2
+ import { useStore } from "@nanostores/react";
3
+ import type React from "react";
4
+ import { Product, ProductPricing } from "./types";
5
+ import { addToCart, cartStore, removeFromCart } from "./utils/storage";
6
+
7
+ /**
8
+ * A card meant for displaying product data, allowing for purchases with managed functions.
9
+ *
10
+ * By default it displays a card component from the ecommerce-example repo but you can use a different component by providing it in the properties.
11
+ *
12
+ * A new component should be declared using this format: ```function ExampleComponent(props: @type {ProductCardComponentProps} { return <></>; })```
13
+ * Pass this new component as ```component={ExampleComponent}```
14
+ */
15
+
16
+ export type ProductCardComponentProps = {
17
+ product: Product;
18
+ foundInCart: {
19
+ product: boolean;
20
+ price: boolean;
21
+ };
22
+ pricing;
23
+ addToCart: (price_index?: number) => void;
24
+ removeFromCart: (price?: boolean) => void;
25
+ };
26
+
27
+ export default function ProductCard({
28
+ product,
29
+ component,
30
+ priceTierName,
31
+ }: {
32
+ product: Product;
33
+ component?: React.FC<ProductCardComponentProps>;
34
+ priceTierName?: string; // Might have incomplete code for cases where the tier name doesn't match.
35
+ }) {
36
+ const cart = JSON.parse(useStore(cartStore));
37
+
38
+ const productInCartIndex: number = (cart.items as any[]).findIndex(
39
+ (p) => p.product_id === product.id
40
+ );
41
+ const productFoundInCart = !!~productInCartIndex;
42
+
43
+ const pricing: ProductPricing[] = JSON.parse(product.pricing);
44
+
45
+ const pi = pricing.findIndex((price) => price.tier_label === priceTierName); // TODO add fail-case for missing price
46
+
47
+ const priceInCartIndex: number = (cart.items as any[]).findIndex(
48
+ (p) => p.price_id === pricing[pi].id
49
+ );
50
+ const priceFoundInCart = !!~priceInCartIndex;
51
+
52
+ function addToCartSafe(priceIndex: number = pi) {
53
+ // Removes the ability to add improper info from the addToCart
54
+ addToCart({
55
+ product_id: product.id,
56
+ name: product.name,
57
+ price_id: pricing[priceIndex].id,
58
+ image: undefined,
59
+ quantity: undefined,
60
+ });
61
+ }
62
+
63
+ const props: ProductCardComponentProps = {
64
+ product,
65
+ foundInCart: {
66
+ product: productFoundInCart,
67
+ price: priceFoundInCart,
68
+ },
69
+ pricing,
70
+ addToCart: addToCartSafe,
71
+ removeFromCart: (price?: boolean) => {
72
+ removeFromCart(price ? priceInCartIndex : productInCartIndex);
73
+ },
74
+ };
75
+
76
+ if (component !== undefined) {
77
+ return component!(props);
78
+ }
79
+
80
+ return <DefaultProductCard {...props} />;
81
+ }
82
+
83
+ function DefaultProductCard({
84
+ product,
85
+ foundInCart,
86
+ pricing,
87
+ addToCart,
88
+ removeFromCart,
89
+ }: ProductCardComponentProps) {
90
+ return (
91
+ <div
92
+ className="card"
93
+ onClick={(e) =>
94
+ // @ts-expect-error it works
95
+ !e.target.closest("button") &&
96
+ (window.location.href = `/product/${product.id}`)
97
+ }
98
+ >
99
+ <div className="card-section">
100
+ <b>{product.name}</b>
101
+ <div className="image-container"></div>
102
+ </div>
103
+ {product.description && (
104
+ <p style={{ margin: "0px 1rem", flexGrow: 1 }}>{product.description}</p>
105
+ )}
106
+ <div className="button-section">
107
+ {foundInCart.product ? (
108
+ <button
109
+ className="addtocartbutton"
110
+ onClick={() => {
111
+ removeFromCart();
112
+ }}
113
+ >
114
+ Remove from cart
115
+ </button>
116
+ ) : pricing.length === 1 ? (
117
+ <button
118
+ className="addtocartbutton"
119
+ onClick={() => {
120
+ addToCart();
121
+ }}
122
+ >
123
+ Add {formatPricing(pricing[0])}
124
+ </button>
125
+ ) : pricing.length === 0 ? (
126
+ <button className="addtocartbutton disabled">
127
+ Currently unavailable
128
+ </button>
129
+ ) : (
130
+ <button className="addtocartbutton hover-button">
131
+ Add to cart ▼
132
+ <div className="hover-section">
133
+ <div className="hover-data">
134
+ <div>
135
+ {pricing.map((p, i) => (
136
+ <button
137
+ className="tieredpricingbutton"
138
+ onClick={() => {
139
+ addToCart(i);
140
+ }}
141
+ >
142
+ Tier {i + 1}:
143
+ <br />
144
+ {formatPricing(p)}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </button>
151
+ )}
152
+ </div>
153
+ </div>
154
+ );
155
+ }
156
+
157
+ function formatPricing({ unit_amount, recurring }: any) {
158
+ if (recurring) {
159
+ const { interval, interval_count } = recurring;
160
+ return `$${(unit_amount / 100).toFixed(2)}/${
161
+ interval_count === 1 ? interval : `${interval_count} ${interval}s`
162
+ }`;
163
+ }
164
+ return `$${(unit_amount / 100).toFixed(2)}`;
165
+ }
@@ -0,0 +1,93 @@
1
+ .two-col {
2
+ display: flex;
3
+ /* grid-template-columns: 1fr 1fr; */
4
+ margin-top: 2rem;
5
+ align-items: flex-start;
6
+ }
7
+
8
+ .two-col > * {
9
+ width: 50%;
10
+ }
11
+
12
+ .checkout-list {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ gap: 1rem;
17
+ padding: 8px 4px;
18
+ margin-top: 1rem;
19
+ max-height: 50vh;
20
+ overflow: auto;
21
+ }
22
+
23
+ @keyframes cardanimate {
24
+ 0% {
25
+ box-shadow: 0px 2px 5px 2px #dfdfdfff;
26
+ }
27
+ 50% {
28
+ box-shadow: 0px 0px 0px 0px #ffffff00;
29
+ }
30
+ 100% {
31
+ box-shadow: 0px 2px 5px 2px #dfdfdfff;
32
+ }
33
+ }
34
+
35
+ .skeleton-card {
36
+ min-height: 318px;
37
+ border-radius: 8px;
38
+ box-shadow: 0px 2px 5px 2px #dfdfdfff;
39
+ animation: cardanimate 1.25s linear infinite;
40
+ }
41
+
42
+ #status-bar {
43
+ display: flex;
44
+ flex-direction: row;
45
+ gap: 1rem;
46
+ align-items: center;
47
+ margin-left: 56px;
48
+ margin-bottom: 1rem;
49
+ margin-top: 2rem;
50
+ font-size: 18px;
51
+ font-weight: 600;
52
+ }
53
+
54
+ .status-bar {
55
+ width: 200px;
56
+ height: 6px;
57
+ background: linear-gradient(
58
+ 90deg,
59
+ rgb(40, 130, 213) 0%,
60
+ rgb(40, 130, 213) 50%,
61
+ lightgrey 50%,
62
+ lightgrey 100%
63
+ );
64
+ background-position: 100% 0;
65
+ transition: color 0.3s 0s; /* active -> inactive */
66
+ background-size: 200% 100%;
67
+ border-radius: 16px;
68
+ }
69
+
70
+ .status-bar.active {
71
+ background-position: 0 0;
72
+ transition: background-position 0.75s; /* inactive -> active */
73
+ }
74
+
75
+ .status-text {
76
+ color: lightgrey;
77
+ transition: color 0.3s 0s; /* active -> inactive */
78
+ }
79
+
80
+ .status-text.active {
81
+ color: rgb(40, 130, 213);
82
+ transition: color 0.5s 0.65s; /* inactive -> active */
83
+ }
84
+
85
+ .shopping-button {
86
+ color: white;
87
+ background-color: rgb(39, 138, 230);
88
+ padding: 4px 16px 6px;
89
+ border-radius: 8px;
90
+ outline: none;
91
+ transition: all 0.3s;
92
+ text-decoration: none;
93
+ }