@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.
@@ -1,272 +0,0 @@
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
- tax_rate_id,
18
- }: {
19
- apiBaseUrl: string;
20
- emptyCartLink?: string;
21
- disableProductLink?: boolean;
22
- tax_rate_id?: number | string;
23
- }) {
24
- const cart = JSON.parse(useStore(cartStore));
25
-
26
- const [prices, setPrices] = React.useState({});
27
- const [total, setTotal] = React.useState("Loading prices...");
28
-
29
- const [status, setStatus] = React.useState(0);
30
- const [order, setOrder] = React.useState(undefined);
31
-
32
- React.useEffect(() => {
33
- const params = new URLSearchParams(window.location.search);
34
-
35
- const sr = params.get("showReceipt");
36
- const oid = params.get("orderId");
37
- const or = params.get("orderRef");
38
- if (oid && or) {
39
- fetch(`${apiBaseUrl}/api/ecommerce/orders/${oid}/reference/${or}`).then(
40
- async (res) =>
41
- await res.json().then((order) => {
42
- setOrder(order);
43
- setStatus(
44
- order.charge_id ||
45
- !["pending", "awaiting_payment"].includes(order.status)
46
- ? 2
47
- : 1
48
- );
49
- })
50
- );
51
- }
52
- }, []);
53
-
54
- // build pricing list
55
- React.useEffect(() => {
56
- // filter out prices that have already been queried
57
- const pricesToGet = cart.items
58
- .map((c) => c.price_id)
59
- .filter(
60
- (pId) => Object.keys(prices).findIndex((pKey) => pKey == pId) === -1
61
- );
62
- if (pricesToGet.length === 0) return;
63
-
64
- const url = `${apiBaseUrl}/api/ecommerce/price?filter={'ids':[${pricesToGet.join(
65
- ","
66
- )}]}`;
67
- fetch(url, {
68
- method: "GET",
69
- headers: {
70
- "Content-Type": "application/json",
71
- },
72
- })
73
- .then((res) =>
74
- res.json().then((data) => {
75
- const pricingData = { ...prices };
76
-
77
- data.forEach((p) => {
78
- pricingData[p.id] = p;
79
- });
80
- setPrices(pricingData);
81
- })
82
- )
83
- .catch(() => {});
84
- }, [cart]);
85
-
86
- React.useEffect(() => {
87
- setTotal(
88
- `$${(
89
- cart.items
90
- .map((product) => prices[product.price_id]?.unit_amount)
91
- .reduce((p, c) => (c ? p + c : p), 0) / 100
92
- ).toFixed(2)}`
93
- );
94
- }, [cart, prices]);
95
-
96
- if (status === 0 && cart.items.length === 0)
97
- return (
98
- <div
99
- style={{
100
- width: "100%",
101
- display: "flex",
102
- alignItems: "center",
103
- flexDirection: "column",
104
- gap: 8,
105
- }}
106
- >
107
- <Typography style={{ fontSize: 32, fontWeight: 600 }}>
108
- Cart is empty
109
- </Typography>
110
- <a className="shopping-button" href={`/${emptyCartLink}`}>
111
- <Typography>BACK</Typography>
112
- </a>
113
- </div>
114
- );
115
-
116
- return (
117
- <div>
118
- <StatusBar status={status} />
119
- <div>
120
- {status === 0 && (
121
- <CartSection
122
- total={total}
123
- setStatus={setStatus}
124
- cart={cart}
125
- prices={prices}
126
- apiBaseUrl={apiBaseUrl}
127
- setOrder={setOrder}
128
- disableProductLink={disableProductLink}
129
- tax_rate_id={tax_rate_id}
130
- />
131
- )}
132
- {status > 0 && order && (
133
- <Checkout
134
- apiBaseUrl={apiBaseUrl}
135
- order={order}
136
- onPlacement={() => {
137
- clearCart();
138
- setStatus(2);
139
- }}
140
- />
141
- )}
142
- </div>
143
- </div>
144
- );
145
- }
146
-
147
- function CartSection({
148
- total,
149
- setStatus,
150
- cart,
151
- prices,
152
- apiBaseUrl,
153
- tax_rate_id,
154
- setOrder,
155
- disableProductLink,
156
- }) {
157
- function createOrder() {
158
- // If an order has already been created and hasn't been changed, get the previous order.
159
- if (cart.order && cart.order.id && cart.order.reference) {
160
- fetch(
161
- `${apiBaseUrl}/api/ecommerce/orders/${cart.order.id}/reference/${cart.order.reference}`
162
- ).then((res) =>
163
- res.json().then((order) => {
164
- setOrder(order);
165
- const currentUrl = new URL(window.location.href);
166
- currentUrl.searchParams.set("orderId", order.id);
167
- currentUrl.searchParams.set("orderRef", order.reference);
168
- window.history.pushState({}, "", currentUrl);
169
- setStatus(1);
170
- })
171
- );
172
- return;
173
- }
174
-
175
- // Otherwise, create a new order.
176
- fetch(apiBaseUrl + "/api/ecommerce/orders", {
177
- method: "POST",
178
- headers: {
179
- "Content-Type": "application/json",
180
- },
181
- body: JSON.stringify({
182
- customer_id: 1,
183
- cart_data: {
184
- tax_rate_id:
185
- typeof tax_rate_id === "string"
186
- ? Number.parseInt(tax_rate_id)
187
- : tax_rate_id,
188
- items: cart.items.map((li) => ({
189
- ...li,
190
- name: undefined,
191
- image: undefined,
192
- })),
193
- },
194
- }),
195
- }).then((res) =>
196
- res.json().then((order) => {
197
- if (!order) throw "Missing order";
198
- setOrder(order);
199
- cartSetOrder({ id: order.id, reference: order.reference });
200
- const currentUrl = new URL(window.location.href);
201
- currentUrl.searchParams.set("orderId", order.id);
202
- currentUrl.searchParams.set("orderRef", order.reference);
203
- window.history.pushState({}, "", currentUrl);
204
- setStatus(1);
205
- })
206
- );
207
- }
208
-
209
- return (
210
- <div style={{ display: "flex", justifyContent: "center" }}>
211
- <div
212
- style={{
213
- display: "flex",
214
- flexDirection: "column",
215
- width: 600,
216
- alignItems: "center",
217
- boxShadow: "0px 2px 5px 2px #dfdfdfff",
218
- margin: "4px",
219
- padding: "2rem 0",
220
- borderRadius: "8px",
221
- }}
222
- >
223
- <Typography style={{ fontSize: "30px", fontWeight: "bold" }}>
224
- Checkout
225
- </Typography>
226
- <Tooltip title="Total is pre-tax and includes the first payment for recurring payments.">
227
- <Typography style={{ fontSize: "20px" }}>Total: {total}</Typography>
228
- </Tooltip>
229
- {/* <Button variant="contained" sx={{ mt: 2 }} onClick={createOrder}>
230
- Confirm Order
231
- </Button> */}
232
- <div className="checkout-list">
233
- {cart.items.map((p, i) => (
234
- <CartProductCard
235
- product={p}
236
- i={i}
237
- price={prices[p.price_id]}
238
- disableProductLink={disableProductLink}
239
- />
240
- ))}
241
- </div>
242
- <Button variant="contained" sx={{ mt: 2 }} onClick={createOrder}>
243
- Confirm Order
244
- </Button>
245
- </div>
246
- </div>
247
- );
248
- }
249
-
250
- function StatusBar({ status }) {
251
- return (
252
- <div
253
- style={{
254
- display: "flex",
255
- alignItems: "center",
256
- flexDirection: "column",
257
- }}
258
- >
259
- <div id="status-bar">
260
- <Typography className="status-text active">REVIEW</Typography>
261
- <div className={"status-bar" + (status > 0 ? " active" : "")}></div>
262
- <Typography className={"status-text" + (status > 0 ? " active" : "")}>
263
- PAYMENT
264
- </Typography>
265
- <div className={"status-bar" + (status > 1 ? " active" : "")}></div>
266
- <Typography className={"status-text" + (status > 1 ? " active" : "")}>
267
- ORDER PLACED
268
- </Typography>
269
- </div>
270
- </div>
271
- );
272
- }
@@ -1,353 +0,0 @@
1
- import { useFormik, FormikProps } from "formik";
2
- import * as Yup from "yup";
3
- import { ChangeEventHandler, useEffect } from "react";
4
- import { PropsWithChildren, useState } from "react";
5
- import {
6
- AddressCityField,
7
- AddressOrganizationNameField,
8
- AddressCountryField,
9
- AddressPostalCodeField,
10
- AddressRegionField,
11
- AddressStreetField,
12
- AddressEmailField,
13
- AddressValues,
14
- } from "./Address";
15
- import {
16
- allCountries,
17
- allCountriesReverse,
18
- getPostalCodeDefault,
19
- } from "@springmicro/utils/address";
20
- import { Box, Button, Container, TextField, Typography } from "@mui/material";
21
- import Cards from "react-credit-cards-2";
22
- import { FocusEventHandler } from "react";
23
- import {
24
- formatCreditCardNumber,
25
- formatCVC,
26
- formatExpirationDate,
27
- splitMonthYear,
28
- expiryDateHasNotPassed,
29
- } from "@springmicro/utils/payment";
30
-
31
- // moved to index.ts
32
- // import "react-credit-cards-2/dist/es/styles-compiled.css";
33
- import { StripeLogoLink } from "./ProviderLogos";
34
-
35
- type CCFocus = "number" | "name" | "cvc" | "expiry";
36
-
37
- export type CreditCardValues = {
38
- cvc: string;
39
- expiry: string;
40
- focus: CCFocus;
41
- name: string;
42
- number: string;
43
- };
44
-
45
- export type AddCardProps = {
46
- contact?: {
47
- organization?: string;
48
- email: string;
49
- first_name: string;
50
- last_name: string;
51
- };
52
- stacked?: boolean;
53
- error?: string;
54
- address?: boolean;
55
- onSubmit?: (
56
- values: CreditCardValues & Partial<AddressValues>
57
- ) => Promise<any>;
58
- };
59
-
60
- export const AddCard = ({
61
- contact,
62
- error,
63
- stacked = false,
64
- address = true,
65
- onSubmit,
66
- }: AddCardProps) => {
67
- const [disabled, setDisabled] = useState(true);
68
-
69
- const initialValuesBase = {
70
- cvc: "",
71
- expiry: "",
72
- focus: "number",
73
- name: contact ? `${contact.first_name} ${contact.last_name}` : "",
74
- number: "",
75
- } as CreditCardValues;
76
- const initialCountry = "US";
77
- const initialValuesAddress = {
78
- country: initialCountry ? allCountries[initialCountry] : "",
79
- email: contact?.email ?? "",
80
- region: "",
81
- line1: "",
82
- line2: "",
83
- organization: contact?.organization ?? "",
84
- city: "",
85
- postal_code: getPostalCodeDefault(initialCountry),
86
- } as AddressValues;
87
-
88
- // const initialValues = { ...initialValuesBase, ...initialValuesAddress }
89
- const initialValues = address
90
- ? { ...initialValuesBase, ...initialValuesAddress }
91
- : initialValuesBase;
92
-
93
- const validationSchemaBase = {
94
- expiry: Yup.string()
95
- // .length(7, "Expiry date must be of the format MM/YY.")
96
- .test("exp-date", "Expiry date has already passed.", (string) => {
97
- if (string?.length === 5) {
98
- if (string.indexOf("/") !== 2) return false;
99
- const [month, year] = splitMonthYear(string);
100
- return expiryDateHasNotPassed(month, year);
101
- } else {
102
- return true;
103
- }
104
- })
105
- .required("Expiry date is required."),
106
- number: Yup.string().required("Card Number is required."),
107
- name: Yup.string().required("Cardholder Name is required."),
108
- cvc: Yup.string().required("CVC is required."),
109
- };
110
-
111
- const validationSchemaAddress = {
112
- country: Yup.string().required("Country is required."),
113
- email: Yup.string().email().required("Email is required."),
114
- line1: Yup.string().required("Street Address 1 is required."),
115
- city: Yup.string().required("City is required."),
116
- postal_code: Yup.string().required("Postal Code is required."),
117
- };
118
-
119
- const formik = useFormik({
120
- initialValues,
121
- validationSchema: Yup.object(
122
- address
123
- ? {
124
- ...validationSchemaBase,
125
- ...validationSchemaAddress,
126
- }
127
- : validationSchemaBase
128
- ),
129
- onSubmit: async (v, helpers) => {
130
- setDisabled(true);
131
- const values = { ...v };
132
- if (disabled) {
133
- setDisabled(false);
134
- throw new Error("Attempted to submit an invalid form.");
135
- }
136
- if (onSubmit) {
137
- if (address) {
138
- // match backend
139
- // @ts-expect-error value exists
140
- values["street_address"] = values.line2
141
- ? // @ts-expect-error value exists
142
- `${values.line1}\n${values.line2}`
143
- : // @ts-expect-error value exists
144
- values.line1;
145
- // @ts-expect-error value exists
146
- values["locality"] = values.city;
147
- // @ts-expect-error value exists
148
- delete values.line1;
149
- // @ts-expect-error value exists
150
- delete values.line2;
151
- // @ts-expect-error value exists
152
- delete values.city;
153
- // @ts-expect-error value exists
154
- if (Object.keys(allCountriesReverse).includes(values.country)) {
155
- // @ts-expect-error value exists
156
- values.country = allCountriesReverse[values.country];
157
- }
158
- }
159
- values["card_number"] = values.number;
160
- values["expiry_date"] = values.expiry;
161
- delete values.focus;
162
- delete values.expiry;
163
- delete values.number;
164
-
165
- const res = await onSubmit(values);
166
- setDisabled(false);
167
- } else {
168
- setDisabled(false);
169
- }
170
- },
171
- });
172
-
173
- const handleInputFocus: FocusEventHandler<HTMLInputElement> = (e) => {
174
- const focusedEl = (e.target as HTMLElement).getAttribute("name") as CCFocus;
175
- if (focusedEl) {
176
- formik.setValues({
177
- ...formik.values,
178
- focus: focusedEl,
179
- });
180
- }
181
- };
182
-
183
- const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
184
- if (e.target.name === "card_number") {
185
- e.target.value = formatCreditCardNumber(e.target.value);
186
- } else if (e.target.name === "expiry") {
187
- e.target.value = formatExpirationDate(e.target.value);
188
- } else if (e.target.name === "cvc") {
189
- e.target.value = formatCVC(e.target.value);
190
- }
191
- formik.handleChange(e);
192
- };
193
-
194
- useEffect(() => {
195
- setDisabled(Object.keys(formik.errors).length > 0);
196
- }, [formik.errors]);
197
-
198
- return (
199
- <Container id="paymentMethodForm">
200
- <form
201
- onSubmit={(e) => {
202
- e.preventDefault();
203
- formik.handleSubmit(e);
204
- }}
205
- >
206
- <Box
207
- sx={
208
- stacked
209
- ? {}
210
- : {
211
- display: "flex",
212
- flexDirection: { xs: "column", sm: "row" },
213
- gap: "1em",
214
- }
215
- }
216
- >
217
- <Cards
218
- {...(formik.values as CreditCardValues)}
219
- focused={formik.values.focus || "number"}
220
- />
221
-
222
- <div>
223
- <TextField
224
- sx={stacked ? { mt: 2 } : {}}
225
- fullWidth
226
- type="tel"
227
- name="number"
228
- label="Card Number"
229
- required
230
- onBlur={formik.handleBlur}
231
- onChange={handleInputChange}
232
- onFocus={handleInputFocus}
233
- variant="outlined"
234
- value={formik.values.number}
235
- error={Boolean(formik.touched.number && formik.errors.number)}
236
- helperText={formik.touched.number && formik.errors.number}
237
- />
238
- <TextField
239
- sx={{ mt: 2 }}
240
- fullWidth
241
- type="text"
242
- name="name"
243
- label="Name on Card"
244
- required
245
- onBlur={formik.handleBlur}
246
- onChange={handleInputChange}
247
- onFocus={handleInputFocus}
248
- variant="outlined"
249
- value={formik.values.name}
250
- error={Boolean(formik.touched.name && formik.errors.name)}
251
- helperText={formik.touched.name && formik.errors.name}
252
- />
253
- <TextField
254
- sx={{ mt: 2, width: "calc(50% - 8px)", mr: 1 }}
255
- type="tel"
256
- name="expiry"
257
- label="Valid Thru"
258
- placeholder="MM/YY"
259
- inputProps={{ pattern: "[0-9]{2}/[0-9]{2}" }}
260
- required
261
- onBlur={formik.handleBlur}
262
- onChange={handleInputChange}
263
- onFocus={handleInputFocus}
264
- variant="outlined"
265
- value={formik.values.expiry}
266
- error={Boolean(formik.touched.expiry && formik.errors.expiry)}
267
- helperText={
268
- (formik.touched.expiry && formik.errors.expiry) || "MM/YY"
269
- }
270
- />
271
- <TextField
272
- sx={{ mt: 2, width: "calc(50% - 8px)", ml: 1 }}
273
- type="tel"
274
- name="cvc"
275
- label="CVC"
276
- required
277
- onBlur={formik.handleBlur}
278
- onChange={handleInputChange}
279
- onFocus={handleInputFocus}
280
- variant="outlined"
281
- value={formik.values.cvc}
282
- error={Boolean(formik.touched.cvc && formik.errors.cvc)}
283
- helperText={formik.touched.cvc && formik.errors.cvc}
284
- />
285
- </div>
286
- {/* {formik.errors.submit && (
287
- <Typography color="error" sx={{ mt: 2 }} variant="body2">
288
- {formik.errors.submit}
289
- </Typography>
290
- )} */}
291
- </Box>
292
- <Box>
293
- {!address ? null : (
294
- <Box>
295
- <Typography variant="h6" sx={{ mt: 2, mb: 3 }}>
296
- Billing Address
297
- </Typography>
298
- <AddressCountryField formik={formik} name="country" />
299
- <AddressEmailField formik={formik} name="email" sx={{ mt: 2 }} />
300
- <AddressOrganizationNameField
301
- sx={{ mt: 2 }}
302
- formik={formik}
303
- name="organization"
304
- />
305
- <AddressStreetField
306
- sx={{ mt: 2 }}
307
- formik={formik}
308
- name="line1"
309
- lineNo="1"
310
- />
311
- <AddressStreetField
312
- sx={{ mt: 2 }}
313
- formik={formik}
314
- name="line2"
315
- lineNo="2"
316
- required={false}
317
- />
318
- <AddressCityField sx={{ mt: 2 }} formik={formik} name="city" />
319
- <AddressRegionField
320
- sx={{ mt: 2 }}
321
- formik={formik}
322
- name="region"
323
- />
324
- <AddressPostalCodeField
325
- sx={{ mt: 2 }}
326
- formik={formik}
327
- name="postal_code"
328
- />
329
- </Box>
330
- )}
331
- </Box>
332
- {error && (
333
- <Typography color="error" sx={{ mt: 2 }} variant="body2">
334
- {error}
335
- </Typography>
336
- )}
337
- <Button
338
- size="large"
339
- sx={{ mt: 3 }}
340
- variant="contained"
341
- // onClick={formik.handleSubmit}
342
- type="submit"
343
- disabled={disabled}
344
- >
345
- Complete and pay
346
- </Button>
347
- <Box sx={{ mt: 1 }}>
348
- <StripeLogoLink />
349
- </Box>
350
- </form>
351
- </Container>
352
- );
353
- };