@springmicro/cart 0.2.0-alpha.1

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 ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@springmicro/cart",
3
+ "private": false,
4
+ "version": "0.2.0-alpha.1",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "publishConfig": {
9
+ "access": "public",
10
+ "registry": "https://registry.npmjs.org/"
11
+ },
12
+ "scripts": {
13
+ "dev": "vite",
14
+ "build": "rm -rf dist && vite build",
15
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
16
+ "preview": "vite preview"
17
+ },
18
+ "dependencies": {
19
+ "@emotion/react": "^11.11.4",
20
+ "@emotion/styled": "^11.11.5",
21
+ "@mui/icons-material": "^5.15.18",
22
+ "@mui/material": "^5.15.18",
23
+ "@nanostores/persistent": "^0.10.1",
24
+ "@nanostores/query": "^0.3.3",
25
+ "@nanostores/react": "^0.7.2",
26
+ "dotenv": "^16.4.5",
27
+ "nanostores": "^0.10.3",
28
+ "react": "^18.2.0",
29
+ "react-dom": "^18.2.0",
30
+ "unstorage": "^1.10.2"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^18.2.66",
34
+ "@types/react-dom": "^18.2.22",
35
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
36
+ "@typescript-eslint/parser": "^7.2.0",
37
+ "@vitejs/plugin-react": "^4.2.1",
38
+ "eslint": "^8.57.0",
39
+ "eslint-plugin-react-hooks": "^4.6.0",
40
+ "eslint-plugin-react-refresh": "^0.4.6",
41
+ "typescript": "^5.2.2",
42
+ "vite": "^5.2.0"
43
+ },
44
+ "gitHead": "d988e7008130d071792f11d20bd22ab2fe4ea695"
45
+ }
@@ -0,0 +1,15 @@
1
+ import { addToCart } from "./utils/storage";
2
+
3
+ type Props = {
4
+ item: any;
5
+ children: any;
6
+ };
7
+
8
+ export default function AddToCartForm({ item, children }: Props) {
9
+ function addItemToCart(e: any) {
10
+ e.preventDefault();
11
+ addToCart(item);
12
+ }
13
+
14
+ return <form onSubmit={addItemToCart}>{children}</form>;
15
+ }
@@ -0,0 +1,178 @@
1
+ import {
2
+ Box,
3
+ Button,
4
+ ClickAwayListener,
5
+ IconButton,
6
+ Tooltip,
7
+ } from "@mui/material";
8
+ import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined";
9
+ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
10
+ import React from "react";
11
+ import CloseIcon from "@mui/icons-material/Close";
12
+ import type { Cart } from "./types";
13
+ import { useStore } from "@nanostores/react";
14
+ import {
15
+ clearCart,
16
+ removeFromCart,
17
+ cartStore,
18
+ apiPathDetails,
19
+ } from "./utils/storage";
20
+
21
+ export default function CartButton({
22
+ user,
23
+ apiBaseUrl,
24
+ color,
25
+ modalYOffset,
26
+ }: {
27
+ user: ({ id: string | number } & any) | undefined;
28
+ apiBaseUrl: string;
29
+ color: string | undefined;
30
+ modalYOffset: string | number | undefined;
31
+ }) {
32
+ const [modalOpen, setModalOpen] = React.useState(false);
33
+
34
+ React.useEffect(() => {
35
+ apiPathDetails.set({
36
+ baseUrl: apiBaseUrl,
37
+ userId: user ? user.id : undefined,
38
+ });
39
+ }, [user]);
40
+
41
+ const cartStorage = useStore(cartStore);
42
+
43
+ return (
44
+ <Cart
45
+ cart={JSON.parse(cartStorage)}
46
+ modalState={[modalOpen, setModalOpen]}
47
+ color={color}
48
+ modalYOffset={modalYOffset}
49
+ />
50
+ );
51
+ }
52
+
53
+ function Cart({
54
+ modalState,
55
+ cart,
56
+ color,
57
+ modalYOffset,
58
+ }: {
59
+ modalState: [boolean, React.Dispatch<React.SetStateAction<boolean>>];
60
+ cart: Cart;
61
+ color?: string;
62
+ modalYOffset?: string | number;
63
+ }) {
64
+ const [modalOpen, setModalOpen] = modalState;
65
+
66
+ return (
67
+ <Box>
68
+ <ClickAwayListener
69
+ onClickAway={() => {
70
+ setModalOpen(false);
71
+ }}
72
+ >
73
+ <Box>
74
+ <IconButton
75
+ sx={{ color: color ?? "white" }}
76
+ onClick={() => {
77
+ setModalOpen((t) => !t);
78
+ }}
79
+ >
80
+ {cart.cart.length > 0 ? (
81
+ <ShoppingCartIcon />
82
+ ) : (
83
+ <ShoppingCartOutlinedIcon />
84
+ )}
85
+ </IconButton>
86
+ <CartModal
87
+ open={modalOpen}
88
+ cart={cart}
89
+ clearCart={clearCart}
90
+ removeFromCart={removeFromCart}
91
+ modalYOffset={modalYOffset}
92
+ />
93
+ </Box>
94
+ </ClickAwayListener>
95
+ </Box>
96
+ );
97
+ }
98
+
99
+ function CartModal({
100
+ open,
101
+ cart,
102
+ removeFromCart,
103
+ clearCart,
104
+ modalYOffset,
105
+ }: {
106
+ open?: boolean;
107
+ cart: Cart;
108
+ removeFromCart: (i: number) => void;
109
+ clearCart: () => void;
110
+ modalYOffset?: string | number;
111
+ }) {
112
+ if (!open) return null;
113
+ return (
114
+ <Box
115
+ sx={{
116
+ marginTop: modalYOffset,
117
+ position: "fixed",
118
+ width: 275,
119
+ minHeight: 300,
120
+ maxHeight: "calc(90vh - 8rem)",
121
+ borderLeft: "2px solid #999",
122
+ borderBottom: "2px solid #999",
123
+ pl: 4,
124
+ pr: 2,
125
+ py: 2,
126
+ right: 0,
127
+ display: "grid",
128
+ gridTemplateRows: "1fr 40px",
129
+ color: "black",
130
+ bgcolor: "white",
131
+ borderBottomLeftRadius: 12,
132
+ gap: 2,
133
+ zIndex: 1,
134
+ }}
135
+ >
136
+ <Box sx={{ overflow: "auto" }}>
137
+ {cart.cart.length === 0 ? <Box>Your cart is empty.</Box> : null}
138
+ {cart.cart.map((product, i) => (
139
+ <Box
140
+ key={product.id}
141
+ sx={{
142
+ display: "flex",
143
+ width: "100%",
144
+ borderBottom: "2px solid #ddd",
145
+ py: 1,
146
+ "&>*": {
147
+ m: 0,
148
+ },
149
+ justifyContent: "space-between",
150
+ }}
151
+ >
152
+ <h2>{product.name}</h2>
153
+ <Tooltip title="Remove from cart">
154
+ <IconButton
155
+ sx={{ color: "red" }}
156
+ onClick={() => {
157
+ removeFromCart(i);
158
+ }}
159
+ >
160
+ <CloseIcon />
161
+ </IconButton>
162
+ </Tooltip>
163
+ </Box>
164
+ ))}
165
+ </Box>
166
+ <Box sx={{ display: "flex", justifyContent: "space-between" }}>
167
+ <Button
168
+ sx={{ color: "red" }}
169
+ onClick={clearCart}
170
+ disabled={cart.cart.length === 0}
171
+ >
172
+ Clear cart
173
+ </Button>
174
+ <Button>Checkout →</Button>
175
+ </Box>
176
+ </Box>
177
+ );
178
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import CartButton from "./CartButton";
2
+ import {
3
+ cartStore,
4
+ addToCart,
5
+ removeFromCart,
6
+ clearCart,
7
+ } from "./utils/storage";
8
+ import type { Cart, CartProduct } from "./types";
9
+ import AddToCartForm from "./AddToCartForm";
10
+
11
+ export {
12
+ cartStore,
13
+ CartButton,
14
+ Cart,
15
+ CartProduct,
16
+ addToCart,
17
+ removeFromCart,
18
+ clearCart,
19
+ AddToCartForm,
20
+ };
package/src/types.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface Cart {
2
+ authentication: {
3
+ loggedIn: boolean;
4
+ user_id?: number | string;
5
+ };
6
+ cart: CartProduct[];
7
+ }
8
+ export type CartProduct = {
9
+ id: number | string;
10
+ name: string;
11
+ quantity?: number;
12
+ price?: number;
13
+ };
14
+
15
+ type CartContextType = {
16
+ cart: Cart;
17
+ addToCart: (p: CartProduct) => void;
18
+ removeFromCart: (i: number) => void;
19
+ clearCart: () => void;
20
+ };
21
+
22
+ type ApiCartResponse = {
23
+ cart: string;
24
+ id: any;
25
+ user_id: number | string;
26
+ };
27
+
28
+ export type PathDetailsType = {
29
+ baseUrl?: string;
30
+ userId?: string | number;
31
+ };
@@ -0,0 +1,50 @@
1
+ import { defaultCartValue } from ".";
2
+ import { ApiCartResponse, Cart } from "../types";
3
+ import { Storage } from "unstorage";
4
+
5
+ export function cartAuthHandler(
6
+ cartState: [Cart, React.Dispatch<React.SetStateAction<Cart>>],
7
+ storage: {
8
+ api: Storage<any>;
9
+ local: Storage<any>;
10
+ } | null,
11
+ userId: number | string | undefined,
12
+ prevUserId: number | string | undefined
13
+ ) {
14
+ const [cart, setCart] = cartState;
15
+ if (storage === null) return () => {}; // storage can be null.
16
+
17
+ if (userId === prevUserId) return;
18
+
19
+ if (userId === undefined) {
20
+ // logout
21
+ setCart(defaultCartValue);
22
+ return;
23
+ }
24
+
25
+ // login
26
+ if (cart.cart.length > 0) {
27
+ setCart((c) => ({
28
+ ...c,
29
+ authentication: {
30
+ loggedIn: true,
31
+ user_id: userId,
32
+ },
33
+ }));
34
+ return;
35
+ }
36
+ storage.api
37
+ .getItem(`${userId}`)
38
+ .then((c2: ApiCartResponse) => {
39
+ if (!c2) return;
40
+
41
+ setCart({
42
+ cart: JSON.parse(c2.cart),
43
+ authentication: {
44
+ loggedIn: true,
45
+ user_id: c2.user_id,
46
+ },
47
+ });
48
+ })
49
+ .catch(() => {});
50
+ }
@@ -0,0 +1,28 @@
1
+ import { Cart, PathDetailsType } from "../types";
2
+
3
+ export const defaultCartValue: Cart = {
4
+ authentication: {
5
+ loggedIn: false,
6
+ },
7
+ cart: [],
8
+ };
9
+
10
+ export function fetchFromCartApi(
11
+ method: "GET" | "PUT" | "DELETE",
12
+ { baseUrl, userId }: PathDetailsType,
13
+ body?: string
14
+ ) {
15
+ return fetch(`${baseUrl}/api/ecommerce/cart/${userId}`, {
16
+ method,
17
+ body,
18
+ headers: {
19
+ "Content-Type": "application/json",
20
+ },
21
+ });
22
+ }
23
+
24
+ export function pathDetailsIsFullyDefined(pathDetails: PathDetailsType) {
25
+ if (pathDetails.userId === undefined) return false;
26
+ if (pathDetails.baseUrl === undefined) return false;
27
+ return true;
28
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ fetchFromCartApi,
3
+ defaultCartValue,
4
+ pathDetailsIsFullyDefined,
5
+ } from "./";
6
+ import { persistentAtom } from "@nanostores/persistent";
7
+ import { atom } from "nanostores";
8
+ import { Cart, CartProduct, PathDetailsType } from "../types";
9
+
10
+ export const cartStore = persistentAtom(
11
+ "cart",
12
+ JSON.stringify(defaultCartValue)
13
+ );
14
+
15
+ export const apiPathDetails = atom<PathDetailsType>({});
16
+
17
+ apiPathDetails.listen((pathDetails) => {
18
+ if (pathDetailsIsFullyDefined(pathDetails)) {
19
+ // Runs on init if there is a user id and api key. Automatically logs in if userId is updated from undefined.
20
+ fetchFromCartApi("GET", pathDetails).then(async (c) => {
21
+ const cart = await c.json();
22
+ cartStore.set(
23
+ JSON.stringify({
24
+ authentication: { loggedIn: true, user_id: cart.user_id },
25
+ cart: JSON.parse((cart as any).cart),
26
+ })
27
+ );
28
+ });
29
+ } else {
30
+ const localCartData = JSON.parse(cartStore.get());
31
+ if (localCartData.authentication.loggedIn)
32
+ cartStore.set(JSON.stringify(defaultCartValue));
33
+ }
34
+ });
35
+
36
+ export function addToCart(p: CartProduct) {
37
+ const cart: Cart = JSON.parse(cartStore.get());
38
+ const newCart = { ...cart, cart: [...cart.cart, p] };
39
+
40
+ const pathDetails = apiPathDetails.get();
41
+ if (pathDetailsIsFullyDefined(pathDetails)) {
42
+ fetchFromCartApi(
43
+ "PUT",
44
+ pathDetails,
45
+ JSON.stringify({
46
+ user_id: pathDetails.userId,
47
+ cart: JSON.stringify(newCart.cart),
48
+ })
49
+ ).then(async () => {
50
+ cartStore.set(JSON.stringify(newCart));
51
+ });
52
+ } else {
53
+ cartStore.set(JSON.stringify(newCart));
54
+ }
55
+ }
56
+
57
+ export function removeFromCart(i: number) {
58
+ const cart = JSON.parse(cartStore.get());
59
+
60
+ const products: CartProduct[] = [...cart.cart];
61
+ products.splice(i, 1);
62
+ const newCart = { ...cart, cart: products };
63
+
64
+ const pathDetails = apiPathDetails.get();
65
+ if (pathDetailsIsFullyDefined(pathDetails)) {
66
+ // If products.length is 0, delete cart. Otheriwise, update cart.
67
+ const cartIsEmpty = products.length > 0;
68
+
69
+ const body = {
70
+ user_id: pathDetails.userId,
71
+ cart: JSON.stringify(newCart.cart),
72
+ };
73
+
74
+ fetchFromCartApi(
75
+ cartIsEmpty ? "PUT" : "DELETE",
76
+ pathDetails,
77
+ cartIsEmpty ? JSON.stringify(body) : undefined
78
+ ).then(async () => {
79
+ cartStore.set(JSON.stringify(newCart));
80
+ });
81
+ } else {
82
+ cartStore.set(JSON.stringify(newCart));
83
+ }
84
+ }
85
+
86
+ export function clearCart() {
87
+ const cart = JSON.parse(cartStore.get());
88
+ const newCart = { ...cart, cart: [] };
89
+
90
+ const pathDetails = apiPathDetails.get();
91
+ if (pathDetailsIsFullyDefined(pathDetails)) {
92
+ fetchFromCartApi("DELETE", pathDetails).then(async () => {
93
+ cartStore.set(JSON.stringify(newCart));
94
+ });
95
+ } else {
96
+ cartStore.set(JSON.stringify(newCart));
97
+ }
98
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": false,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "noImplicitAny": false
21
+ },
22
+ "include": ["src"],
23
+ "references": [{ "path": "./tsconfig.node.json" }]
24
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true
9
+ },
10
+ "include": ["vite.config.ts"]
11
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,24 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { resolve } from "path";
3
+ import { defineConfig } from "vite";
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ build: {
8
+ lib: {
9
+ entry: resolve(__dirname, "src/index.ts"),
10
+ name: "@springmicro/forms",
11
+ fileName: "index",
12
+ },
13
+ rollupOptions: {
14
+ external: ["react", "react-dom", "nanoid"],
15
+ output: {
16
+ globals: {
17
+ react: "React",
18
+ "react-dom": "ReactDOM",
19
+ nanoid: "Nanoid",
20
+ },
21
+ },
22
+ },
23
+ },
24
+ });