├── .env.example
├── .eslintrc.json
├── .gitignore
├── .npmrc
├── .nvmrc
├── LICENSE
├── README.md
├── environment.d.ts
├── next.config.js
├── package.json
├── public
├── favicon.ico
└── vercel.svg
├── src
├── assets
│ └── svg
│ │ ├── empty_cart.svg
│ │ └── hero.svg
├── components
│ ├── Head.tsx
│ ├── Hero.tsx
│ ├── Layout
│ │ ├── Content.tsx
│ │ ├── Footer.tsx
│ │ └── index.tsx
│ ├── Loader.tsx
│ ├── NextProgress.tsx
│ ├── NoData.tsx
│ ├── ProductList
│ │ ├── Product.tsx
│ │ └── index.tsx
│ ├── SectionTitle.tsx
│ ├── Toaster.tsx
│ └── index.ts
├── features
│ ├── Cart
│ │ ├── Provider.tsx
│ │ ├── components
│ │ │ ├── Header.tsx
│ │ │ ├── OrderSummary.tsx
│ │ │ ├── Table.tsx
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── service.ts
│ │ └── types
│ │ │ └── index.ts
│ └── Checkout
│ │ ├── Provider.tsx
│ │ ├── components
│ │ ├── AddressForm
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Select.tsx
│ │ │ └── index.tsx
│ │ ├── Confirmation.tsx
│ │ ├── PaymentForm
│ │ │ ├── Review.tsx
│ │ │ └── index.tsx
│ │ └── index.tsx
│ │ ├── index.ts
│ │ ├── service.ts
│ │ └── types
│ │ └── index.ts
├── lib
│ └── commerce.ts
├── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ │ └── hello.ts
│ ├── cart.tsx
│ ├── checkout.tsx
│ └── index.tsx
├── providers
│ └── Theme.tsx
├── styles
│ ├── darkTheme.ts
│ ├── index.ts
│ └── lightTheme.ts
├── types
│ └── index.ts
└── utils
│ ├── createEmotionCache.ts
│ └── storage.ts
├── tsconfig.json
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_CHEC_PUBLIC_API_KEY="..."
2 | NEXT_PUBLIC_STRIPE_PUBLIC_API_KEY="..."
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "no-restricted-imports": ["error", { "patterns": ["@/features/*/*"] }]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/gallium
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 ratul-devr
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DevR Commerce
2 |
3 | DevR-Commerce is a simple E-Commerce store created with Next.Js using the CommerceJs back-end. It has basic E-Commerce features since it's has been recently published. It will have more useful features in future.
4 |
5 | ## ⏩ Quick Start
6 |
7 | ```bash
8 | git clone git@github.com:developeratul/devr-commerce.git
9 | cd devr-commerce
10 | yarn
11 | cp .env.example .env.local # please change the credentials
12 | ```
13 |
14 | Then run
15 |
16 | ```bash
17 | yarn dev
18 | ```
19 |
20 | ## 🧑💻 Technologies used
21 |
22 | - MUI - for designing the front-end
23 | - React-Hot-Toast - for toast notifications
24 | - nextjs-progressbar - for slim progressbars
25 | - @mui/icons-material - for icons
26 | - Dracula - the theme used in the app
27 |
28 | That's it!
29 |
30 | ## ⚒️ Upcoming features
31 |
32 | - [ ] Promo Code to get discount on checkouts
33 | - [ ] A separate product page where all the products will be shown and they can be filtered and searched
34 | - [ ] Separate page for each products
35 | - [ ] Variant selection for products
36 |
37 | ## 🐛 Fixes to be done
38 |
39 | - [x] Making the cart page responsive
40 | - [ ] Speed optimization
41 |
42 | ## 🛡️ License
43 |
44 | This project is under MIT license
45 |
46 | ## 👨💻 Author
47 |
48 | - Twitter: [@developeratul](https://twitter.com/developeratul)
49 | - Github: [@developeratl](https://github.com/developeratul)
50 |
--------------------------------------------------------------------------------
/environment.d.ts:
--------------------------------------------------------------------------------
1 | export declare global {
2 | namespace NodeJS {
3 | interface ProcessEnv {
4 | NODE_ENV: "development" | "production";
5 | NEXT_PUBLIC_CHEC_PUBLIC_API_KEY: string;
6 | NEXT_PUBLIC_STRIPE_PUBLIC_API_KEY: string;
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: false,
4 | swcMinify: true,
5 | images: {
6 | domains: ["cdn.chec.io"],
7 | },
8 | };
9 |
10 | module.exports = nextConfig;
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "devr-commerce",
3 | "version": "0.1.0",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "dev": "next dev",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@chec/commerce.js": "^2.8.0",
14 | "@emotion/react": "^11.10.0",
15 | "@emotion/server": "^11.10.0",
16 | "@emotion/styled": "^11.10.0",
17 | "@fontsource/poppins": "^4.5.9",
18 | "@mui/icons-material": "^5.8.4",
19 | "@mui/lab": "^5.0.0-alpha.95",
20 | "@mui/material": "^5.10.0",
21 | "@stripe/react-stripe-js": "^1.10.0",
22 | "@stripe/stripe-js": "^1.35.0",
23 | "next": "12.2.5",
24 | "nextjs-progressbar": "^0.0.14",
25 | "react": "18.2.0",
26 | "react-dom": "18.2.0",
27 | "react-hot-toast": "^2.3.0",
28 | "universal-cookie": "^4.0.4",
29 | "validator": "^13.7.0"
30 | },
31 | "devDependencies": {
32 | "@types/chec__commerce.js": "^2.8.5",
33 | "@types/node": "18.7.3",
34 | "@types/react": "18.0.17",
35 | "@types/react-dom": "18.0.6",
36 | "@types/validator": "^13.7.5",
37 | "eslint": "8.22.0",
38 | "eslint-config-next": "12.2.5",
39 | "typescript": "4.7.4"
40 | },
41 | "engines": {
42 | "node": ">=16.0.0",
43 | "yarn": ">=1.22.0",
44 | "npm": "please-use-yarn"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developeratul/devr-commerce/07323956178a635c7234464f367266a28f4f37bd/public/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/svg/empty_cart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/svg/hero.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Head.tsx:
--------------------------------------------------------------------------------
1 | import NextHead from "next/head";
2 |
3 | type HeadProps = {
4 | title?: string;
5 | description?: string;
6 | };
7 |
8 | export default function Head(props: HeadProps) {
9 | const { title, description } = props;
10 | return (
11 |
12 | {title ? `DevR Commerce - ${title}` : "DevR Commerce"}
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/Hero.tsx:
--------------------------------------------------------------------------------
1 | import HeroImage from "@/assets/svg/hero.svg";
2 | import { Flex } from "@/styles";
3 | import { GitHub } from "@mui/icons-material";
4 | import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
5 | import { Box, Button, Container, styled, Typography } from "@mui/material";
6 | import Image from "next/image";
7 |
8 | const FlexContainer = styled(Box)(({ theme }) => ({
9 | display: "flex",
10 | justifyContent: "space-between",
11 | alignItems: "center",
12 | padding: "100px 0",
13 | gap: 5,
14 | [theme.breakpoints.down("md")]: {
15 | flexDirection: "column",
16 | },
17 | }));
18 | const LeftContent = styled(Box)(({ theme }) => ({
19 | width: "100%",
20 | maxWidth: "600px",
21 | [theme.breakpoints.down("md")]: {
22 | marginBottom: 50,
23 | },
24 | }));
25 | const RightContent = styled(Box)(({ theme }) => ({
26 | [theme.breakpoints.down("lg")]: {
27 | maxWidth: "400px",
28 | },
29 | }));
30 | export default function Hero() {
31 | return (
32 |
33 |
34 |
35 |
41 | DevR Commerce
42 |
43 |
49 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Eum voluptates magnam aperiam
50 | amet? Ea iusto repudiandae corporis, animi excepturi non nostrum cupiditate ad! Est,
51 | nostrum!
52 |
53 |
54 | }
58 | variant="contained"
59 | >
60 | Get started
61 |
62 | }
67 | variant="contained"
68 | color="secondary"
69 | >
70 | Star on github
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/Layout/Content.tsx:
--------------------------------------------------------------------------------
1 | import { AppProps } from "@/types";
2 | import { Box, styled } from "@mui/material";
3 |
4 | const ContentContainer = styled(Box)({
5 | flex: 1,
6 | display: "flex",
7 | flexDirection: "column",
8 | });
9 | export default function Content(props: AppProps) {
10 | const { children } = props;
11 | return {children};
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/Layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@/styles";
2 | import { GitHub, LogoDev, Twitter } from "@mui/icons-material";
3 | import * as Mui from "@mui/material";
4 |
5 | const FooterContainer = Mui.styled(Mui.Box)(({ theme }) => ({
6 | padding: "20px 10px",
7 | marginTop: "auto",
8 | background: theme.palette.background.paper,
9 | }));
10 | export default function Footer() {
11 | return (
12 |
13 |
17 |
18 | Designed and Developed with Next.js by{" "}
19 |
20 | DevR
21 |
22 |
23 |
24 |
30 |
31 |
32 |
38 |
39 |
40 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { Header } from "@/features/Cart";
2 | import { AppProps } from "@/types";
3 | import { Box, styled } from "@mui/material";
4 | import Content from "./Content";
5 | import Footer from "./Footer";
6 |
7 | const LayoutContainer = styled(Box)({
8 | display: "flex",
9 | flexDirection: "column",
10 | minHeight: "100vh",
11 | margin: 0,
12 | });
13 | export default function Layout(props: AppProps) {
14 | const { children } = props;
15 | return (
16 |
17 |
18 | {children}
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Loader.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@/styles";
2 | import { CircularProgress, styled } from "@mui/material";
3 |
4 | const LoaderContainer = styled(Flex)({
5 | width: "100vw",
6 | height: "100vh",
7 | justifyContent: "center",
8 | alignItems: "center",
9 | });
10 | export default function Loader() {
11 | return (
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/NextProgress.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/providers/Theme";
2 | import NPProgress from "nextjs-progressbar";
3 |
4 | export default function NextProgress() {
5 | const theme = useTheme();
6 | const color = theme.palette.primary.main;
7 | return ;
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/NoData.tsx:
--------------------------------------------------------------------------------
1 | import EmptyCartImage from "@/assets/svg/empty_cart.svg";
2 | import { Box, styled, Typography } from "@mui/material";
3 | import Image from "next/image";
4 |
5 | type PropType = {
6 | title: string;
7 | description?: string;
8 | };
9 | const NoDataContainer = styled(Box)({
10 | display: "flex",
11 | justifyContent: "center",
12 | alignItems: "center",
13 | flexDirection: "column",
14 | padding: "100px 0",
15 | });
16 | export default function NoData(props: PropType) {
17 | const { title, description } = props;
18 | return (
19 |
20 |
28 |
29 | {title}
30 |
31 | {description && (
32 |
33 | {description}
34 |
35 | )}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/ProductList/Product.tsx:
--------------------------------------------------------------------------------
1 | import { CartService, useCartDispatchContext } from "@/features/Cart";
2 | import { Product } from "@chec/commerce.js/types/product";
3 | import { ShoppingCart } from "@mui/icons-material";
4 | import { CardActionArea, CardActions, IconButton } from "@mui/material";
5 | import Card from "@mui/material/Card";
6 | import CardContent from "@mui/material/CardContent";
7 | import CardMedia from "@mui/material/CardMedia";
8 | import Typography from "@mui/material/Typography";
9 | import { useState } from "react";
10 | import toast from "react-hot-toast";
11 |
12 | type ProductProps = {
13 | product: Product;
14 | };
15 | export default function SingleProduct(props: ProductProps) {
16 | const { product } = props;
17 | const { setCart } = useCartDispatchContext();
18 | const [isAdding, setIsAdding] = useState(false);
19 |
20 | const addToCart = async (productId: string) => {
21 | setIsAdding(true);
22 | try {
23 | const { cart } = await CartService.add(productId);
24 | setCart(cart);
25 | } catch (err: any) {
26 | toast.error(err?.message);
27 | } finally {
28 | setIsAdding(false);
29 | }
30 | };
31 | return (
32 |
33 |
34 |
41 |
42 | {product.name}
43 |
44 |
45 |
46 | addToCart(product.id)} disabled={isAdding} color="secondary">
47 |
48 |
49 | {product.price.formatted_with_symbol}
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/ProductList/index.tsx:
--------------------------------------------------------------------------------
1 | import { SectionTitle } from "@/components";
2 | import { Product } from "@chec/commerce.js/types/product";
3 | import { Box, Container, Grid, styled } from "@mui/material";
4 | import SingleProduct from "./Product";
5 |
6 | type ProductListProps = {
7 | products: Product[];
8 | };
9 |
10 | const ProductsSection = styled(Box)(({ theme }) => ({
11 | background: theme.palette.background.secondary,
12 | padding: "100px 0",
13 | }));
14 | export function ProductList(props: ProductListProps) {
15 | const { products } = props;
16 | return (
17 |
18 |
19 |
23 |
24 | {products.map((product) => (
25 |
26 |
27 |
28 | ))}
29 |
30 |
31 |
32 | );
33 | }
34 | export { default as SingleProduct } from "./Product";
35 |
--------------------------------------------------------------------------------
/src/components/SectionTitle.tsx:
--------------------------------------------------------------------------------
1 | import { Box, styled, Typography } from "@mui/material";
2 |
3 | type SectionTitleProps = {
4 | title: string;
5 | subtitle?: string;
6 | };
7 |
8 | const SectionTitleContainer = styled(Box)({
9 | display: "flex",
10 | flexDirection: "column",
11 | justifyContent: "center",
12 | marginBottom: 50,
13 | });
14 | export default function SectionTitle(props: SectionTitleProps) {
15 | const { title, subtitle } = props;
16 | return (
17 |
18 |
19 | {title}
20 |
21 | {subtitle && {subtitle}}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/Toaster.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/providers/Theme";
2 | import { Toaster as ToasterLib } from "react-hot-toast";
3 |
4 | export default function Toaster() {
5 | const theme = useTheme();
6 | return (
7 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Head } from "./Head";
2 | export { default as HeroSection } from "./Hero";
3 | export { default as Layout } from "./Layout";
4 | export { default as Loader } from "./Loader";
5 | export { default as NextProgress } from "./NextProgress";
6 | export { default as NoData } from "./NoData";
7 | export * from "./ProductList";
8 | export { default as SectionTitle } from "./SectionTitle";
9 | export { default as Toaster } from "./Toaster";
10 |
--------------------------------------------------------------------------------
/src/features/Cart/Provider.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "@/components";
2 | import { Cart } from "@chec/commerce.js/types/cart";
3 | import React from "react";
4 | import toast from "react-hot-toast";
5 | import CartService from "./service";
6 | import { CartProviderProps, DispatchState, InitialState, Reducer } from "./types";
7 |
8 | const initialState: InitialState = { cart: null, isLoading: true };
9 | const dispatchState: DispatchState = { setCart: () => null };
10 |
11 | const CartStateContext = React.createContext(initialState);
12 | const CartDispatchContext = React.createContext(dispatchState);
13 |
14 | const reducer: Reducer = (state = initialState, action) => {
15 | switch (action.type) {
16 | case "SET_CART": {
17 | return { ...state, cart: action.payload, isLoading: false };
18 | }
19 | default: {
20 | throw new Error(`Unknown cart method ${action.type}`);
21 | }
22 | }
23 | };
24 |
25 | export function CartProvider(props: CartProviderProps) {
26 | const { children } = props;
27 | const [state, dispatch] = React.useReducer(reducer, initialState);
28 |
29 | const setCart = (payload: Cart | null) => dispatch({ type: "SET_CART", payload });
30 |
31 | const getCart = React.useCallback(async () => {
32 | try {
33 | const cart = await CartService.retrieve();
34 | setCart(cart);
35 | } catch (err: any) {
36 | toast.error(err.message);
37 | }
38 | }, []);
39 |
40 | React.useEffect(() => {
41 | getCart();
42 | }, [getCart]);
43 |
44 | if (state.isLoading) return ;
45 |
46 | return (
47 |
48 | {children}
49 |
50 | );
51 | }
52 | export const useCartStateContext = () => React.useContext(CartStateContext);
53 | export const useCartDispatchContext = () => React.useContext(CartDispatchContext);
54 |
--------------------------------------------------------------------------------
/src/features/Cart/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useColorModeContext } from "@/providers/Theme";
2 | import { DarkMode, LightMode, ShoppingCart } from "@mui/icons-material";
3 | import * as Mui from "@mui/material";
4 | import Link from "next/link";
5 | import { useCartStateContext } from "../Provider";
6 |
7 | const StyledToolbar = Mui.styled(Mui.Toolbar)({
8 | display: "flex",
9 | justifyContent: "space-between",
10 | alignItems: "center",
11 | });
12 | const StyledAppBar = Mui.styled(Mui.AppBar)(({ theme }) => ({
13 | background: theme.palette.background.paper,
14 | }));
15 | const RightContent = Mui.styled(Mui.Box)({
16 | ...StyledToolbar,
17 | });
18 | const LeftContent = Mui.styled(Mui.Box)({
19 | display: "flex",
20 | justifyContent: "center",
21 | alignItems: "center",
22 | gap: 10,
23 | });
24 | export default function Header() {
25 | const { toggleColorMode, currentMode } = useColorModeContext();
26 | const { cart } = useCartStateContext();
27 | return (
28 |
29 |
30 |
31 |
32 |
33 | DevR Commerce
34 |
35 |
36 |
37 | {currentMode === "light" ? : }
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/features/Cart/components/OrderSummary.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@/styles";
2 | import { Block, ShoppingBag } from "@mui/icons-material";
3 | import * as Mui from "@mui/material";
4 | import Link from "next/link";
5 | import { useCartDispatchContext, useCartStateContext } from "../Provider";
6 | import Cart from "../service";
7 |
8 | const OrderSummaryContainer = Mui.styled(Mui.Paper)(({ theme }) => ({
9 | flex: 1,
10 | [theme.breakpoints.up("lg")]: {
11 | maxWidth: 400,
12 | minWidth: 350,
13 | },
14 | boxShadow: "none",
15 | padding: "50px 20px",
16 | }));
17 | export default function OrderSummary() {
18 | const { cart } = useCartStateContext();
19 | const { setCart } = useCartDispatchContext();
20 | const emptyCart = async () => {
21 | const { cart } = await Cart.empty();
22 | setCart(cart);
23 | };
24 | return (
25 |
26 |
27 | Order Summary
28 |
29 |
30 |
31 | Items {cart?.total_items}
32 | {cart?.subtotal.formatted_with_symbol}
33 |
34 |
35 | emptyCart()}
37 | color="warning"
38 | startIcon={}
39 | fullWidth
40 | variant="contained"
41 | disabled={!cart?.line_items.length}
42 | >
43 | Empty Cart
44 |
45 |
46 | }
49 | fullWidth
50 | variant="contained"
51 | >
52 | Checkout
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/features/Cart/components/Table.tsx:
--------------------------------------------------------------------------------
1 | import { NoData } from "@/components";
2 | import { Flex } from "@/styles";
3 | import { Add, Delete, Remove } from "@mui/icons-material";
4 | import * as Mui from "@mui/material";
5 | import Image from "next/image";
6 | import { useCartDispatchContext, useCartStateContext } from "../Provider";
7 | import Service from "../service";
8 |
9 | const StyledTableContainer = Mui.styled(Mui.TableContainer)(({ theme }) => ({
10 | background: theme.palette.background.secondary,
11 | borderRadius: 5,
12 | boxShadow: theme.shadows[1],
13 | }));
14 | const StyledTableCell = Mui.styled(Mui.TableCell)(({ theme }) => ({
15 | [`&.${Mui.tableCellClasses.head}`]: {
16 | backgroundColor: theme.palette.background.paper,
17 | color: theme.palette.primary.main,
18 | },
19 | }));
20 | const Quantity = Mui.styled(Mui.Typography)({ padding: "20px" });
21 |
22 | export default function CartTable() {
23 | const { cart } = useCartStateContext();
24 | const { setCart } = useCartDispatchContext();
25 | const addQuantity = async (lineId: string, currentQty: number) => {
26 | const { cart } = await Service.update(lineId, (currentQty += 1));
27 | setCart(cart);
28 | };
29 | const subQuantity = async (lineId: string, currentQty: number) => {
30 | const updatedQty = (currentQty -= 1);
31 | if (updatedQty <= 0) {
32 | const { cart } = await Service.remove(lineId);
33 | setCart(cart);
34 | return;
35 | }
36 | const { cart } = await Service.update(lineId, updatedQty);
37 | setCart(cart);
38 | };
39 | const removeFromCart = (lineId: string) => subQuantity(lineId, 0);
40 | if (!cart?.line_items.length) {
41 | return (
42 |
46 | );
47 | }
48 | return (
49 |
50 |
51 |
52 |
53 | Product Details
54 | Quantity
55 | Price
56 |
57 |
58 |
59 | {cart?.line_items.map((item) => (
60 |
61 |
62 |
63 |
71 |
72 |
73 | {item.name}
74 |
75 | removeFromCart(item.id)}
77 | color="error"
78 | variant="outlined"
79 | startIcon={}
80 | >
81 | Remove
82 |
83 |
84 |
85 |
86 |
87 |
88 | addQuantity(item.id, item.quantity)}
91 | >
92 |
93 |
94 | {item.quantity}
95 | subQuantity(item.id, item.quantity)}
98 | >
99 |
100 |
101 |
102 |
103 |
104 | {item.price.formatted_with_symbol}
105 |
106 |
107 | ))}
108 |
109 |
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/src/features/Cart/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Header } from "./Header";
2 | export { default as OrderSummary } from "./OrderSummary";
3 | export { default as CartTable } from "./Table";
4 |
--------------------------------------------------------------------------------
/src/features/Cart/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./components";
2 | export * from "./Provider";
3 | export { default as CartService } from "./service";
4 |
--------------------------------------------------------------------------------
/src/features/Cart/service.ts:
--------------------------------------------------------------------------------
1 | import client from "@/lib/commerce";
2 |
3 | const Cart = {
4 | async add(productId: string) {
5 | return await client.cart.add(productId);
6 | },
7 | async retrieve() {
8 | return await client.cart.retrieve();
9 | },
10 | async update(lineId: string, quantity: number) {
11 | return await client.cart.update(lineId, { quantity });
12 | },
13 | async remove(lineId: string) {
14 | return await client.cart.remove(lineId);
15 | },
16 | async empty() {
17 | return await client.cart.empty();
18 | },
19 | };
20 |
21 | export default Cart;
22 |
--------------------------------------------------------------------------------
/src/features/Cart/types/index.ts:
--------------------------------------------------------------------------------
1 | import { AppProps } from "@/types";
2 | import { Cart } from "@chec/commerce.js/types/cart";
3 | import React from "react";
4 |
5 | export type InitialState = { cart: Cart | null; isLoading: boolean };
6 | export type DispatchState = { setCart: (payload: Cart | null) => void };
7 | export type Action = { type: "SET_CART"; payload: Cart | null };
8 | export type Reducer = React.Reducer;
9 | export type CartProviderProps = AppProps;
10 |
--------------------------------------------------------------------------------
/src/features/Checkout/Provider.tsx:
--------------------------------------------------------------------------------
1 | import { useCartStateContext } from "@/features/Cart";
2 | import { AppProps } from "@/types";
3 | import { CheckoutCaptureResponse } from "@chec/commerce.js/types/checkout-capture-response";
4 | import { CheckoutToken } from "@chec/commerce.js/types/checkout-token";
5 | import { useRouter } from "next/router";
6 | import React from "react";
7 | import toast from "react-hot-toast";
8 | import validator from "validator";
9 | import Checkout from "./service";
10 | import { DispatchState, FieldNames, InitialState, Reducer, Rule } from "./types";
11 |
12 | const initialState: InitialState = {
13 | isLoading: true,
14 | checkoutToken: null,
15 | firstName: "",
16 | lastName: "",
17 | email: "",
18 | shippingStreet: "",
19 | shippingCity: "",
20 | shippingStateProvince: "",
21 | shippingPostalZipCode: "",
22 | shippingCountry: "",
23 | shippingCountries: {},
24 | shippingSubdivisions: { isLoading: true, data: {} },
25 | shippingOptions: { isLoading: true, data: [] },
26 | shippingOption: "",
27 | order: null,
28 | errors: [],
29 | };
30 |
31 | const CheckoutStateContext = React.createContext(initialState);
32 | const CheckoutDispatchContext = React.createContext({
33 | setTokenAndShippingCountries: () => null,
34 | setValue: () => null,
35 | setShippingCountry: () => null,
36 | setShippingSubDivision: () => null,
37 | validateInputs: () => false,
38 | setOrder: () => null,
39 | });
40 |
41 | const reducer: Reducer = (state = initialState, action) => {
42 | switch (action.type) {
43 | case "SET_VALUE": {
44 | return { ...state, [action.payload.name]: action.payload.value };
45 | }
46 | case "SET_TOKEN_AND_SHIPPING_COUNTRIES": {
47 | return {
48 | ...state,
49 | checkoutToken: action.payload.token,
50 | shippingCountries: action.payload.countries,
51 | isLoading: false,
52 | };
53 | }
54 | case "SET_LOADING_STATE": {
55 | return {
56 | ...state,
57 | [action.payload.name]: { ...state[action.payload.name], isLoading: true },
58 | };
59 | }
60 | case "LOAD_SUB_DIVISIONS": {
61 | return { ...state, shippingSubdivisions: { data: action.payload.data, isLoading: false } };
62 | }
63 | case "LOAD_SHIPPING_OPTIONS": {
64 | return { ...state, shippingOptions: { data: action.payload.data, isLoading: false } };
65 | }
66 | case "THROW_ERROR": {
67 | const doesErrorExist = state.errors.find((error) => error.field === action.payload.field);
68 | if (!doesErrorExist) {
69 | return { ...state, errors: [...state.errors, action.payload] };
70 | } else return state;
71 | }
72 | case "REMOVE_ERROR": {
73 | console.log("call in remove error");
74 | const errors = state.errors.filter((error) => error.field !== action.payload.field);
75 | return { ...state, errors };
76 | }
77 | case "SET_ORDER": {
78 | return { ...state, order: action.payload };
79 | }
80 | }
81 | };
82 |
83 | export function CheckoutProvider(props: AppProps) {
84 | const { children } = props;
85 | const [state, dispatch] = React.useReducer(reducer, initialState);
86 | const { cart } = useCartStateContext();
87 | const router = useRouter();
88 |
89 | const setTokenAndShippingCountries = (token: CheckoutToken, countries: {}) =>
90 | dispatch({ type: "SET_TOKEN_AND_SHIPPING_COUNTRIES", payload: { token, countries } });
91 |
92 | const setValue = (name: FieldNames, value: string) =>
93 | dispatch({ type: "SET_VALUE", payload: { name, value } });
94 |
95 | const setLoadingState = (name: "shippingSubdivisions" | "shippingOptions") =>
96 | dispatch({ type: "SET_LOADING_STATE", payload: { name } });
97 |
98 | const setSubDivisions = (data: {}) => dispatch({ type: "LOAD_SUB_DIVISIONS", payload: { data } });
99 | const setShippingOptions = (data: []) =>
100 | dispatch({ type: "LOAD_SHIPPING_OPTIONS", payload: { data } });
101 |
102 | const setOrder = (order: CheckoutCaptureResponse) =>
103 | dispatch({ type: "SET_ORDER", payload: order });
104 |
105 | const setShippingCountry = async (countryCode: string) => {
106 | setValue("shippingCountry", countryCode);
107 | setValue("shippingStateProvince", "");
108 | setValue("shippingOption", "");
109 | setShippingOptions([]);
110 | setLoadingState("shippingSubdivisions");
111 | setLoadingState("shippingOptions");
112 | const subDivisions = await Checkout.getSubDivisions(countryCode);
113 | setSubDivisions(subDivisions);
114 | };
115 |
116 | const setShippingSubDivision = async (stateProvince: string) => {
117 | setValue("shippingStateProvince", stateProvince);
118 | setValue("shippingOption", "");
119 | setLoadingState("shippingOptions");
120 | const shippingOptions = await Checkout.getShippingOptions(
121 | state.checkoutToken?.id as string,
122 | state.shippingCountry,
123 | stateProvince
124 | );
125 | setShippingOptions(shippingOptions);
126 | };
127 |
128 | const throwError = (field: string, message: string) =>
129 | dispatch({ type: "THROW_ERROR", payload: { field, message } });
130 | const removeError = (field: FieldNames) => dispatch({ type: "REMOVE_ERROR", payload: { field } });
131 |
132 | const validateInputs = () => {
133 | let isValidated = true;
134 |
135 | const rules: Rule[] = [
136 | {
137 | field: "email",
138 | condition: validator.isEmail(state.email),
139 | errorMessage: "Please enter a valid email",
140 | },
141 | ];
142 |
143 | rules.map((rule) => {
144 | if (!rule.condition) {
145 | isValidated = false;
146 | throwError(rule.field, rule.errorMessage);
147 | } else {
148 | removeError("email");
149 | }
150 | });
151 |
152 | return isValidated;
153 | };
154 |
155 | const generateCheckoutToken = React.useCallback(async () => {
156 | try {
157 | if (cart?.line_items.length) {
158 | const token = await Checkout.generateToken(cart.id);
159 | const countries = await Checkout.getShippingCountries(token.id);
160 | setTokenAndShippingCountries(token, countries);
161 | } else {
162 | router.replace("/cart");
163 | toast.error("Your cart is empty");
164 | }
165 | } catch (err: any) {
166 | toast.error(err.message);
167 | }
168 | }, [cart, router]);
169 |
170 | React.useEffect(() => {
171 | generateCheckoutToken();
172 | }, [generateCheckoutToken]);
173 |
174 | return (
175 |
185 | {children}
186 |
187 | );
188 | }
189 | export const useCheckoutStateContext = () => React.useContext(CheckoutStateContext);
190 | export const useCheckoutDispatchContext = () => React.useContext(CheckoutDispatchContext);
191 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/AddressForm/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as Mui from "@mui/material";
2 | import { ButtonProps } from "../../types";
3 |
4 | const Button = (props: ButtonProps) => {
5 | const { children, ...restProps } = props;
6 | return (
7 |
8 |
9 | {children}
10 |
11 |
12 | );
13 | };
14 |
15 | export default Button;
16 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/AddressForm/Input.tsx:
--------------------------------------------------------------------------------
1 | import * as Mui from "@mui/material";
2 | import { ChangeEvent } from "react";
3 | import { useCheckoutDispatchContext, useCheckoutStateContext } from "../../Provider";
4 | import { FieldNames, InputProps } from "../../types";
5 |
6 | const Input = (props: InputProps) => {
7 | const { label, name } = props;
8 | const { setValue } = useCheckoutDispatchContext();
9 | const state = useCheckoutStateContext();
10 | const handleChange = (e: ChangeEvent) =>
11 | setValue(e.target.name as FieldNames, e.target.value);
12 | const isError = !!state.errors.find((error) => error.field === name);
13 | const error = state.errors.find((error) => error.field === name);
14 | return (
15 |
16 |
17 |
25 | {error?.message}
26 |
27 |
28 | );
29 | };
30 |
31 | export default Input;
32 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/AddressForm/Select.tsx:
--------------------------------------------------------------------------------
1 | import * as Mui from "@mui/material";
2 | import { SelectChangeEvent } from "@mui/material";
3 | import { useCheckoutDispatchContext, useCheckoutStateContext } from "../../Provider";
4 | import { FieldNames, SelectProps } from "../../types";
5 |
6 | const Select = (props: SelectProps) => {
7 | const { options, label, name, onChange: changeHandler, disabled = false } = props;
8 | const state = useCheckoutStateContext();
9 | const { setValue } = useCheckoutDispatchContext();
10 | const handleChange = (e: SelectChangeEvent) =>
11 | setValue(e.target.name as FieldNames, e.target.value);
12 | return (
13 |
14 |
15 | {label}
16 |
17 | {options.map((option) => (
18 |
19 | {option.label}
20 |
21 | ))}
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default Select;
29 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/AddressForm/index.tsx:
--------------------------------------------------------------------------------
1 | import * as Mui from "@mui/material";
2 | import { SelectChangeEvent } from "@mui/material";
3 | import { FormEvent } from "react";
4 | import { useCheckoutDispatchContext, useCheckoutStateContext } from "../../Provider";
5 | import { FormProps } from "../../types";
6 | import Button from "./Button";
7 | import Input from "./Input";
8 | import Select from "./Select";
9 |
10 | const AddressFormContainer = Mui.styled(Mui.Box)({});
11 | export default function AddressForm(props: FormProps) {
12 | const { nextStep } = props;
13 | const state = useCheckoutStateContext();
14 | const { setShippingCountry, setShippingSubDivision, validateInputs } =
15 | useCheckoutDispatchContext();
16 |
17 | const handleSubmit = (e: FormEvent) => {
18 | e.preventDefault();
19 | const isValidated = validateInputs();
20 | isValidated && nextStep();
21 | };
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/Confirmation.tsx:
--------------------------------------------------------------------------------
1 | import { useCartDispatchContext } from "@/features/Cart";
2 | import * as Mui from "@mui/material";
3 | import { useRouter } from "next/router";
4 | import { useCheckoutStateContext } from "../Provider";
5 |
6 | const ConfirmationContainer = Mui.styled(Mui.Box)({});
7 | export default function Confirmation() {
8 | const { order } = useCheckoutStateContext();
9 | const { setCart } = useCartDispatchContext();
10 | const router = useRouter();
11 | const handleClick = () => {
12 | setCart(null);
13 | router.push("/cart");
14 | };
15 | return (
16 |
17 |
18 | Thank you for purchase, {order?.customer.firstname}!
19 |
20 |
21 | Order ref: {order?.customer_reference}
22 |
23 | Back to cart
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/PaymentForm/Review.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@/styles";
2 | import * as Mui from "@mui/material";
3 | import Image from "next/image";
4 | import { useCheckoutStateContext } from "../../Provider";
5 |
6 | const ReviewContainer = Mui.styled(Mui.Box)({
7 | width: "100%",
8 | marginBottom: 30,
9 | display: "flex",
10 | flexDirection: "column",
11 | gap: 20,
12 | });
13 | const ImageContainer = Mui.styled(Mui.Box)({
14 | width: 75,
15 | height: 75,
16 | overflow: "hidden",
17 | borderRadius: 2,
18 | alignSelf: "flex-start",
19 | });
20 | const SingleReview = Mui.styled(Flex)(({ theme }) => ({}));
21 | export default function Review() {
22 | const { checkoutToken } = useCheckoutStateContext();
23 | console.log(checkoutToken?.line_items);
24 | return (
25 |
26 |
27 | Order Summary
28 |
29 | {checkoutToken?.line_items.map((item: any) => (
30 |
31 |
32 |
39 |
40 |
41 | {item.name}
42 |
43 | {item.price.formatted_with_symbol}
44 |
45 |
46 | Quantity: {item.quantity}
47 |
48 |
49 |
50 | ))}
51 |
52 | Total:
53 |
54 | {checkoutToken?.live.subtotal.formatted_with_symbol}
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/PaymentForm/index.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/providers/Theme";
2 | import { Flex } from "@/styles";
3 | import { CheckoutCapture } from "@chec/commerce.js/types/checkout-capture";
4 | import Button from "@mui/lab/LoadingButton";
5 | import { Alert, AlertTitle, Box, Divider, styled, Typography } from "@mui/material";
6 | import { CardElement, Elements, ElementsConsumer } from "@stripe/react-stripe-js";
7 | import { loadStripe, Stripe, StripeElements, StripeElementStyle } from "@stripe/stripe-js";
8 | import { FormEvent, useState } from "react";
9 | import { useCheckoutDispatchContext, useCheckoutStateContext } from "../../Provider";
10 | import Checkout from "../../service";
11 | import { FormProps } from "../../types";
12 | import Review from "./Review";
13 |
14 | const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_API_KEY);
15 |
16 | const CardElementContainer = styled(Box)({
17 | marginBottom: 30,
18 | marginTop: 30,
19 | });
20 | const ButtonsContainer = styled(Flex)({
21 | justifyContent: "space-between",
22 | alignItems: "center",
23 | });
24 | export default function PaymentForm(props: FormProps) {
25 | const { nextStep, prevStep } = props;
26 | const [isProcessing, setIsProcessing] = useState(false);
27 | const [checkoutError, setCheckoutError] = useState("");
28 | const state = useCheckoutStateContext();
29 | const { setOrder } = useCheckoutDispatchContext();
30 | const theme = useTheme();
31 |
32 | const handleSubmit = async (
33 | event: FormEvent,
34 | elements: StripeElements | null,
35 | stripe: Stripe | null
36 | ) => {
37 | try {
38 | event.preventDefault();
39 | setIsProcessing(true);
40 | setCheckoutError("");
41 | if (!stripe || !elements) throw new Error("Invalid stripe api key");
42 |
43 | const cardElement = elements.getElement(CardElement);
44 | if (!cardElement) throw new Error("");
45 |
46 | const { error, paymentMethod } = await stripe.createPaymentMethod({
47 | type: "card",
48 | card: cardElement,
49 | });
50 | if (error) throw new Error(error.message);
51 |
52 | const orderData: CheckoutCapture = {
53 | line_items: state.checkoutToken?.live.line_items,
54 | customer: { firstname: state.firstName, lastname: state.lastName, email: state.email },
55 | shipping: {
56 | name: "International",
57 | street: state.shippingStreet,
58 | town_city: state.shippingCity,
59 | county_state: state.shippingStateProvince,
60 | postal_zip_code: state.shippingPostalZipCode,
61 | country: state.shippingCountry,
62 | },
63 | fulfillment: { shipping_method: state.shippingOption },
64 | payment: {
65 | gateway: "stripe",
66 | stripe: { payment_method_id: paymentMethod.id },
67 | },
68 | };
69 |
70 | const order = await Checkout.captureCheckout(state.checkoutToken?.id as string, orderData);
71 | setOrder(order);
72 | nextStep();
73 | } catch (err: any) {
74 | setCheckoutError(err.message);
75 | } finally {
76 | setIsProcessing(false);
77 | }
78 | };
79 |
80 | const cardElementStyles: StripeElementStyle = {
81 | base: {
82 | backgroundColor: "transparent",
83 | color: theme.palette.text.primary,
84 | },
85 | };
86 | const submitButtonText = isProcessing
87 | ? "Processing..."
88 | : `Pay ${state.checkoutToken?.live.subtotal.formatted_with_symbol}`;
89 |
90 | return (
91 |
92 |
93 | {({ elements, stripe }) => (
94 |
118 | )}
119 |
120 | {checkoutError && (
121 |
122 | Checkout Error
123 | {checkoutError}
124 |
125 | )}
126 |
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/src/features/Checkout/components/index.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "@/components";
2 | import { useCheckoutStateContext } from "@/features/Checkout";
3 | import { Paper, Step, StepLabel, Stepper, styled } from "@mui/material";
4 | import { useState } from "react";
5 | import AddressForm from "./AddressForm";
6 | import Confirmation from "./Confirmation";
7 | import PaymentForm from "./PaymentForm";
8 |
9 | const steps = [
10 | { label: "Shipping address", component: AddressForm },
11 | { label: "Payment details", component: PaymentForm },
12 | { label: "Confirmation", component: Confirmation },
13 | ];
14 |
15 | const CheckoutFormContainer = styled(Paper)(({ theme }) => ({
16 | width: "100%",
17 | maxWidth: 700,
18 | padding: 50,
19 | [theme.breakpoints.down("sm")]: {
20 | padding: 30,
21 | },
22 | }));
23 | const StyledStepper = styled(Stepper)(({ theme }) => ({
24 | marginBottom: 30,
25 | [theme.breakpoints.down("sm")]: {
26 | display: "none",
27 | },
28 | }));
29 |
30 | export default function CheckoutForm() {
31 | const [currentStep, setCurrentStep] = useState(0);
32 | const { isLoading } = useCheckoutStateContext();
33 | const StepArea = steps[currentStep].component;
34 |
35 | const nextStep = () => {
36 | if (currentStep + 1 < steps.length) {
37 | setCurrentStep((pre) => (pre += 1));
38 | }
39 | };
40 |
41 | const prevStep = () => {
42 | if (currentStep - 1 >= 0) {
43 | setCurrentStep((pre) => (pre -= 1));
44 | }
45 | };
46 |
47 | if (isLoading) return ;
48 |
49 | return (
50 |
51 |
52 | {steps.map((step) => (
53 |
54 | {step.label}
55 |
56 | ))}
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/features/Checkout/index.ts:
--------------------------------------------------------------------------------
1 | export { default as CheckoutForm } from "./components";
2 | export * from "./Provider";
3 | export { default as CheckoutService } from "./service";
4 |
--------------------------------------------------------------------------------
/src/features/Checkout/service.ts:
--------------------------------------------------------------------------------
1 | import client from "@/lib/commerce";
2 | import { CheckoutCapture } from "@chec/commerce.js/types/checkout-capture";
3 |
4 | const Checkout = {
5 | async generateToken(cartId: string) {
6 | return await client.checkout.generateToken(cartId, { type: "cart" });
7 | },
8 | async getShippingCountries(checkoutTokenId: string) {
9 | return (await client.services.localeListShippingCountries(checkoutTokenId)).countries;
10 | },
11 | async getSubDivisions(countryCode: string) {
12 | return (await client.services.localeListSubdivisions(countryCode)).subdivisions;
13 | },
14 | async getShippingOptions(
15 | checkoutTokenId: string,
16 | country: string,
17 | stateProvince: string | undefined = undefined
18 | ) {
19 | return (await client.checkout.getShippingOptions(checkoutTokenId, {
20 | country,
21 | region: stateProvince,
22 | })) as [];
23 | },
24 | async captureCheckout(checkoutTokenId: string, newOrder: CheckoutCapture) {
25 | return await client.checkout.capture(checkoutTokenId, newOrder);
26 | },
27 | };
28 |
29 | export default Checkout;
30 |
--------------------------------------------------------------------------------
/src/features/Checkout/types/index.ts:
--------------------------------------------------------------------------------
1 | import { AppProps } from "@/types";
2 | import { CheckoutCaptureResponse } from "@chec/commerce.js/types/checkout-capture-response";
3 | import { CheckoutToken } from "@chec/commerce.js/types/checkout-token";
4 | import { ShippingMethod } from "@chec/commerce.js/types/shipping-method";
5 | import * as Mui from "@mui/material";
6 | import React from "react";
7 |
8 | type Error = {
9 | field: string;
10 | message: string;
11 | };
12 |
13 | export type InitialState = {
14 | isLoading: boolean;
15 | checkoutToken: CheckoutToken | null;
16 | firstName: string;
17 | lastName: string;
18 | email: string;
19 | shippingStreet: string;
20 | shippingCity: string;
21 | shippingStateProvince: string;
22 | shippingPostalZipCode: string;
23 | shippingCountry: string;
24 | shippingCountries: {};
25 | shippingSubdivisions: { isLoading: boolean; data: {} };
26 | shippingOptions: { isLoading: boolean; data: ShippingMethod[] };
27 | shippingOption: string;
28 | order: CheckoutCaptureResponse | null;
29 | errors: Error[];
30 | };
31 |
32 | export type FieldNames =
33 | | "firstName"
34 | | "lastName"
35 | | "email"
36 | | "shippingStreet"
37 | | "shippingCity"
38 | | "shippingStateProvince"
39 | | "shippingPostalZipCode"
40 | | "shippingCountry"
41 | | "shippingOption"
42 | | "order";
43 |
44 | export type Action =
45 | | { type: "SET_VALUE"; payload: { name: FieldNames; value: string } }
46 | | { type: "SET_TOKEN_AND_SHIPPING_COUNTRIES"; payload: { token: CheckoutToken; countries: {} } }
47 | | { type: "SET_LOADING_STATE"; payload: { name: "shippingSubdivisions" | "shippingOptions" } }
48 | | { type: "LOAD_SUB_DIVISIONS"; payload: { data: {} } }
49 | | { type: "LOAD_SHIPPING_OPTIONS"; payload: { data: [] } }
50 | | { type: "THROW_ERROR"; payload: Error }
51 | | { type: "REMOVE_ERROR"; payload: { field: string } }
52 | | { type: "SET_ORDER"; payload: CheckoutCaptureResponse };
53 |
54 | export type Reducer = React.Reducer;
55 | export type Rule = {
56 | field: FieldNames;
57 | condition: boolean;
58 | errorMessage: string;
59 | };
60 | export type DispatchState = {
61 | setTokenAndShippingCountries: (token: CheckoutToken, countries: {}) => void;
62 | setValue: (name: FieldNames, value: string) => void;
63 | setShippingCountry: (countryCode: string) => void;
64 | setShippingSubDivision: (stateProvince: string) => void;
65 | validateInputs: () => boolean;
66 | setOrder: (order: CheckoutCaptureResponse) => void;
67 | };
68 |
69 | export type InputProps = {
70 | name: FieldNames;
71 | label: string;
72 | };
73 |
74 | export type SelectProps = {
75 | label: string;
76 | options: { label: string; value: any }[];
77 | name: "shippingCountry" | "shippingOption" | "shippingStateProvince";
78 | disabled?: boolean;
79 | onChange?: (e: Mui.SelectChangeEvent) => void;
80 | };
81 |
82 | export type ButtonProps = AppProps | Mui.ButtonProps;
83 |
84 | export type FormProps = { nextStep: () => void; prevStep: () => void };
85 |
--------------------------------------------------------------------------------
/src/lib/commerce.ts:
--------------------------------------------------------------------------------
1 | import CommerceSDK from "@chec/commerce.js";
2 |
3 | const client = new CommerceSDK(process.env.NEXT_PUBLIC_CHEC_PUBLIC_API_KEY);
4 |
5 | export default client;
6 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { Head, Layout, NextProgress, Toaster } from "@/components";
2 | import { CartProvider } from "@/features/Cart";
3 | import ThemeProvider from "@/providers/Theme";
4 | import createEmotionCache from "@/utils/createEmotionCache";
5 | import { CacheProvider, EmotionCache } from "@emotion/react";
6 | import "@fontsource/poppins";
7 | import { CssBaseline } from "@mui/material";
8 | import type { AppProps } from "next/app";
9 |
10 | type ExtendedAppProps = {
11 | emotionCache?: EmotionCache;
12 | } & AppProps;
13 |
14 | const clientSideEmotionCache = createEmotionCache();
15 | export default function App(props: ExtendedAppProps) {
16 | const { Component, pageProps, emotionCache = clientSideEmotionCache } = props;
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { darkTheme } from "@/styles/darkTheme";
2 | import createEmotionCache from "@/utils/createEmotionCache";
3 | import createEmotionServer from "@emotion/server/create-instance";
4 | import Document, { Head, Html, Main, NextScript } from "next/document";
5 | import React from "react";
6 |
7 | export default class CustomDocument extends Document {
8 | render() {
9 | return (
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | CustomDocument.getInitialProps = async (ctx) => {
33 | // Resolution order
34 | //
35 | // On the server:
36 | // 1. app.getInitialProps
37 | // 2. page.getInitialProps
38 | // 3. document.getInitialProps
39 | // 4. app.render
40 | // 5. page.render
41 | // 6. document.render
42 | //
43 | // On the server with error:
44 | // 1. document.getInitialProps
45 | // 2. app.render
46 | // 3. page.render
47 | // 4. document.render
48 | //
49 | // On the client
50 | // 1. app.getInitialProps
51 | // 2. page.getInitialProps
52 | // 3. app.render
53 | // 4. page.render
54 |
55 | const originalRenderPage = ctx.renderPage;
56 |
57 | // You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
58 | // However, be aware that it can have global side effects.
59 | const cache = createEmotionCache();
60 | const { extractCriticalToChunks } = createEmotionServer(cache);
61 |
62 | /* eslint-disable */
63 | ctx.renderPage = () =>
64 | originalRenderPage({
65 | enhanceApp: (App: any) => (props) => ,
66 | });
67 | /* eslint-enable */
68 |
69 | const initialProps = await Document.getInitialProps(ctx);
70 | // This is important. It prevents emotion to render invalid HTML.
71 | // See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
72 | const emotionStyles = extractCriticalToChunks(initialProps.html);
73 | const emotionStyleTags = emotionStyles.styles.map((style) => (
74 |
80 | ));
81 |
82 | return {
83 | ...initialProps,
84 | // Styles fragment is rendered after the app and page rendering finish.
85 | styles: [...React.Children.toArray(initialProps.styles), ...emotionStyleTags],
86 | };
87 | };
88 |
--------------------------------------------------------------------------------
/src/pages/api/hello.ts:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 |
4 | type Data = {
5 | name: string
6 | }
7 |
8 | export default function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse
11 | ) {
12 | res.status(200).json({ name: 'John Doe' })
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/cart.tsx:
--------------------------------------------------------------------------------
1 | import { Head, SectionTitle } from "@/components";
2 | import { CartTable, OrderSummary } from "@/features/Cart";
3 | import { Flex } from "@/styles";
4 | import { Box, styled } from "@mui/material";
5 |
6 | const CartContentWrapper = styled(Flex)(({ theme }) => ({
7 | flex: 1,
8 | }));
9 | const CartItems = styled(Box)({
10 | flex: 1,
11 | overflowX: "auto"
12 | });
13 | export default function CartPage() {
14 | return (
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/pages/checkout.tsx:
--------------------------------------------------------------------------------
1 | import { Head } from "@/components";
2 | import { CheckoutForm, CheckoutProvider } from "@/features/Checkout";
3 | import { Flex } from "@/styles";
4 | import { styled } from "@mui/material";
5 |
6 | const CheckoutContainer = styled(Flex)(({ theme }) => ({
7 | flex: 1,
8 | justifyContent: "center",
9 | alignItems: "center",
10 | padding: "50px 0",
11 | }));
12 | export default function CheckoutPage() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Head, HeroSection, ProductList } from "@/components";
2 | import client from "@/lib/commerce";
3 | import { GetServerSideProps, InferGetServerSidePropsType } from "next";
4 |
5 | export default function HomePage(props: InferGetServerSidePropsType) {
6 | const { products } = props;
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 | export const getServerSideProps: GetServerSideProps = async () => {
16 | const { data: products } = await client.products.list({ limit: 8 });
17 | return { props: { products } };
18 | };
19 |
--------------------------------------------------------------------------------
/src/providers/Theme.tsx:
--------------------------------------------------------------------------------
1 | import { darkTheme } from "@/styles/darkTheme";
2 | import { lightTheme } from "@/styles/lightTheme";
3 | import { AppProps } from "@/types";
4 | import storage from "@/utils/storage";
5 | import type { PaletteMode } from "@mui/material";
6 | import { ThemeProvider as MuiThemeProvider } from "@mui/material";
7 | import { createContext, useContext, useEffect, useMemo, useState } from "react";
8 |
9 | const STORAGE_KEY = "color-mode";
10 |
11 | type InitialState = { toggleColorMode: () => void; currentMode: PaletteMode };
12 | export const ColorModeContext = createContext({
13 | toggleColorMode: () => null,
14 | currentMode: "dark",
15 | });
16 | export default function ThemeProvider(props: AppProps) {
17 | const { children } = props;
18 | const [mode, setMode] = useState("dark");
19 | const theme = useMemo(() => (mode === "light" ? lightTheme : darkTheme), [mode]);
20 | const values = useMemo(
21 | () => ({
22 | toggleColorMode: () => {
23 | setMode((prevMode) => {
24 | const newMode = prevMode === "light" ? "dark" : "light";
25 | storage.setItem(STORAGE_KEY, newMode);
26 | return newMode;
27 | });
28 | },
29 | currentMode: mode,
30 | }),
31 | [mode]
32 | );
33 | useEffect(() => {
34 | setMode(storage.getItem(STORAGE_KEY) ?? "dark");
35 | }, []);
36 | return (
37 |
38 | {children}
39 |
40 | );
41 | }
42 | export const useColorModeContext = () => useContext(ColorModeContext);
43 | export const useTheme = () => {
44 | const { currentMode } = useColorModeContext();
45 | return currentMode === "light" ? lightTheme : darkTheme;
46 | };
47 |
--------------------------------------------------------------------------------
/src/styles/darkTheme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme, Theme } from "@mui/material";
2 |
3 | declare module "@mui/material/styles" {
4 | interface TypeBackground {
5 | secondary: string;
6 | }
7 | }
8 | export const darkTheme: Theme = createTheme({
9 | palette: {
10 | mode: "dark",
11 | background: { default: "#22212C", paper: "#14141B", secondary: "#282A36" },
12 | primary: { main: "#ff79c6", dark: "#c94695", light: "#ffacf9" },
13 | secondary: { main: "#bd93f9", dark: "#8b65c6", light: "#f1c4ff" },
14 | info: { main: "#8be9fd", dark: "#56b7ca", light: "#c0ffff" },
15 | error: { main: "#ff5555", dark: "#c5162c", light: "#ff8982" },
16 | success: { main: "#50fa7b", dark: "#00c64c", light: "#8dffac" },
17 | warning: { main: "#ffb86c", dark: "#c9883e", light: "#ffea9c" },
18 | common: { white: "#f8f8f2" },
19 | text: {
20 | primary: "#f8f8f2",
21 | secondary: "#8a8f98",
22 | disabled: "#6272a4",
23 | },
24 | divider: "#6272a4",
25 | },
26 | typography: { fontFamily: "Poppins" },
27 | components: {
28 | MuiButton: {
29 | styleOverrides: { root: { textTransform: "capitalize" } },
30 | },
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/src/styles/index.ts:
--------------------------------------------------------------------------------
1 | import { Box, styled } from "@mui/material";
2 |
3 | export const Flex = styled(Box)({
4 | display: "flex",
5 | });
6 |
--------------------------------------------------------------------------------
/src/styles/lightTheme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme, Theme } from "@mui/material";
2 |
3 | declare module "@mui/material/styles" {
4 | interface TypeBackground {
5 | secondary: string;
6 | }
7 | }
8 | export const lightTheme: Theme = createTheme({
9 | palette: {
10 | mode: "light",
11 | background: { default: "#fff", paper: "#efefef", secondary: "#f7f7f7" },
12 | primary: { main: "#ea5cbc", dark: "#b5238c", light: "#ff8fef" },
13 | secondary: { main: "#8964ba", dark: "#59398a", light: "#bb92ed" },
14 | info: { main: "#8be9fd", dark: "#56b7ca", light: "#c0ffff" },
15 | error: { main: "#ff5555", dark: "#c5162c", light: "#ff8982" },
16 | success: { main: "#50fa7b", dark: "#00c64c", light: "#8dffac" },
17 | warning: { main: "#ffb86c", dark: "#c9883e", light: "#ffea9c" },
18 | common: { white: "#f8f8f2" },
19 | text: {
20 | primary: "#282a36",
21 | secondary: "#282a36",
22 | disabled: "#92a0d6",
23 | },
24 | divider: "#92a0d6",
25 | },
26 | typography: { fontFamily: "Poppins" },
27 | components: {
28 | MuiButton: {
29 | styleOverrides: { root: { textTransform: "capitalize" } },
30 | },
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 |
3 | export type AppProps = {
4 | children: ReactNode;
5 | };
6 |
--------------------------------------------------------------------------------
/src/utils/createEmotionCache.ts:
--------------------------------------------------------------------------------
1 | import createCache from "@emotion/cache";
2 |
3 | const createEmotionCache = () => {
4 | return createCache({ key: "css", prepend: true });
5 | };
6 |
7 | export default createEmotionCache;
8 |
--------------------------------------------------------------------------------
/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | import Cookies from "universal-cookie";
2 |
3 | const cookie = new Cookies();
4 |
5 | const storage = {
6 | setItem: (key: string, item: unknown) => {
7 | console.log(key, item);
8 | cookie.set(key, item);
9 | },
10 | getItem(key: string) {
11 | return cookie.get(key);
12 | },
13 | removeItem(key: string) {
14 | cookie.remove(key);
15 | },
16 | };
17 |
18 | export default storage;
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@/*": ["./src/*"]
20 | }
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------