@springmicro/cart 0.2.0 → 0.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.
@@ -0,0 +1,264 @@
1
+ import "./CheckoutList.css";
2
+ import { useStore } from "@nanostores/react";
3
+ import { CartProductCard } from "./components/CartProductCard";
4
+ import React from "react";
5
+ import { Button, Tooltip, Typography } from "@mui/material";
6
+ import {
7
+ cartStore,
8
+ clearCart,
9
+ setOrder as cartSetOrder,
10
+ } from "../utils/storage";
11
+ import Checkout from "./components";
12
+
13
+ export default function CheckoutList({
14
+ apiBaseUrl,
15
+ emptyCartLink = "",
16
+ disableProductLink = false,
17
+ }: {
18
+ apiBaseUrl: string;
19
+ emptyCartLink?: string;
20
+ disableProductLink?: boolean;
21
+ }) {
22
+ const cart = JSON.parse(useStore(cartStore));
23
+
24
+ const [prices, setPrices] = React.useState({});
25
+ const [total, setTotal] = React.useState("Loading prices...");
26
+
27
+ const [status, setStatus] = React.useState(0);
28
+ const [order, setOrder] = React.useState(undefined);
29
+
30
+ React.useEffect(() => {
31
+ const params = new URLSearchParams(window.location.search);
32
+
33
+ const sr = params.get("showReceipt");
34
+ const oid = params.get("orderId");
35
+ const or = params.get("orderRef");
36
+ if (oid && or) {
37
+ fetch(`${apiBaseUrl}/api/ecommerce/orders/${oid}/reference/${or}`).then(
38
+ async (res) =>
39
+ await res.json().then((order) => {
40
+ setOrder(order);
41
+ setStatus(
42
+ order.charge_id ||
43
+ !["pending", "awaiting_payment"].includes(order.status)
44
+ ? 2
45
+ : 1
46
+ );
47
+ })
48
+ );
49
+ }
50
+ }, []);
51
+
52
+ // build pricing list
53
+ React.useEffect(() => {
54
+ // filter out prices that have already been queried
55
+ const pricesToGet = cart.items
56
+ .map((c) => c.price_id)
57
+ .filter(
58
+ (pId) => Object.keys(prices).findIndex((pKey) => pKey == pId) === -1
59
+ );
60
+ if (pricesToGet.length === 0) return;
61
+
62
+ const url = `${apiBaseUrl}/api/ecommerce/price?filter={'ids':[${pricesToGet.join(
63
+ ","
64
+ )}]}`;
65
+ fetch(url, {
66
+ method: "GET",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ },
70
+ })
71
+ .then((res) =>
72
+ res.json().then((data) => {
73
+ const pricingData = { ...prices };
74
+
75
+ data.forEach((p) => {
76
+ pricingData[p.id] = p;
77
+ });
78
+ setPrices(pricingData);
79
+ })
80
+ )
81
+ .catch(() => {});
82
+ }, [cart]);
83
+
84
+ React.useEffect(() => {
85
+ setTotal(
86
+ `$${(
87
+ cart.items
88
+ .map((product) => prices[product.price_id]?.unit_amount)
89
+ .reduce((p, c) => (c ? p + c : p), 0) / 100
90
+ ).toFixed(2)}`
91
+ );
92
+ }, [cart, prices]);
93
+
94
+ if (status === 0 && cart.items.length === 0)
95
+ return (
96
+ <div
97
+ style={{
98
+ width: "100%",
99
+ display: "flex",
100
+ alignItems: "center",
101
+ flexDirection: "column",
102
+ gap: 8,
103
+ }}
104
+ >
105
+ <Typography style={{ fontSize: 32, fontWeight: 600 }}>
106
+ Cart is empty
107
+ </Typography>
108
+ <a className="shopping-button" href={`/${emptyCartLink}`}>
109
+ <Typography>BACK</Typography>
110
+ </a>
111
+ </div>
112
+ );
113
+
114
+ return (
115
+ <div>
116
+ <StatusBar status={status} />
117
+ <div>
118
+ {status === 0 && (
119
+ <CartSection
120
+ total={total}
121
+ setStatus={setStatus}
122
+ cart={cart}
123
+ prices={prices}
124
+ apiBaseUrl={apiBaseUrl}
125
+ setOrder={setOrder}
126
+ disableProductLink={disableProductLink}
127
+ />
128
+ )}
129
+ {status > 0 && order && (
130
+ <Checkout
131
+ apiBaseUrl={apiBaseUrl}
132
+ order={order}
133
+ onPlacement={() => {
134
+ clearCart();
135
+ setStatus(2);
136
+ }}
137
+ />
138
+ )}
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ function CartSection({
145
+ total,
146
+ setStatus,
147
+ cart,
148
+ prices,
149
+ apiBaseUrl,
150
+ setOrder,
151
+ disableProductLink,
152
+ }) {
153
+ function createOrder() {
154
+ // If an order has already been created and hasn't been changed, get the previous order.
155
+ if (cart.order && cart.order.id && cart.order.reference) {
156
+ fetch(
157
+ `${apiBaseUrl}/api/ecommerce/orders/${cart.order.id}/reference/${cart.order.reference}`
158
+ ).then((res) =>
159
+ res.json().then((order) => {
160
+ setOrder(order);
161
+ const currentUrl = new URL(window.location.href);
162
+ currentUrl.searchParams.set("orderId", order.id);
163
+ currentUrl.searchParams.set("orderRef", order.reference);
164
+ window.history.pushState({}, "", currentUrl);
165
+ setStatus(1);
166
+ })
167
+ );
168
+ return;
169
+ }
170
+
171
+ // Otherwise, create a new order.
172
+ fetch(apiBaseUrl + "/api/ecommerce/orders", {
173
+ method: "POST",
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ },
177
+ body: JSON.stringify({
178
+ customer_id: 1,
179
+ cart_data: {
180
+ items: cart.items.map((li) => ({
181
+ ...li,
182
+ name: undefined,
183
+ image: undefined,
184
+ })),
185
+ },
186
+ }),
187
+ }).then((res) =>
188
+ res.json().then((order) => {
189
+ if (!order) throw "Missing order";
190
+ setOrder(order);
191
+ cartSetOrder({ id: order.id, reference: order.reference });
192
+ const currentUrl = new URL(window.location.href);
193
+ currentUrl.searchParams.set("orderId", order.id);
194
+ currentUrl.searchParams.set("orderRef", order.reference);
195
+ window.history.pushState({}, "", currentUrl);
196
+ setStatus(1);
197
+ })
198
+ );
199
+ }
200
+
201
+ return (
202
+ <div style={{ display: "flex", justifyContent: "center" }}>
203
+ <div
204
+ style={{
205
+ display: "flex",
206
+ flexDirection: "column",
207
+ width: 600,
208
+ alignItems: "center",
209
+ boxShadow: "0px 2px 5px 2px #dfdfdfff",
210
+ margin: "4px",
211
+ padding: "2rem 0",
212
+ borderRadius: "8px",
213
+ }}
214
+ >
215
+ <Typography style={{ fontSize: "30px", fontWeight: "bold" }}>
216
+ Checkout
217
+ </Typography>
218
+ <Tooltip title="Total is pre-tax and includes the first payment for recurring payments.">
219
+ <Typography style={{ fontSize: "20px" }}>Total: {total}</Typography>
220
+ </Tooltip>
221
+ {/* <Button variant="contained" sx={{ mt: 2 }} onClick={createOrder}>
222
+ Confirm Order
223
+ </Button> */}
224
+ <div className="checkout-list">
225
+ {cart.items.map((p, i) => (
226
+ <CartProductCard
227
+ product={p}
228
+ i={i}
229
+ price={prices[p.price_id]}
230
+ disableProductLink={disableProductLink}
231
+ />
232
+ ))}
233
+ </div>
234
+ <Button variant="contained" sx={{ mt: 2 }} onClick={createOrder}>
235
+ Confirm Order
236
+ </Button>
237
+ </div>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ function StatusBar({ status }) {
243
+ return (
244
+ <div
245
+ style={{
246
+ display: "flex",
247
+ alignItems: "center",
248
+ flexDirection: "column",
249
+ }}
250
+ >
251
+ <div id="status-bar">
252
+ <Typography className="status-text active">REVIEW</Typography>
253
+ <div className={"status-bar" + (status > 0 ? " active" : "")}></div>
254
+ <Typography className={"status-text" + (status > 0 ? " active" : "")}>
255
+ PAYMENT
256
+ </Typography>
257
+ <div className={"status-bar" + (status > 1 ? " active" : "")}></div>
258
+ <Typography className={"status-text" + (status > 1 ? " active" : "")}>
259
+ ORDER PLACED
260
+ </Typography>
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -238,7 +238,7 @@ export function AddressPostalCodeField({
238
238
  ] as Alpha2Code;
239
239
 
240
240
  React.useEffect(() => {
241
- if (!!getPostalCodeDefault(country2code)) {
241
+ if (getPostalCodeDefault(country2code)) {
242
242
  formik
243
243
  .setFieldValue(name, getPostalCodeDefault(country2code))
244
244
  .then(() => formik.setFieldTouched(name, true));
@@ -256,7 +256,7 @@ export function AddressPostalCodeField({
256
256
  onBlur={formik.handleBlur}
257
257
  required={true}
258
258
  helperText={
259
- !!getPostalCodeDefault(country2code)
259
+ getPostalCodeDefault(country2code)
260
260
  ? 'Our records indicate that your country does not have a postal code system, so "00000" will be used by default.'
261
261
  : ""
262
262
  }
@@ -126,30 +126,33 @@ export const AddCard = ({
126
126
  }
127
127
  : validationSchemaBase
128
128
  ),
129
- onSubmit: async (values, helpers) => {
129
+ onSubmit: async (v, helpers) => {
130
+ setDisabled(true);
131
+ const values = { ...v };
130
132
  if (disabled) {
133
+ setDisabled(false);
131
134
  throw new Error("Attempted to submit an invalid form.");
132
135
  }
133
136
  if (onSubmit) {
134
137
  if (address) {
135
138
  // match backend
136
- // @ts-ignore
139
+ // @ts-expect-error value exists
137
140
  values["street_address"] = values.line2
138
- ? // @ts-ignore
141
+ ? // @ts-expect-error value exists
139
142
  `${values.line1}\n${values.line2}`
140
- : // @ts-ignore
143
+ : // @ts-expect-error value exists
141
144
  values.line1;
142
- // @ts-ignore
145
+ // @ts-expect-error value exists
143
146
  values["locality"] = values.city;
144
- // @ts-ignore
147
+ // @ts-expect-error value exists
145
148
  delete values.line1;
146
- // @ts-ignore
149
+ // @ts-expect-error value exists
147
150
  delete values.line2;
148
- // @ts-ignore
151
+ // @ts-expect-error value exists
149
152
  delete values.city;
150
- // @ts-ignore
153
+ // @ts-expect-error value exists
151
154
  if (Object.keys(allCountriesReverse).includes(values.country)) {
152
- // @ts-ignore
155
+ // @ts-expect-error value exists
153
156
  values.country = allCountriesReverse[values.country];
154
157
  }
155
158
  }
@@ -160,6 +163,9 @@ export const AddCard = ({
160
163
  delete values.number;
161
164
 
162
165
  const res = await onSubmit(values);
166
+ setDisabled(false);
167
+ } else {
168
+ setDisabled(false);
163
169
  }
164
170
  },
165
171
  });
@@ -194,7 +200,7 @@ export const AddCard = ({
194
200
  <form
195
201
  onSubmit={(e) => {
196
202
  e.preventDefault();
197
- formik.handleSubmit();
203
+ formik.handleSubmit(e);
198
204
  }}
199
205
  >
200
206
  <Box
@@ -332,10 +338,11 @@ export const AddCard = ({
332
338
  size="large"
333
339
  sx={{ mt: 3 }}
334
340
  variant="contained"
341
+ // onClick={formik.handleSubmit}
335
342
  type="submit"
336
343
  disabled={disabled}
337
344
  >
338
- Continue
345
+ Complete and pay
339
346
  </Button>
340
347
  <Box sx={{ mt: 1 }}>
341
348
  <StripeLogoLink />
@@ -0,0 +1,67 @@
1
+ .product-card {
2
+ width: 500px;
3
+ /* display: flex;
4
+ flex-direction: row;
5
+ justify-content: space-between; */
6
+ display: grid;
7
+ grid-template-columns: 1fr 40px;
8
+ gap: 0.5rem;
9
+ border-radius: 8px;
10
+ padding: 1rem;
11
+ box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0);
12
+ transition: all 0.3s 0.1s;
13
+ cursor: pointer;
14
+ min-height: 48px;
15
+ flex-shrink: 0;
16
+ }
17
+
18
+ .product-card:hover {
19
+ box-shadow: 0px 2px 5px 2px #dfdfdfff;
20
+ transition: all 0.3s 0s;
21
+ }
22
+
23
+ .left-section {
24
+ display: flex;
25
+ flex-direction: row;
26
+ gap: 1rem;
27
+ flex-grow: 1;
28
+ }
29
+
30
+ .left-section > img,
31
+ .missing-image {
32
+ min-height: 100%;
33
+ max-height: 100px;
34
+ aspect-ratio: 1;
35
+ border-radius: 6px;
36
+ overflow: hidden;
37
+ }
38
+
39
+ .missing-image {
40
+ background-color: rgb(222, 222, 222);
41
+ }
42
+
43
+ .two-row {
44
+ display: flex;
45
+ flex-direction: column;
46
+ }
47
+
48
+ .two-row :nth-child(1) {
49
+ font-size: 20px;
50
+ }
51
+
52
+ .two-row :nth-child(2) {
53
+ font-size: 14px;
54
+ }
55
+
56
+ .two-row > * {
57
+ margin: 0;
58
+ }
59
+
60
+ .remove-button {
61
+ color: transparent !important;
62
+ transition: all 0.2s !important;
63
+ }
64
+
65
+ .product-card:hover .remove-button {
66
+ color: red !important;
67
+ }
@@ -0,0 +1,80 @@
1
+ import "./CartProductCard.css";
2
+ import { Tooltip, IconButton, Typography } from "@mui/material";
3
+ import CloseIcon from "@mui/icons-material/Close";
4
+ import React from "react";
5
+ import { CartProduct } from "../../types";
6
+ import { removeFromCart } from "../../utils/storage";
7
+
8
+ export function CartProductCard({
9
+ product,
10
+ i,
11
+ price,
12
+ disableProductLink = false,
13
+ }: {
14
+ product: CartProduct;
15
+ i: number;
16
+ price: any;
17
+ disableProductLink?: boolean;
18
+ }) {
19
+ const [hoverDelete, setHoverDelete] = React.useState(false);
20
+ console.log(product);
21
+ return (
22
+ <a
23
+ className="product-card"
24
+ href={
25
+ disableProductLink || hoverDelete
26
+ ? undefined
27
+ : `/product/${product.product_id}`
28
+ }
29
+ >
30
+ <div className="left-section">
31
+ {product.image ? <img /> : <div className="missing-image"></div>}
32
+ <div className="two-row">
33
+ <Typography>{product.name}</Typography>
34
+ <Typography>
35
+ {price
36
+ ? `$${(price.unit_amount / 100).toFixed(2)}${
37
+ price.recurring
38
+ ? `/${
39
+ price.recurring.interval_count > 1
40
+ ? `${price.recurring.interval_count} `
41
+ : ""
42
+ }${price.recurring.interval}${
43
+ price.recurring.interval_count > 1 ? "s" : ""
44
+ }`
45
+ : ""
46
+ }`
47
+ : "Loading price..."}
48
+ </Typography>
49
+ </div>
50
+ </div>
51
+ <div
52
+ style={{
53
+ display: "flex",
54
+ flexGrow: 1,
55
+ justifyContent: "center",
56
+ alignItems: "center",
57
+ }}
58
+ >
59
+ <Tooltip
60
+ title="Remove from cart"
61
+ onMouseEnter={() => {
62
+ setHoverDelete(true);
63
+ }}
64
+ onMouseLeave={() => {
65
+ setHoverDelete(false);
66
+ }}
67
+ >
68
+ <IconButton
69
+ className="remove-button"
70
+ onClick={() => {
71
+ removeFromCart(i);
72
+ }}
73
+ >
74
+ <CloseIcon />
75
+ </IconButton>
76
+ </Tooltip>
77
+ </div>
78
+ </a>
79
+ );
80
+ }
@@ -1,6 +1,6 @@
1
1
  import { AddCard, type AddCardProps } from "./Billing";
2
2
  import Order from "./Order";
3
- import { postCheckout } from "../utils/api";
3
+ import { postCheckout } from "../../utils/api";
4
4
  import React from "react";
5
5
  import { Alert, Container, Typography, CircularProgress } from "@mui/material";
6
6
 
@@ -8,12 +8,14 @@ type CheckoutProps = AddCardProps & {
8
8
  order: any;
9
9
  apiBaseUrl: string;
10
10
  invoiceId?: string;
11
+ onPlacement?: () => void;
11
12
  };
12
13
 
13
14
  export default function Checkout({
14
15
  order,
15
16
  apiBaseUrl,
16
17
  invoiceId,
18
+ onPlacement,
17
19
  }: CheckoutProps) {
18
20
  const currentUrl = new URL(window.location.href);
19
21
  const [isSubmitting, setIsSubmitting] = React.useState(false);
@@ -34,10 +36,13 @@ export default function Checkout({
34
36
  } else {
35
37
  // success
36
38
  setSuccessData(data);
39
+ onPlacement && onPlacement();
37
40
  // Get the current URL
38
41
  const currentUrl = new URL(window.location.href);
39
42
  // Set the query parameter 'showReceipt' to '1'
40
43
  currentUrl.searchParams.set("showReceipt", "1");
44
+ currentUrl.searchParams.set("orderId", order.id);
45
+ currentUrl.searchParams.set("orderRef", order.reference);
41
46
 
42
47
  // Update the browser's URL and history without refreshing the page
43
48
  window.history.pushState({}, "", currentUrl);
@@ -47,7 +52,8 @@ export default function Checkout({
47
52
 
48
53
  if (
49
54
  successData !== null ||
50
- currentUrl.searchParams.get("showReceipt") ||
55
+ // currentUrl.searchParams.get("showReceipt")
56
+ order.charge_id ||
51
57
  !["pending", "awaiting_payment"].includes(order.status)
52
58
  ) {
53
59
  const print = (
@@ -57,6 +63,7 @@ export default function Checkout({
57
63
  window.print();
58
64
  return false;
59
65
  }}
66
+ style={{ textDecoration: "underline" }}
60
67
  >
61
68
  print
62
69
  </a>
@@ -73,10 +80,10 @@ export default function Checkout({
73
80
  );
74
81
  return (
75
82
  <>
76
- <Order order={order} invoiceId={invoiceId} />
77
83
  <Container>
78
84
  <Alert severity="success">{text}</Alert>
79
85
  </Container>
86
+ <Order order={order} invoiceId={invoiceId} />
80
87
  </>
81
88
  );
82
89
  }
package/src/index.ts CHANGED
@@ -4,14 +4,17 @@ import {
4
4
  addToCart,
5
5
  removeFromCart,
6
6
  clearCart,
7
+ setOrder,
7
8
  } from "./utils/storage";
8
9
  import type { Cart, CartProduct } from "./types";
9
10
  import AddToCartForm from "./AddToCartForm";
10
- import { AddCard, AddCardProps } from "./checkout/Billing";
11
- import Order from "./checkout/Order";
12
- import Checkout from "./checkout";
11
+ import { AddCard, AddCardProps } from "./checkout/components/Billing";
12
+ import Order from "./checkout/components/Order";
13
+ import Checkout from "./checkout/components";
13
14
  import "react-credit-cards-2/dist/es/styles-compiled.css";
14
15
  import "./index.css";
16
+ import ProductCard from "./ProductCard";
17
+ import CheckoutList from "./checkout/CheckoutList";
15
18
 
16
19
  export {
17
20
  Checkout,
@@ -20,10 +23,13 @@ export {
20
23
  type AddCardProps,
21
24
  cartStore,
22
25
  CartButton,
23
- Cart,
24
- CartProduct,
26
+ type Cart,
27
+ type CartProduct,
25
28
  addToCart,
26
29
  removeFromCart,
27
30
  clearCart,
28
31
  AddToCartForm,
32
+ setOrder,
33
+ ProductCard,
34
+ CheckoutList,
29
35
  };
package/src/types.d.ts CHANGED
@@ -3,13 +3,19 @@ export interface Cart {
3
3
  loggedIn: boolean;
4
4
  user_id?: number | string;
5
5
  };
6
- cart: CartProduct[];
6
+ items: CartProduct[];
7
+ order?: {
8
+ id: number;
9
+ reference: string;
10
+ };
7
11
  }
8
12
  export type CartProduct = {
9
- id: number | string;
10
- name: string;
13
+ product_id: number | string;
14
+ price_id: number | string;
11
15
  quantity?: number;
12
- price?: number;
16
+ // Used in local cart, delete when sent to api
17
+ name: string;
18
+ image?: string;
13
19
  };
14
20
 
15
21
  type CartContextType = {
@@ -29,3 +35,22 @@ export type PathDetailsType = {
29
35
  baseUrl?: string;
30
36
  userId?: string | number;
31
37
  };
38
+
39
+ export type Product = {
40
+ id: string | number;
41
+ name: string;
42
+ /** v Parses to @type {ProductPricing} v */
43
+ pricing: string;
44
+ description?: string;
45
+ } & unknown;
46
+
47
+ export type ProductPricing = {
48
+ id: number | string;
49
+ unit_amount: number; // in cents
50
+ recurring?: {
51
+ interval: "month" | "week" | "day";
52
+ interval_count: number;
53
+ };
54
+ tier_label?: string;
55
+ tier_description?: string;
56
+ };
package/src/utils/api.ts CHANGED
@@ -62,7 +62,6 @@ export async function postCheckout(
62
62
  if (invoiceId !== undefined) {
63
63
  url.searchParams.set("invoice_id", invoiceId);
64
64
  }
65
- console.log(body);
66
65
  const res = await fetch(url, postInit(body));
67
66
  return await dataOrResponse(res);
68
67
  }
@@ -23,7 +23,7 @@ export function cartAuthHandler(
23
23
  }
24
24
 
25
25
  // login
26
- if (cart.cart.length > 0) {
26
+ if (cart.items.length > 0) {
27
27
  setCart((c) => ({
28
28
  ...c,
29
29
  authentication: {
@@ -39,7 +39,7 @@ export function cartAuthHandler(
39
39
  if (!c2) return;
40
40
 
41
41
  setCart({
42
- cart: JSON.parse(c2.cart),
42
+ items: JSON.parse(c2.cart),
43
43
  authentication: {
44
44
  loggedIn: true,
45
45
  user_id: c2.user_id,
@@ -4,7 +4,7 @@ export const defaultCartValue: Cart = {
4
4
  authentication: {
5
5
  loggedIn: false,
6
6
  },
7
- cart: [],
7
+ items: [],
8
8
  };
9
9
 
10
10
  export function fetchFromCartApi(