@springmicro/cart 0.3.4 → 0.3.5

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.
@@ -43,9 +43,9 @@ export function AddressCountryField({ formik, name, sx }: AddressFieldProps) {
43
43
  return (
44
44
  <Autocomplete
45
45
  sx={{ width: 300, ...sx }}
46
- options={Object.values(allCountries)}
46
+ options={Object.keys(allCountries)}
47
47
  autoHighlight
48
- getOptionLabel={(option) => option}
48
+ getOptionLabel={(option) => allCountries[option]}
49
49
  value={formik.values[name] as string}
50
50
  onChange={(e, newValue) => {
51
51
  // console.log(newValue)
@@ -75,14 +75,14 @@ export function AddressCountryField({ formik, name, sx }: AddressFieldProps) {
75
75
  loading="lazy"
76
76
  width="20"
77
77
  src={`https://flagcdn.com/w20/${allCountriesReverse[
78
- option
78
+ allCountries[option]
79
79
  ].toLowerCase()}.png`}
80
80
  srcSet={`https://flagcdn.com/w40/${allCountriesReverse[
81
- option
81
+ allCountries[option]
82
82
  ].toLowerCase()}.png 2x`}
83
83
  alt=""
84
84
  />
85
- {option}
85
+ {allCountries[option]}
86
86
  </Box>
87
87
  )}
88
88
  renderInput={(params) => (
@@ -100,9 +100,7 @@ export function AddressCountryField({ formik, name, sx }: AddressFieldProps) {
100
100
  export function AddressRegionField({ formik, name, sx }: AddressFieldProps) {
101
101
  const [addressRegionFieldOptional, setAddressRegionFieldOptional] =
102
102
  useState<boolean>(false);
103
- const country2code = allCountriesReverse[
104
- formik.values["country"] as string
105
- ] as Alpha2Code;
103
+ const country2code = formik.values["country"] as Alpha2Code;
106
104
 
107
105
  useEffect(() => {
108
106
  setAddressRegionFieldOptional(
@@ -233,9 +231,7 @@ export function AddressPostalCodeField({
233
231
  name,
234
232
  sx,
235
233
  }: AddressFieldProps) {
236
- const country2code = allCountriesReverse[
237
- formik.values["country"] as string
238
- ] as Alpha2Code;
234
+ const country2code = formik.values["country"] as Alpha2Code;
239
235
 
240
236
  React.useEffect(() => {
241
237
  if (getPostalCodeDefault(country2code)) {
@@ -0,0 +1,151 @@
1
+ import { Box, Button, Tooltip, Typography } from "@mui/material";
2
+ import { CartProductCard } from "./CartProductCard";
3
+
4
+ function formatPrice(cents) {
5
+ if (typeof cents === "string") return cents;
6
+ return `$${(cents / 100).toFixed(2)}`;
7
+ }
8
+
9
+ export function CartList({
10
+ status,
11
+ cart,
12
+ subtotal,
13
+ tax,
14
+ shipping,
15
+ discount,
16
+ prices,
17
+ disableMissingImage,
18
+ disableProductLink,
19
+ formik,
20
+ formDisabled,
21
+ formError,
22
+ }) {
23
+ return (
24
+ <form
25
+ style={{ flexGrow: 1 }}
26
+ onSubmit={(e) => {
27
+ e.preventDefault();
28
+ formik.handleSubmit(e);
29
+ }}
30
+ >
31
+ <Box
32
+ sx={{
33
+ display: "flex",
34
+ justifyContent: "center",
35
+ flexGrow: 1,
36
+ mb: { xs: 4, xl: undefined },
37
+ }}
38
+ >
39
+ <Box
40
+ sx={{
41
+ display: "flex",
42
+ flexDirection: "column",
43
+ maxWidth: 650,
44
+ alignItems: "center",
45
+ boxShadow: "0px 2px 5px 2px #dfdfdfff",
46
+ margin: "4px",
47
+ padding: "2rem 0",
48
+ borderRadius: "8px",
49
+ flexGrow: 1,
50
+ }}
51
+ >
52
+ <Typography style={{ fontSize: "30px", fontWeight: "bold" }}>
53
+ Checkout
54
+ </Typography>
55
+ <Box
56
+ sx={{
57
+ width: "50%",
58
+ display: "flex",
59
+ justifyContent: "space-between",
60
+ }}
61
+ >
62
+ <Typography sx={{ fontSize: "20px", p: 1 }}>
63
+ Subtotal: {formatPrice(subtotal)}
64
+ </Typography>
65
+ {tax.error ? (
66
+ <Tooltip
67
+ disableInteractive
68
+ title={`Tax could not be calculated. Error: ${tax.error}`}
69
+ >
70
+ <Typography
71
+ sx={{
72
+ fontSize: "20px",
73
+ bgcolor: "#ff000040",
74
+ borderRadius: 2,
75
+ p: 1,
76
+ textDecoration: "underline dotted",
77
+ cursor: "help",
78
+ }}
79
+ >
80
+ Taxes: {formatPrice(tax.tax_amount)}
81
+ </Typography>
82
+ </Tooltip>
83
+ ) : (
84
+ <Typography sx={{ fontSize: "20px", p: 1 }}>
85
+ Taxes: {formatPrice(tax.tax_amount)}
86
+ </Typography>
87
+ )}
88
+ </Box>
89
+ {(discount != 0 || shipping != 0) && (
90
+ <Box
91
+ sx={{
92
+ width: "50%",
93
+ display: "flex",
94
+ justifyContent: "space-between",
95
+ }}
96
+ >
97
+ <Typography sx={{ fontSize: "20px", p: 1 }}>
98
+ {shipping && <>Shipping: {formatPrice(shipping)}</>}
99
+ </Typography>
100
+ <Typography sx={{ fontSize: "20px", p: 1 }}>
101
+ {discount && <>Discount: {formatPrice(discount)}</>}
102
+ </Typography>
103
+ </Box>
104
+ )}
105
+ <Box>
106
+ <Typography style={{ fontSize: "26px" }}>
107
+ Total:{" "}
108
+ {typeof subtotal === "number"
109
+ ? formatPrice(
110
+ subtotal +
111
+ (tax.tax_amount === "TBD" ? 0 : tax.tax_amount) +
112
+ shipping -
113
+ discount
114
+ )
115
+ : subtotal}
116
+ </Typography>
117
+ </Box>
118
+ {formError && (
119
+ <Typography color="red">
120
+ Could not confirm payment. Your card info may have been entered
121
+ incorrectly.
122
+ </Typography>
123
+ )}
124
+ {status === 1 && (
125
+ <Button
126
+ variant="contained"
127
+ sx={{ mt: 2 }}
128
+ type="submit"
129
+ disabled={formDisabled}
130
+ color={formError ? "error" : undefined}
131
+ >
132
+ Confirm Order
133
+ </Button>
134
+ )}
135
+ <Box className="checkout-list">
136
+ {cart.items.map((p, i) => (
137
+ <CartProductCard
138
+ product={p}
139
+ i={i}
140
+ price={prices[p.price_id]}
141
+ disableProductLink={disableProductLink}
142
+ disableMissingImage={disableMissingImage}
143
+ disableModification={status != 0}
144
+ />
145
+ ))}
146
+ </Box>
147
+ </Box>
148
+ </Box>
149
+ </form>
150
+ );
151
+ }
@@ -58,7 +58,7 @@
58
58
  }
59
59
 
60
60
  .remove-button {
61
- color: transparent !important;
61
+ color: #ff000040 !important;
62
62
  transition: all 0.2s !important;
63
63
  }
64
64
 
@@ -10,14 +10,18 @@ export function CartProductCard({
10
10
  i,
11
11
  price,
12
12
  disableProductLink = false,
13
+ disableMissingImage = false,
14
+ disableModification = false,
13
15
  }: {
14
16
  product: CartProduct;
15
17
  i: number;
16
18
  price: any;
17
19
  disableProductLink?: boolean;
20
+ disableMissingImage?: boolean;
21
+ disableModification?: boolean;
18
22
  }) {
19
23
  const [hoverDelete, setHoverDelete] = React.useState(false);
20
- console.log(product);
24
+
21
25
  return (
22
26
  <a
23
27
  className="product-card"
@@ -28,7 +32,11 @@ export function CartProductCard({
28
32
  }
29
33
  >
30
34
  <div className="left-section">
31
- {product.image ? <img /> : <div className="missing-image"></div>}
35
+ {product.image ? (
36
+ <img />
37
+ ) : (
38
+ !disableMissingImage && <div className="missing-image" />
39
+ )}
32
40
  <div className="two-row">
33
41
  <Typography>{product.name}</Typography>
34
42
  <Typography>
@@ -56,24 +64,26 @@ export function CartProductCard({
56
64
  alignItems: "center",
57
65
  }}
58
66
  >
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);
67
+ {!disableModification && (
68
+ <Tooltip
69
+ title="Remove from cart"
70
+ onMouseEnter={() => {
71
+ setHoverDelete(true);
72
+ }}
73
+ onMouseLeave={() => {
74
+ setHoverDelete(false);
72
75
  }}
73
76
  >
74
- <CloseIcon />
75
- </IconButton>
76
- </Tooltip>
77
+ <IconButton
78
+ className="remove-button"
79
+ onClick={() => {
80
+ removeFromCart(i);
81
+ }}
82
+ >
83
+ <CloseIcon />
84
+ </IconButton>
85
+ </Tooltip>
86
+ )}
77
87
  </div>
78
88
  </a>
79
89
  );
@@ -0,0 +1,145 @@
1
+ import { AddCard, type AddCardProps } from "./AddCard";
2
+ import { postCheckout } from "../../utils/api";
3
+ import React from "react";
4
+ import {
5
+ Alert,
6
+ Container,
7
+ Typography,
8
+ CircularProgress,
9
+ TableContainer,
10
+ Table,
11
+ TableHead,
12
+ TableRow,
13
+ TableCell,
14
+ Paper,
15
+ TableBody,
16
+ } from "@mui/material";
17
+
18
+ type CheckoutProps = AddCardProps & {
19
+ order: any;
20
+ invoiceId?: string;
21
+ successData;
22
+ };
23
+
24
+ export default function Invoice({
25
+ order,
26
+ invoiceId,
27
+ successData,
28
+ }: CheckoutProps) {
29
+ const currentUrl = new URL(window.location.href);
30
+ const formatter = new Intl.NumberFormat("en-US", {
31
+ style: "currency",
32
+ currency: "USD",
33
+ });
34
+
35
+ if (
36
+ successData !== null ||
37
+ // currentUrl.searchParams.get("showReceipt")
38
+ order.charge_id ||
39
+ !["pending", "awaiting_payment"].includes(order.status)
40
+ ) {
41
+ const print = (
42
+ <a
43
+ href="#"
44
+ onClick={() => {
45
+ window.print();
46
+ return false;
47
+ }}
48
+ style={{ textDecoration: "underline" }}
49
+ >
50
+ print
51
+ </a>
52
+ );
53
+ const text =
54
+ successData !== null ? (
55
+ <>
56
+ Payment received! An email was sent to{" "}
57
+ <strong>{successData.billing_information.email}</strong>. You can also{" "}
58
+ {print} this page for your records.
59
+ </>
60
+ ) : (
61
+ <>Payment received! You can {print} this page for your records.</>
62
+ );
63
+ return (
64
+ <>
65
+ <Container>
66
+ <Alert severity="success">{text}</Alert>
67
+ </Container>
68
+ <Container>
69
+ <Typography variant="h4" gutterBottom>
70
+ {invoiceId ? `Invoice #${invoiceId}` : "Order"}
71
+ </Typography>
72
+ <Typography variant="subtitle1">
73
+ Reference: {order.reference}
74
+ </Typography>
75
+ <Typography variant="subtitle1">
76
+ Date: {new Date(order.date).toLocaleDateString()}
77
+ </Typography>
78
+ <Typography variant="subtitle1">Status: {order.status}</Typography>
79
+ {order.customer ? (
80
+ <Typography variant="subtitle1">
81
+ {order.customer.first_name} {order.customer.last_name}
82
+ </Typography>
83
+ ) : (
84
+ <Typography variant="subtitle1">
85
+ Customer ID: {order.customer_id}
86
+ </Typography>
87
+ )}
88
+
89
+ <TableContainer component={Paper} sx={{ my: 2 }}>
90
+ <Table>
91
+ <TableHead>
92
+ <TableRow>
93
+ <TableCell>Item ID</TableCell>
94
+ <TableCell>Name</TableCell>
95
+ <TableCell>Description</TableCell>
96
+ <TableCell>Quantity</TableCell>
97
+ <TableCell>Unit Price</TableCell>
98
+ <TableCell>Total Price</TableCell>
99
+ </TableRow>
100
+ </TableHead>
101
+ <TableBody>
102
+ {order.basket.map((item) => (
103
+ <TableRow key={item.item_id}>
104
+ <TableCell>{item.item_id}</TableCell>
105
+ <TableCell>{item.name}</TableCell>
106
+ <TableCell>{item.description || "-"}</TableCell>
107
+ <TableCell>{item.quantity}</TableCell>
108
+ <TableCell>
109
+ {formatter.format(item.unit_price_cents / 100)}
110
+ </TableCell>
111
+ <TableCell>
112
+ {formatter.format(
113
+ (item.quantity * item.unit_price_cents) / 100
114
+ )}
115
+ </TableCell>
116
+ </TableRow>
117
+ ))}
118
+ </TableBody>
119
+ </Table>
120
+ </TableContainer>
121
+ </Container>
122
+ </>
123
+ );
124
+ }
125
+
126
+ // return (
127
+ // <>
128
+ // <Order order={order} invoiceId={invoiceId} />
129
+ // {isSubmitting ? (
130
+ // <Container>
131
+ // <Typography>
132
+ // <CircularProgress color="primary" /> Submitting...
133
+ // </Typography>
134
+ // </Container>
135
+ // ) : (
136
+ // <AddCard
137
+ // contact={order.customer}
138
+ // onSubmit={onSubmit}
139
+ // PriceDetails={PriceDetails}
140
+ // />
141
+ // )
142
+ // }
143
+ // </>
144
+ // );
145
+ }
@@ -0,0 +1,32 @@
1
+ import { Box, IconButton, Typography } from "@mui/material";
2
+ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
3
+
4
+ export function StatusBar({ status, backlink = "" }) {
5
+ return (
6
+ <Box
7
+ style={{
8
+ display: "flex",
9
+ justifyContent: "center",
10
+ flexDirection: "row",
11
+ }}
12
+ >
13
+ <Box sx={{ flex: "1 1" }}>
14
+ <IconButton href={`/${backlink}`}>
15
+ <ArrowBackIcon />
16
+ </IconButton>
17
+ </Box>
18
+ <div id="status-bar">
19
+ <Typography className="status-text active">PAYMENT DETAILS</Typography>
20
+ <div className={"status-bar" + (status > 0 ? " active" : "")}></div>
21
+ <Typography className={"status-text" + (status > 0 ? " active" : "")}>
22
+ CONFIRMATION
23
+ </Typography>
24
+ <div className={"status-bar" + (status > 1 ? " active" : "")}></div>
25
+ <Typography className={"status-text" + (status > 1 ? " active" : "")}>
26
+ ORDER PLACED
27
+ </Typography>
28
+ </div>
29
+ <Box sx={{ flex: "1 1" }} />
30
+ </Box>
31
+ );
32
+ }
@@ -0,0 +1,161 @@
1
+ import "./ReviewCartAndCalculateTaxes.css";
2
+ import { useStore } from "@nanostores/react";
3
+ import { useEffect, useState } from "react";
4
+ import { Typography } from "@mui/material";
5
+ import { cartStore, clearCart } from "../utils/storage";
6
+ import { StatusBar } from "./components/StatusBar";
7
+ import ReviewAndCalculateTaxes from "./ReviewCartAndCalculateTaxes";
8
+ import Invoice from "./components/Invoice";
9
+
10
+ export default function Checkout({
11
+ apiBaseUrl,
12
+ taxProvider,
13
+ emptyCartLink = "",
14
+ disableProductLink = false,
15
+ disableMissingImage = false,
16
+ }: {
17
+ apiBaseUrl: string;
18
+ taxProvider: string;
19
+ emptyCartLink?: string;
20
+ disableProductLink?: boolean;
21
+ disableMissingImage?: boolean;
22
+ }) {
23
+ const cart = JSON.parse(useStore(cartStore));
24
+
25
+ const [prices, setPrices] = useState({});
26
+ const [subtotal, setSubtotal] = useState("Loading prices...");
27
+ const taxState = useState({ tax_amount: "TBD" });
28
+ const shipping = 0;
29
+ const discount = 0;
30
+
31
+ const [status, setStatus] = useState(0);
32
+ const [order, setOrder] = useState(undefined);
33
+ const [successData, setSuccessData] = useState(null);
34
+
35
+ useEffect(() => {
36
+ const params = new URLSearchParams(window.location.search);
37
+
38
+ const sr = params.get("showReceipt");
39
+ const oid = params.get("orderId");
40
+ const or = params.get("orderRef");
41
+ if (oid && or) {
42
+ fetch(`${apiBaseUrl}/api/ecommerce/orders/${oid}/reference/${or}`).then(
43
+ async (res) =>
44
+ await res.json().then((order) => {
45
+ setOrder(order);
46
+ setStatus(
47
+ order.charge_id ||
48
+ !["pending", "awaiting_payment"].includes(order.status)
49
+ ? 2
50
+ : 0 // normally this should be set to 1 but we need to ensure it sends card data to the payment provider and that data is likely lost on refresh.
51
+ );
52
+ })
53
+ );
54
+ }
55
+ }, []);
56
+
57
+ // build pricing list
58
+ useEffect(() => {
59
+ // filter out prices that have already been queried
60
+ const pricesToGet = cart.items
61
+ .map((c) => c.price_id)
62
+ .filter(
63
+ (pId) => Object.keys(prices).findIndex((pKey) => pKey == pId) === -1
64
+ );
65
+ if (pricesToGet.length === 0) return;
66
+
67
+ const url = `${apiBaseUrl}/api/ecommerce/price?filter={'ids':[${pricesToGet.join(
68
+ ","
69
+ )}]}`;
70
+ fetch(url, {
71
+ method: "GET",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ },
75
+ })
76
+ .then((res) =>
77
+ res.json().then((data) => {
78
+ const pricingData = { ...prices };
79
+
80
+ data.forEach((p) => {
81
+ pricingData[p.id] = p;
82
+ });
83
+ setPrices(pricingData);
84
+ })
85
+ )
86
+ .catch(() => {});
87
+ }, [cart]);
88
+
89
+ useEffect(() => {
90
+ setSubtotal(
91
+ cart.items
92
+ .map((product) => prices[product.price_id]?.unit_amount)
93
+ .reduce((p, c) => (c ? p + c : p), 0)
94
+ );
95
+ }, [cart, prices]);
96
+
97
+ if (status === 0 && cart.items.length === 0)
98
+ return (
99
+ <div
100
+ style={{
101
+ width: "100%",
102
+ display: "flex",
103
+ alignItems: "center",
104
+ flexDirection: "column",
105
+ gap: 8,
106
+ }}
107
+ >
108
+ <Typography style={{ fontSize: 32, fontWeight: 600 }}>
109
+ Cart is empty
110
+ </Typography>
111
+ <a className="shopping-button" href={`/${emptyCartLink}`}>
112
+ <Typography>BACK</Typography>
113
+ </a>
114
+ </div>
115
+ );
116
+
117
+ return (
118
+ <div>
119
+ <StatusBar status={status} />
120
+ <div>
121
+ {/**
122
+ *
123
+ * status === 0
124
+ * Manage cart and enter in payment details
125
+ *
126
+ * status === 1
127
+ * Review taxes and shipping
128
+ *
129
+ * status === 2
130
+ * Order has been place. Invoice for printing.
131
+ *
132
+ */}
133
+
134
+ {status != 2 && (
135
+ <ReviewAndCalculateTaxes
136
+ subtotal={subtotal}
137
+ discount={discount}
138
+ shipping={shipping}
139
+ taxProvider={taxProvider}
140
+ taxState={taxState}
141
+ statusState={[status, setStatus]}
142
+ cart={cart}
143
+ prices={prices}
144
+ apiBaseUrl={apiBaseUrl}
145
+ orderState={[order, setOrder]}
146
+ disableProductLink={disableProductLink}
147
+ disableMissingImage={disableMissingImage}
148
+ setSuccessData={setSuccessData}
149
+ onPlacement={() => {
150
+ clearCart();
151
+ setStatus(2);
152
+ }}
153
+ />
154
+ )}
155
+ {status === 2 && order !== undefined && (
156
+ <Invoice order={order} successData={successData} />
157
+ )}
158
+ </div>
159
+ </div>
160
+ );
161
+ }
package/src/index.ts CHANGED
@@ -8,17 +8,18 @@ import {
8
8
  } from "./utils/storage";
9
9
  import type { Cart, CartProduct } from "./types";
10
10
  import AddToCartForm from "./AddToCartForm";
11
- import { AddCard, AddCardProps } from "./checkout/components/Billing";
12
- import Order from "./checkout/components/Order";
13
- import Checkout from "./checkout/components";
11
+ import { AddCard, AddCardProps } from "./checkout/components/AddCard";
12
+ // import Order from "./checkout/components/Order";
13
+ import Invoice from "./checkout/components/Invoice";
14
14
  import "react-credit-cards-2/dist/es/styles-compiled.css";
15
15
  import "./index.css";
16
16
  import ProductCard from "./ProductCard";
17
- import CheckoutList from "./checkout/CheckoutList";
17
+ import ReviewCartAndCalculateTaxes from "./checkout/ReviewCartAndCalculateTaxes";
18
+ import Checkout from "./checkout";
18
19
 
19
20
  export {
20
21
  Checkout,
21
- Order,
22
+ Invoice,
22
23
  AddCard,
23
24
  type AddCardProps,
24
25
  cartStore,
@@ -31,5 +32,5 @@ export {
31
32
  AddToCartForm,
32
33
  setOrder,
33
34
  ProductCard,
34
- CheckoutList,
35
+ ReviewCartAndCalculateTaxes,
35
36
  };