@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@springmicro/cart",
3
3
  "private": false,
4
- "version": "0.3.4",
4
+ "version": "0.3.5",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -24,7 +24,7 @@
24
24
  "@nanostores/persistent": "^0.10.1",
25
25
  "@nanostores/query": "^0.3.3",
26
26
  "@nanostores/react": "^0.7.2",
27
- "@springmicro/utils": "0.3.4",
27
+ "@springmicro/utils": "0.3.5",
28
28
  "dotenv": "^16.4.5",
29
29
  "nanostores": "^0.10.3",
30
30
  "react": "^18.2.0",
@@ -49,5 +49,5 @@
49
49
  "vite-plugin-css-injected-by-js": "^3.5.1",
50
50
  "yup": "^1.4.0"
51
51
  },
52
- "gitHead": "291a6da39f3801c2d7993958d252a2ffa3f4ef1e"
52
+ "gitHead": "1bbf206d79047e5897172fb794fc876ad6c45498"
53
53
  }
@@ -44,7 +44,7 @@
44
44
  flex-direction: row;
45
45
  gap: 1rem;
46
46
  align-items: center;
47
- margin-left: 56px;
47
+ margin-left: -29px;
48
48
  margin-bottom: 1rem;
49
49
  margin-top: 2rem;
50
50
  font-size: 18px;
@@ -0,0 +1,366 @@
1
+ import "./ReviewCartAndCalculateTaxes.css";
2
+ import { Box, useTheme, useMediaQuery } from "@mui/material";
3
+ import { setOrder as cartSetOrder } from "../utils/storage";
4
+ import { AddCard, CCFocus, CreditCardValues } from "./components/AddCard";
5
+ import { CartList } from "./components/CartList";
6
+ import {
7
+ ChangeEventHandler,
8
+ FocusEventHandler,
9
+ useEffect,
10
+ useState,
11
+ } from "react";
12
+ import {
13
+ expiryDateHasNotPassed,
14
+ formatCreditCardNumber,
15
+ formatCVC,
16
+ formatExpirationDate,
17
+ splitMonthYear,
18
+ } from "@springmicro/utils/payment";
19
+ import * as Yup from "yup";
20
+ import {
21
+ allCountries,
22
+ allCountriesReverse,
23
+ getPostalCodeDefault,
24
+ } from "@springmicro/utils/address";
25
+ import { AddressValues } from "./components/Address";
26
+ import { useFormik } from "formik";
27
+ import { postCheckout } from "../utils/api";
28
+
29
+ async function createOrder(cart, apiBaseUrl) {
30
+ // If an order has already been created and hasn't been changed, get the previous order.
31
+ if (cart.order && cart.order.id && cart.order.reference) {
32
+ const res = await fetch(
33
+ `${apiBaseUrl}/api/ecommerce/orders/${cart.order.id}/reference/${cart.order.reference}`
34
+ );
35
+ const order = await res.json();
36
+
37
+ const currentUrl = new URL(window.location.href);
38
+ currentUrl.searchParams.set("orderId", order.id);
39
+ currentUrl.searchParams.set("orderRef", order.reference);
40
+ window.history.pushState({}, "", currentUrl);
41
+
42
+ return order;
43
+ }
44
+
45
+ // Otherwise, create a new order.
46
+ const res = await fetch(apiBaseUrl + "/api/ecommerce/orders", {
47
+ method: "POST",
48
+ headers: {
49
+ "Content-Type": "application/json",
50
+ },
51
+ body: JSON.stringify({
52
+ customer_id: 1,
53
+ cart_data: {
54
+ items: cart.items.map((li) => ({
55
+ ...li,
56
+ name: undefined,
57
+ image: undefined,
58
+ })),
59
+ },
60
+ }),
61
+ });
62
+ const order = await res.json();
63
+
64
+ if (!order) throw "Missing order";
65
+ cartSetOrder({ id: order.id, reference: order.reference });
66
+ const currentUrl = new URL(window.location.href);
67
+ currentUrl.searchParams.set("orderId", order.id);
68
+ currentUrl.searchParams.set("orderRef", order.reference);
69
+ window.history.pushState({}, "", currentUrl);
70
+
71
+ return order;
72
+ }
73
+
74
+ async function getTax(apiBaseUrl, taxProvider, items, v) {
75
+ const res = await fetch(
76
+ `${apiBaseUrl}/api/ecommerce/tax?tax_provider=${taxProvider ?? "dummy"}`,
77
+ {
78
+ method: "POST",
79
+ body: JSON.stringify({
80
+ cart_data: items,
81
+ address: {
82
+ country: v.country,
83
+ city: v.locality,
84
+ line1: v.street_address,
85
+ postal_code: v.postal_code,
86
+ state: v.region,
87
+ },
88
+ }),
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ },
92
+ }
93
+ );
94
+ return await res.json();
95
+ }
96
+
97
+ export default function ReviewAndCalculateTaxes({
98
+ subtotal,
99
+ discount,
100
+ taxState,
101
+ shipping,
102
+ statusState,
103
+ cart,
104
+ prices,
105
+ apiBaseUrl,
106
+ orderState,
107
+ disableProductLink,
108
+ disableMissingImage,
109
+ taxProvider,
110
+ setSuccessData,
111
+ invoiceId = undefined,
112
+ onPlacement = undefined,
113
+ }) {
114
+ const [formDisabled, setFormDisabled] = useState(true);
115
+ const [formError, setFormError] = useState(undefined);
116
+ const theme = useTheme();
117
+ const stacked = useMediaQuery(theme.breakpoints.down("lg"));
118
+
119
+ const [status, setStatus] = statusState;
120
+ const [tax, setTax] = taxState;
121
+ const [order, setOrder] = orderState;
122
+
123
+ async function createOrderAndGetTaxes(values) {
124
+ setFormError(undefined);
125
+ // status === 0 when this is clicked.
126
+ // Creates an order and calculates tax.
127
+
128
+ Promise.all([
129
+ createOrder(cart, apiBaseUrl),
130
+ getTax(
131
+ apiBaseUrl,
132
+ taxProvider,
133
+ cart.items.map((i) => ({
134
+ amount: prices[i.price_id].unit_amount,
135
+ reference: i.name,
136
+ })),
137
+ values
138
+ ),
139
+ ])
140
+ .then(([order, taxData]) => {
141
+ setTax(taxData);
142
+ setOrder(order);
143
+ setStatus(1);
144
+ })
145
+ .catch(() => {
146
+ setFormError(true);
147
+ });
148
+ }
149
+
150
+ async function confirmOrder(values) {
151
+ setFormError(undefined);
152
+ setFormDisabled(true);
153
+ postCheckout(
154
+ apiBaseUrl,
155
+ order.id,
156
+ order.reference,
157
+ values,
158
+ "stripe",
159
+ invoiceId
160
+ )
161
+ .then((data) => {
162
+ if (data instanceof Response) {
163
+ setFormError(true);
164
+ alert(JSON.stringify(data));
165
+ } else {
166
+ // success
167
+ setSuccessData(data);
168
+ onPlacement && onPlacement();
169
+ // Get the current URL
170
+ const currentUrl = new URL(window.location.href);
171
+ // Set the query parameter 'showReceipt' to '1'
172
+ currentUrl.searchParams.set("showReceipt", "1");
173
+ currentUrl.searchParams.set("orderId", order.id);
174
+ currentUrl.searchParams.set("orderRef", order.reference);
175
+
176
+ // Update the browser's URL and history without refreshing the page
177
+ window.history.pushState({}, "", currentUrl);
178
+ }
179
+ setFormDisabled(false);
180
+ })
181
+ .catch(() => {
182
+ console.log("ERROR");
183
+ setFormError(true);
184
+ });
185
+ }
186
+
187
+ /**
188
+ *
189
+ * AddCard formik data
190
+ *
191
+ */
192
+
193
+ const contact = undefined;
194
+ const address = true;
195
+
196
+ const initialValuesBase = {
197
+ cvc: "",
198
+ expiry: "",
199
+ focus: "number",
200
+ name: contact ? `${contact.first_name} ${contact.last_name}` : "",
201
+ number: "",
202
+ } as CreditCardValues;
203
+ const initialCountry = "US";
204
+ const initialValuesAddress = {
205
+ country: initialCountry ?? "",
206
+ email: contact?.email ?? "",
207
+ region: "",
208
+ line1: "",
209
+ line2: "",
210
+ organization: contact?.organization ?? "",
211
+ city: "",
212
+ postal_code: getPostalCodeDefault(initialCountry),
213
+ } as AddressValues;
214
+
215
+ const initialValues: CreditCardValues & Partial<AddressValues> = address
216
+ ? { ...initialValuesBase, ...initialValuesAddress }
217
+ : initialValuesBase;
218
+
219
+ const validationSchemaBase = {
220
+ expiry: Yup.string()
221
+ // .length(7, "Expiry date must be of the format MM/YY.")
222
+ .test("exp-date", "Expiry date has already passed.", (string) => {
223
+ if (string?.length === 5) {
224
+ if (string.indexOf("/") !== 2) return false;
225
+ const [month, year] = splitMonthYear(string);
226
+ return expiryDateHasNotPassed(month, year);
227
+ } else {
228
+ return true;
229
+ }
230
+ })
231
+ .required("Expiry date is required."),
232
+ number: Yup.string().required("Card Number is required."),
233
+ name: Yup.string().required("Cardholder Name is required."),
234
+ cvc: Yup.string().required("CVC is required."),
235
+ };
236
+
237
+ const validationSchemaAddress = {
238
+ country: Yup.string().required("Country is required."),
239
+ email: Yup.string().email().required("Email is required."),
240
+ line1: Yup.string().required("Street Address 1 is required."),
241
+ city: Yup.string().required("City is required."),
242
+ postal_code: Yup.string().required("Postal Code is required."),
243
+ };
244
+ const formik = useFormik({
245
+ initialValues,
246
+ validationSchema: Yup.object(
247
+ address
248
+ ? {
249
+ ...validationSchemaBase,
250
+ ...validationSchemaAddress,
251
+ }
252
+ : validationSchemaBase
253
+ ),
254
+ onSubmit: async (v, helpers) => {
255
+ setFormDisabled(true);
256
+ const values = { ...v };
257
+ if (formDisabled) {
258
+ setFormDisabled(false);
259
+ throw new Error("Attempted to submit an invalid form.");
260
+ }
261
+
262
+ const submitFunc = status === 0 ? createOrderAndGetTaxes : confirmOrder;
263
+
264
+ if (submitFunc) {
265
+ if (address) {
266
+ // match backend
267
+ values["street_address"] = values.line2
268
+ ? `${values.line1}\n${values.line2}`
269
+ : values.line1;
270
+ values["locality"] = values.city;
271
+ delete values.line1;
272
+ delete values.line2;
273
+ delete values.city;
274
+ if (Object.keys(allCountriesReverse).includes(values.country)) {
275
+ values.country = allCountriesReverse[values.country];
276
+ }
277
+ }
278
+ values["card_number"] = values.number;
279
+ values["expiry_date"] = values.expiry;
280
+ delete values.focus;
281
+ delete values.expiry;
282
+ delete values.number;
283
+
284
+ // const res = await submitFunc(values);
285
+ submitFunc(values)
286
+ .then((v) => {})
287
+ .finally(() => {
288
+ setFormDisabled(false);
289
+ });
290
+ } else {
291
+ setFormDisabled(false);
292
+ }
293
+ },
294
+ });
295
+
296
+ const handleInputFocus: FocusEventHandler<HTMLInputElement> = (e) => {
297
+ const focusedEl = (e.target as HTMLElement).getAttribute("name") as CCFocus;
298
+ if (focusedEl) {
299
+ formik.setValues({
300
+ ...formik.values,
301
+ focus: focusedEl,
302
+ });
303
+ }
304
+ };
305
+
306
+ const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
307
+ if (e.target.name === "card_number") {
308
+ e.target.value = formatCreditCardNumber(e.target.value);
309
+ } else if (e.target.name === "expiry") {
310
+ e.target.value = formatExpirationDate(e.target.value);
311
+ } else if (e.target.name === "cvc") {
312
+ e.target.value = formatCVC(e.target.value);
313
+ }
314
+ formik.handleChange(e);
315
+ };
316
+
317
+ useEffect(() => {
318
+ setFormDisabled(Object.keys(formik.errors).length > 0);
319
+ }, [formik.errors]);
320
+
321
+ return (
322
+ <Box
323
+ sx={{
324
+ mt: 2,
325
+ display: "flex",
326
+ flexDirection: { xs: "column", xl: "row" },
327
+ justifyContent: "center",
328
+ alignItems: { xs: undefined, xl: "flex-start" },
329
+ px: { xs: 32, xl: 16 },
330
+ "&>div": { width: { xs: undefined, xl: "50%" } },
331
+ }}
332
+ >
333
+ <CartList
334
+ {...{
335
+ status,
336
+ cart,
337
+ subtotal,
338
+ tax,
339
+ shipping,
340
+ discount,
341
+ prices,
342
+ disableMissingImage,
343
+ disableProductLink,
344
+ formik,
345
+ formDisabled,
346
+ formError,
347
+ }}
348
+ />
349
+ {status === 0 && (
350
+ <Box sx={{ flexGrow: 1 }}>
351
+ <AddCard
352
+ onSubmit={createOrderAndGetTaxes}
353
+ stacked={stacked}
354
+ formData={{
355
+ formik,
356
+ handleInputChange,
357
+ handleInputFocus,
358
+ formDisabled,
359
+ formError,
360
+ }}
361
+ />
362
+ </Box>
363
+ )}
364
+ </Box>
365
+ );
366
+ }
@@ -0,0 +1,267 @@
1
+ import { useFormik, FormikProps } from "formik";
2
+ import * as Yup from "yup";
3
+ import React, {
4
+ ChangeEventHandler,
5
+ useEffect,
6
+ PropsWithChildren,
7
+ useState,
8
+ } from "react";
9
+ import {
10
+ AddressCityField,
11
+ AddressOrganizationNameField,
12
+ AddressCountryField,
13
+ AddressPostalCodeField,
14
+ AddressRegionField,
15
+ AddressStreetField,
16
+ AddressEmailField,
17
+ AddressValues,
18
+ } from "./Address";
19
+ import {
20
+ allCountries,
21
+ allCountriesReverse,
22
+ getPostalCodeDefault,
23
+ } from "@springmicro/utils/address";
24
+ import { Box, Button, Container, TextField, Typography } from "@mui/material";
25
+ import Cards from "react-credit-cards-2";
26
+ import { FocusEventHandler } from "react";
27
+ import {
28
+ formatCreditCardNumber,
29
+ formatCVC,
30
+ formatExpirationDate,
31
+ splitMonthYear,
32
+ expiryDateHasNotPassed,
33
+ } from "@springmicro/utils/payment";
34
+
35
+ // moved to index.ts
36
+ // import "react-credit-cards-2/dist/es/styles-compiled.css";
37
+ import { StripeLogoLink } from "./ProviderLogos";
38
+
39
+ export type CCFocus = "number" | "name" | "cvc" | "expiry";
40
+
41
+ export type CreditCardValues = {
42
+ cvc: string;
43
+ expiry: string;
44
+ focus: CCFocus;
45
+ name: string;
46
+ number: string;
47
+ };
48
+
49
+ export type AddCardProps = {
50
+ contact?: {
51
+ organization?: string;
52
+ email: string;
53
+ first_name: string;
54
+ last_name: string;
55
+ };
56
+ stacked?: boolean;
57
+ error?: string;
58
+ address?: boolean;
59
+ onSubmit?: (
60
+ values: CreditCardValues & Partial<AddressValues>
61
+ ) => Promise<any>;
62
+ PriceDetails?: JSX.Element;
63
+ formData?: {
64
+ formik;
65
+ handleInputChange;
66
+ handleInputFocus;
67
+ formDisabled;
68
+ formError;
69
+ };
70
+ };
71
+
72
+ export const AddCard = ({
73
+ contact,
74
+ error,
75
+ stacked = false,
76
+ address = true,
77
+ PriceDetails,
78
+ formData: {
79
+ formik,
80
+ handleInputChange,
81
+ handleInputFocus,
82
+ formDisabled,
83
+ formError,
84
+ },
85
+ }: AddCardProps) => {
86
+ return (
87
+ <Container id="paymentMethodForm">
88
+ <form
89
+ onSubmit={(e) => {
90
+ e.preventDefault();
91
+ formik.handleSubmit(e);
92
+ }}
93
+ >
94
+ <Box
95
+ sx={
96
+ stacked
97
+ ? {}
98
+ : {
99
+ display: "flex",
100
+ flexDirection: { xs: "column", sm: "row" },
101
+ gap: "1em",
102
+ }
103
+ }
104
+ >
105
+ <Cards
106
+ {...(formik.values as CreditCardValues)}
107
+ focused={formik.values.focus || "number"}
108
+ />
109
+
110
+ <div>
111
+ <TextField
112
+ sx={stacked ? { mt: 2 } : {}}
113
+ fullWidth
114
+ type="tel"
115
+ name="number"
116
+ label="Card Number"
117
+ required
118
+ onBlur={formik.handleBlur}
119
+ onChange={handleInputChange}
120
+ onFocus={handleInputFocus}
121
+ variant="outlined"
122
+ value={formik.values.number}
123
+ error={Boolean(formik.touched.number && formik.errors.number)}
124
+ helperText={formik.touched.number && formik.errors.number}
125
+ />
126
+ <TextField
127
+ sx={{ mt: 2 }}
128
+ fullWidth
129
+ type="text"
130
+ name="name"
131
+ label="Name on Card"
132
+ required
133
+ onBlur={formik.handleBlur}
134
+ onChange={handleInputChange}
135
+ onFocus={handleInputFocus}
136
+ variant="outlined"
137
+ value={formik.values.name}
138
+ error={Boolean(formik.touched.name && formik.errors.name)}
139
+ helperText={formik.touched.name && formik.errors.name}
140
+ />
141
+ <TextField
142
+ sx={{ mt: 2, width: "calc(50% - 8px)", mr: 1 }}
143
+ type="tel"
144
+ name="expiry"
145
+ label="Valid Thru"
146
+ placeholder="MM/YY"
147
+ inputProps={{ pattern: "[0-9]{2}/[0-9]{2}" }}
148
+ required
149
+ onBlur={formik.handleBlur}
150
+ onChange={handleInputChange}
151
+ onFocus={handleInputFocus}
152
+ variant="outlined"
153
+ value={formik.values.expiry}
154
+ error={Boolean(formik.touched.expiry && formik.errors.expiry)}
155
+ helperText={
156
+ (formik.touched.expiry && formik.errors.expiry) || "MM/YY"
157
+ }
158
+ />
159
+ <TextField
160
+ sx={{ mt: 2, width: "calc(50% - 8px)", ml: 1 }}
161
+ type="tel"
162
+ name="cvc"
163
+ label="CVC"
164
+ required
165
+ onBlur={formik.handleBlur}
166
+ onChange={handleInputChange}
167
+ onFocus={handleInputFocus}
168
+ variant="outlined"
169
+ value={formik.values.cvc}
170
+ error={Boolean(formik.touched.cvc && formik.errors.cvc)}
171
+ helperText={formik.touched.cvc && formik.errors.cvc}
172
+ />
173
+ </div>
174
+ {/* {formik.errors.submit && (
175
+ <Typography color="error" sx={{ mt: 2 }} variant="body2">
176
+ {formik.errors.submit}
177
+ </Typography>
178
+ )} */}
179
+ </Box>
180
+ <Box
181
+ sx={{ display: "flex", flexDirection: "row", gap: 2, width: "100%" }}
182
+ >
183
+ <Box sx={{ flexGrow: 1 }}>
184
+ {!address ? null : (
185
+ <Box>
186
+ <Typography variant="h6" sx={{ mt: 2, mb: 3 }}>
187
+ Billing Address
188
+ </Typography>
189
+ <AddressCountryField formik={formik} name="country" />
190
+ <AddressEmailField
191
+ formik={formik}
192
+ name="email"
193
+ sx={{ mt: 2 }}
194
+ />
195
+ <AddressOrganizationNameField
196
+ sx={{ mt: 2 }}
197
+ formik={formik}
198
+ name="organization"
199
+ />
200
+ <AddressStreetField
201
+ sx={{ mt: 2 }}
202
+ formik={formik}
203
+ name="line1"
204
+ lineNo="1"
205
+ />
206
+ <AddressStreetField
207
+ sx={{ mt: 2 }}
208
+ formik={formik}
209
+ name="line2"
210
+ lineNo="2"
211
+ required={false}
212
+ />
213
+ <AddressCityField sx={{ mt: 2 }} formik={formik} name="city" />
214
+ <AddressRegionField
215
+ sx={{ mt: 2 }}
216
+ formik={formik}
217
+ name="region"
218
+ />
219
+ <AddressPostalCodeField
220
+ sx={{ mt: 2 }}
221
+ formik={formik}
222
+ name="postal_code"
223
+ />
224
+ </Box>
225
+ )}
226
+ </Box>
227
+ <Box
228
+ sx={{
229
+ mt: "16px",
230
+ display: "flex",
231
+ flexDirection: "column",
232
+ alignItems: "flex-end",
233
+ flexGrow: 1,
234
+ }}
235
+ >
236
+ {PriceDetails}
237
+ </Box>
238
+ </Box>
239
+ {error && (
240
+ <Typography color="error" sx={{ mt: 2 }} variant="body2">
241
+ {error}
242
+ </Typography>
243
+ )}
244
+ {formError && (
245
+ <Typography color="red">
246
+ Could not create order or calculate tax. Please double-check your
247
+ payment information.
248
+ </Typography>
249
+ )}
250
+ <Button
251
+ size="large"
252
+ sx={{ mt: 3 }}
253
+ variant="contained"
254
+ // onClick={formik.handleSubmit}
255
+ type="submit"
256
+ disabled={formDisabled}
257
+ color={formError ? "error" : undefined}
258
+ >
259
+ Calculate Tax
260
+ </Button>
261
+ <Box sx={{ mt: 1 }}>
262
+ <StripeLogoLink />
263
+ </Box>
264
+ </form>
265
+ </Container>
266
+ );
267
+ };