@springmicro/cart 0.2.0-alpha.3 → 0.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@springmicro/cart",
3
3
  "private": false,
4
- "version": "0.2.0-alpha.3",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -10,7 +10,7 @@
10
10
  "registry": "https://registry.npmjs.org/"
11
11
  },
12
12
  "scripts": {
13
- "dev": "vite",
13
+ "dev": "vite build --watch",
14
14
  "build": "rm -rf dist && vite build",
15
15
  "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
16
16
  "preview": "vite preview"
@@ -23,11 +23,14 @@
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.0",
26
27
  "dotenv": "^16.4.5",
27
28
  "nanostores": "^0.10.3",
28
29
  "react": "^18.2.0",
30
+ "react-credit-cards-2": "^1.0.2",
29
31
  "react-dom": "^18.2.0",
30
- "unstorage": "^1.10.2"
32
+ "unstorage": "^1.10.2",
33
+ "vite-plugin-dts": "^3.9.0"
31
34
  },
32
35
  "devDependencies": {
33
36
  "@types/react": "^18.2.66",
@@ -38,8 +41,11 @@
38
41
  "eslint": "^8.57.0",
39
42
  "eslint-plugin-react-hooks": "^4.6.0",
40
43
  "eslint-plugin-react-refresh": "^0.4.6",
44
+ "formik": "^2.4.6",
41
45
  "typescript": "^5.2.2",
42
- "vite": "^5.2.0"
46
+ "vite": "^5.2.0",
47
+ "vite-plugin-css-injected-by-js": "^3.5.1",
48
+ "yup": "^1.4.0"
43
49
  },
44
- "gitHead": "0854b518932e657d4c08fb5877c725a9425b36e9"
50
+ "gitHead": "bada90b1f5c1efcd122b4a69436098408162677c"
45
51
  }
@@ -0,0 +1,265 @@
1
+ import { FormikProps } from "formik";
2
+ import {
3
+ allCountries,
4
+ allProvinces,
5
+ allCountriesReverse,
6
+ getRegionLabel,
7
+ getPostalCodeLabel,
8
+ getPostalCodeDefault,
9
+ provinces,
10
+ } from "@springmicro/utils/address";
11
+ import type { Alpha2Code, Province } from "@springmicro/utils/address";
12
+ import React from "react";
13
+ import { useEffect, useState } from "react";
14
+ import { SxProps } from "@mui/material";
15
+ import MenuItem from "@mui/material/MenuItem";
16
+ import Box from "@mui/material/Box";
17
+ import TextField, { StandardTextFieldProps } from "@mui/material/TextField";
18
+ import Autocomplete from "@mui/material/Autocomplete";
19
+
20
+ export type AddressValues = {
21
+ country: string;
22
+ region: string;
23
+ line1: string;
24
+ line2?: string;
25
+ organization?: string;
26
+ email: string;
27
+ city: string;
28
+ postal_code: string;
29
+ };
30
+
31
+ export type AddressFieldProps = {
32
+ formik: FormikProps<AddressValues & any>; // formik object might contain other values in addition to address
33
+ name: keyof AddressValues;
34
+ sx?: SxProps;
35
+ };
36
+
37
+ export function AddressCountryField({ formik, name, sx }: AddressFieldProps) {
38
+ /**
39
+ * Selects the full country name, need to do a reverse lookup for the Alpha2Code.
40
+ */
41
+ const [inputValue, setInputValue] = useState(formik.values[name] as string);
42
+
43
+ return (
44
+ <Autocomplete
45
+ sx={{ width: 300, ...sx }}
46
+ options={Object.values(allCountries)}
47
+ autoHighlight
48
+ getOptionLabel={(option) => option}
49
+ value={formik.values[name] as string}
50
+ onChange={(e, newValue) => {
51
+ // console.log(newValue)
52
+ formik.setValues({
53
+ ...formik.values,
54
+ region: "",
55
+ country: newValue as string,
56
+ line1: "",
57
+ line2: "",
58
+ organization: "",
59
+ city: "",
60
+ postal_code: "",
61
+ });
62
+ }}
63
+ onBlur={formik.handleBlur}
64
+ inputValue={inputValue}
65
+ onInputChange={(event, newInputValue) => {
66
+ setInputValue(newInputValue);
67
+ }}
68
+ renderOption={(props, option) => (
69
+ <Box
70
+ component="li"
71
+ sx={{ "& > img": { mr: 2, flexShrink: 0 } }}
72
+ {...props}
73
+ >
74
+ <img
75
+ loading="lazy"
76
+ width="20"
77
+ src={`https://flagcdn.com/w20/${allCountriesReverse[
78
+ option
79
+ ].toLowerCase()}.png`}
80
+ srcSet={`https://flagcdn.com/w40/${allCountriesReverse[
81
+ option
82
+ ].toLowerCase()}.png 2x`}
83
+ alt=""
84
+ />
85
+ {option}
86
+ </Box>
87
+ )}
88
+ renderInput={(params) => (
89
+ <TextField
90
+ {...params}
91
+ required={true}
92
+ name={name as string}
93
+ label="Country"
94
+ />
95
+ )}
96
+ />
97
+ );
98
+ }
99
+
100
+ export function AddressRegionField({ formik, name, sx }: AddressFieldProps) {
101
+ const [addressRegionFieldOptional, setAddressRegionFieldOptional] =
102
+ useState<boolean>(false);
103
+ const country2code = allCountriesReverse[
104
+ formik.values["country"] as string
105
+ ] as Alpha2Code;
106
+
107
+ useEffect(() => {
108
+ setAddressRegionFieldOptional(
109
+ provinces.filter(
110
+ (province: Province) => province.country === country2code
111
+ ).length === 0
112
+ );
113
+ }, [country2code]);
114
+
115
+ const baseProps = {
116
+ sx: { width: 300, ...sx },
117
+ label: getRegionLabel(country2code),
118
+ name: name as string,
119
+ id: name as string,
120
+ value: formik.values[name],
121
+ onChange: formik.handleChange,
122
+ onBlur: formik.handleBlur,
123
+ };
124
+
125
+ if (!country2code || !getRegionLabel(country2code)) {
126
+ return <></>;
127
+ }
128
+
129
+ if (addressRegionFieldOptional) {
130
+ return (
131
+ <TextField
132
+ {...baseProps}
133
+ required={false}
134
+ sx={{ display: "block", ...baseProps.sx }}
135
+ />
136
+ );
137
+ } else {
138
+ return (
139
+ <TextField {...baseProps} select required={true}>
140
+ {allProvinces
141
+ .filter((val) => val.country === country2code)
142
+ .map((province, i) => (
143
+ <MenuItem key={i} value={province.value}>
144
+ {province.name}
145
+ </MenuItem>
146
+ ))}
147
+ </TextField>
148
+ );
149
+ }
150
+ }
151
+
152
+ export function AddressStreetField({
153
+ formik,
154
+ name,
155
+ sx,
156
+ required = true,
157
+ lineNo,
158
+ }: AddressFieldProps & {
159
+ required?: boolean;
160
+ lineNo?: "1" | "2" | "3" | undefined;
161
+ }) {
162
+ return (
163
+ <>
164
+ <TextField
165
+ label={lineNo ? `Street Address ${lineNo}` : "Street Address"}
166
+ sx={{ width: 400, display: "block", ...sx }}
167
+ name={name as string}
168
+ id={name as string}
169
+ value={formik.values[name]}
170
+ onChange={formik.handleChange}
171
+ onBlur={formik.handleBlur}
172
+ required={required}
173
+ />
174
+ </>
175
+ );
176
+ }
177
+
178
+ export function AddressEmailField({ formik, name, sx }: AddressFieldProps) {
179
+ return (
180
+ <>
181
+ <TextField
182
+ label={"Email"}
183
+ sx={{ width: 400, display: "block", ...sx }}
184
+ name={name as string}
185
+ id={name as string}
186
+ value={formik.values[name]}
187
+ onChange={formik.handleChange}
188
+ onBlur={formik.handleBlur}
189
+ type="email"
190
+ required={true}
191
+ />
192
+ </>
193
+ );
194
+ }
195
+
196
+ export function AddressOrganizationNameField({
197
+ formik,
198
+ name,
199
+ sx,
200
+ }: AddressFieldProps) {
201
+ return (
202
+ <TextField
203
+ label="Organization Name"
204
+ sx={{ width: 400, display: "block", ...sx }}
205
+ name={name as string}
206
+ id={name as string}
207
+ value={formik.values[name]}
208
+ onChange={formik.handleChange}
209
+ onBlur={formik.handleBlur}
210
+ required={false}
211
+ helperText="Only include organization name if you want it included on the address."
212
+ />
213
+ );
214
+ }
215
+
216
+ export function AddressCityField({ formik, name, sx }: AddressFieldProps) {
217
+ return (
218
+ <TextField
219
+ label="City"
220
+ sx={{ width: 400, display: "block", ...sx }}
221
+ name={name as string}
222
+ id={name as string}
223
+ value={formik.values[name]}
224
+ onChange={formik.handleChange}
225
+ onBlur={formik.handleBlur}
226
+ required={true}
227
+ />
228
+ );
229
+ }
230
+
231
+ export function AddressPostalCodeField({
232
+ formik,
233
+ name,
234
+ sx,
235
+ }: AddressFieldProps) {
236
+ const country2code = allCountriesReverse[
237
+ formik.values["country"] as string
238
+ ] as Alpha2Code;
239
+
240
+ React.useEffect(() => {
241
+ if (!!getPostalCodeDefault(country2code)) {
242
+ formik
243
+ .setFieldValue(name, getPostalCodeDefault(country2code))
244
+ .then(() => formik.setFieldTouched(name, true));
245
+ }
246
+ }, [country2code]);
247
+
248
+ return (
249
+ <TextField
250
+ label={getPostalCodeLabel(country2code)}
251
+ sx={{ width: 400, display: "block", ...sx }}
252
+ name={name as string}
253
+ id={name as string}
254
+ value={formik.values[name]}
255
+ onChange={formik.handleChange}
256
+ onBlur={formik.handleBlur}
257
+ required={true}
258
+ helperText={
259
+ !!getPostalCodeDefault(country2code)
260
+ ? 'Our records indicate that your country does not have a postal code system, so "00000" will be used by default.'
261
+ : ""
262
+ }
263
+ />
264
+ );
265
+ }
@@ -0,0 +1,346 @@
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 (values, helpers) => {
130
+ if (disabled) {
131
+ throw new Error("Attempted to submit an invalid form.");
132
+ }
133
+ if (onSubmit) {
134
+ if (address) {
135
+ // match backend
136
+ // @ts-ignore
137
+ values["street_address"] = values.line2
138
+ ? // @ts-ignore
139
+ `${values.line1}\n${values.line2}`
140
+ : // @ts-ignore
141
+ values.line1;
142
+ // @ts-ignore
143
+ values["locality"] = values.city;
144
+ // @ts-ignore
145
+ delete values.line1;
146
+ // @ts-ignore
147
+ delete values.line2;
148
+ // @ts-ignore
149
+ delete values.city;
150
+ // @ts-ignore
151
+ if (Object.keys(allCountriesReverse).includes(values.country)) {
152
+ // @ts-ignore
153
+ values.country = allCountriesReverse[values.country];
154
+ }
155
+ }
156
+ values["card_number"] = values.number;
157
+ values["expiry_date"] = values.expiry;
158
+ delete values.focus;
159
+ delete values.expiry;
160
+ delete values.number;
161
+
162
+ const res = await onSubmit(values);
163
+ }
164
+ },
165
+ });
166
+
167
+ const handleInputFocus: FocusEventHandler<HTMLInputElement> = (e) => {
168
+ const focusedEl = (e.target as HTMLElement).getAttribute("name") as CCFocus;
169
+ if (focusedEl) {
170
+ formik.setValues({
171
+ ...formik.values,
172
+ focus: focusedEl,
173
+ });
174
+ }
175
+ };
176
+
177
+ const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
178
+ if (e.target.name === "card_number") {
179
+ e.target.value = formatCreditCardNumber(e.target.value);
180
+ } else if (e.target.name === "expiry") {
181
+ e.target.value = formatExpirationDate(e.target.value);
182
+ } else if (e.target.name === "cvc") {
183
+ e.target.value = formatCVC(e.target.value);
184
+ }
185
+ formik.handleChange(e);
186
+ };
187
+
188
+ useEffect(() => {
189
+ setDisabled(Object.keys(formik.errors).length > 0);
190
+ }, [formik.errors]);
191
+
192
+ return (
193
+ <Container id="paymentMethodForm">
194
+ <form
195
+ onSubmit={(e) => {
196
+ e.preventDefault();
197
+ formik.handleSubmit();
198
+ }}
199
+ >
200
+ <Box
201
+ sx={
202
+ stacked
203
+ ? {}
204
+ : {
205
+ display: "flex",
206
+ flexDirection: { xs: "column", sm: "row" },
207
+ gap: "1em",
208
+ }
209
+ }
210
+ >
211
+ <Cards
212
+ {...(formik.values as CreditCardValues)}
213
+ focused={formik.values.focus || "number"}
214
+ />
215
+
216
+ <div>
217
+ <TextField
218
+ sx={stacked ? { mt: 2 } : {}}
219
+ fullWidth
220
+ type="tel"
221
+ name="number"
222
+ label="Card Number"
223
+ required
224
+ onBlur={formik.handleBlur}
225
+ onChange={handleInputChange}
226
+ onFocus={handleInputFocus}
227
+ variant="outlined"
228
+ value={formik.values.number}
229
+ error={Boolean(formik.touched.number && formik.errors.number)}
230
+ helperText={formik.touched.number && formik.errors.number}
231
+ />
232
+ <TextField
233
+ sx={{ mt: 2 }}
234
+ fullWidth
235
+ type="text"
236
+ name="name"
237
+ label="Name on Card"
238
+ required
239
+ onBlur={formik.handleBlur}
240
+ onChange={handleInputChange}
241
+ onFocus={handleInputFocus}
242
+ variant="outlined"
243
+ value={formik.values.name}
244
+ error={Boolean(formik.touched.name && formik.errors.name)}
245
+ helperText={formik.touched.name && formik.errors.name}
246
+ />
247
+ <TextField
248
+ sx={{ mt: 2, width: "calc(50% - 8px)", mr: 1 }}
249
+ type="tel"
250
+ name="expiry"
251
+ label="Valid Thru"
252
+ placeholder="MM/YY"
253
+ inputProps={{ pattern: "[0-9]{2}/[0-9]{2}" }}
254
+ required
255
+ onBlur={formik.handleBlur}
256
+ onChange={handleInputChange}
257
+ onFocus={handleInputFocus}
258
+ variant="outlined"
259
+ value={formik.values.expiry}
260
+ error={Boolean(formik.touched.expiry && formik.errors.expiry)}
261
+ helperText={
262
+ (formik.touched.expiry && formik.errors.expiry) || "MM/YY"
263
+ }
264
+ />
265
+ <TextField
266
+ sx={{ mt: 2, width: "calc(50% - 8px)", ml: 1 }}
267
+ type="tel"
268
+ name="cvc"
269
+ label="CVC"
270
+ required
271
+ onBlur={formik.handleBlur}
272
+ onChange={handleInputChange}
273
+ onFocus={handleInputFocus}
274
+ variant="outlined"
275
+ value={formik.values.cvc}
276
+ error={Boolean(formik.touched.cvc && formik.errors.cvc)}
277
+ helperText={formik.touched.cvc && formik.errors.cvc}
278
+ />
279
+ </div>
280
+ {/* {formik.errors.submit && (
281
+ <Typography color="error" sx={{ mt: 2 }} variant="body2">
282
+ {formik.errors.submit}
283
+ </Typography>
284
+ )} */}
285
+ </Box>
286
+ <Box>
287
+ {!address ? null : (
288
+ <Box>
289
+ <Typography variant="h6" sx={{ mt: 2, mb: 3 }}>
290
+ Billing Address
291
+ </Typography>
292
+ <AddressCountryField formik={formik} name="country" />
293
+ <AddressEmailField formik={formik} name="email" sx={{ mt: 2 }} />
294
+ <AddressOrganizationNameField
295
+ sx={{ mt: 2 }}
296
+ formik={formik}
297
+ name="organization"
298
+ />
299
+ <AddressStreetField
300
+ sx={{ mt: 2 }}
301
+ formik={formik}
302
+ name="line1"
303
+ lineNo="1"
304
+ />
305
+ <AddressStreetField
306
+ sx={{ mt: 2 }}
307
+ formik={formik}
308
+ name="line2"
309
+ lineNo="2"
310
+ required={false}
311
+ />
312
+ <AddressCityField sx={{ mt: 2 }} formik={formik} name="city" />
313
+ <AddressRegionField
314
+ sx={{ mt: 2 }}
315
+ formik={formik}
316
+ name="region"
317
+ />
318
+ <AddressPostalCodeField
319
+ sx={{ mt: 2 }}
320
+ formik={formik}
321
+ name="postal_code"
322
+ />
323
+ </Box>
324
+ )}
325
+ </Box>
326
+ {error && (
327
+ <Typography color="error" sx={{ mt: 2 }} variant="body2">
328
+ {error}
329
+ </Typography>
330
+ )}
331
+ <Button
332
+ size="large"
333
+ sx={{ mt: 3 }}
334
+ variant="contained"
335
+ type="submit"
336
+ disabled={disabled}
337
+ >
338
+ Continue
339
+ </Button>
340
+ <Box sx={{ mt: 1 }}>
341
+ <StripeLogoLink />
342
+ </Box>
343
+ </form>
344
+ </Container>
345
+ );
346
+ };