├── .babelrc ├── .env ├── .env.test ├── .eslintignore ├── .eslintrc.cjs ├── .eslintrc.js ├── .github └── workflows │ └── postgres-setup.yaml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── README.md ├── __e2e_tests__ └── e2e-process-payment.spec.ts ├── __tests__ ├── helper.ts ├── specs │ ├── customers.ts │ ├── discounts.ts │ ├── products.ts │ └── variants.ts └── testdata.ts ├── components ├── CartItems.tsx ├── Checkout.tsx ├── CheckoutResult.tsx ├── CheckoutSidebar.tsx ├── Customer.tsx ├── CustomerForm.tsx ├── CustomersChart.tsx ├── DataTable.tsx ├── DiscountAdmin.tsx ├── DiscountEdit.tsx ├── DiscountNew.tsx ├── Layout.tsx ├── Modal.tsx ├── ModalVariant.tsx ├── Nav.tsx ├── NavAdmin.tsx ├── Order.tsx ├── OrderForm.tsx ├── OrdersChart.tsx ├── PrintObject.tsx ├── Product.tsx ├── ProductAdmin.tsx ├── ProductEdit.tsx ├── ProductImages.tsx ├── ProductNew.tsx ├── Products.tsx ├── Search.tsx ├── SearchResult.tsx ├── Spinner.tsx ├── UserForm.tsx └── login-btn.tsx ├── context └── Cart.tsx ├── jest.config.js ├── jest.setup.js ├── lib ├── customers.ts ├── discounts.ts ├── helpers.tsx ├── images.ts ├── orders.ts ├── prisma.ts ├── products.ts ├── schemas.ts ├── types.ts ├── user.ts └── variants.ts ├── netlify.toml ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── admin │ ├── customer │ │ └── [id].tsx │ ├── customers.tsx │ ├── dashboard.tsx │ ├── discount-new.tsx │ ├── discount │ │ └── [id].tsx │ ├── discounts.tsx │ ├── index.tsx │ ├── order │ │ └── [id].tsx │ ├── orders.tsx │ ├── product-new.tsx │ ├── product │ │ └── [id].tsx │ ├── products.tsx │ └── user │ │ └── [id].tsx ├── api │ ├── auth │ │ └── [...nextauth].ts │ ├── customer.ts │ ├── customers.ts │ ├── customers │ │ ├── create.ts │ │ ├── save.ts │ │ └── search.ts │ ├── dashboard │ │ ├── customers.ts │ │ ├── discounts.ts │ │ ├── files │ │ │ ├── remove │ │ │ │ └── [id].tsx │ │ │ ├── sort │ │ │ │ └── index.ts │ │ │ └── upload.ts │ │ ├── orders.ts │ │ ├── products.ts │ │ └── products │ │ │ └── search.ts │ ├── discount │ │ ├── checkcode.ts │ │ ├── create.ts │ │ ├── delete.ts │ │ ├── get.ts │ │ └── save.ts │ ├── order.ts │ ├── orders.ts │ ├── orders │ │ └── search.ts │ ├── product │ │ ├── admin.ts │ │ ├── create.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ └── save.ts │ ├── products.ts │ ├── products │ │ └── admin.ts │ ├── search.ts │ ├── square │ │ ├── checkout-hosted-return.ts │ │ ├── create-checkout.ts │ │ └── webhook.ts │ ├── stripe │ │ ├── checkout-hosted-return.ts │ │ ├── create-checkout.ts │ │ └── webhook.ts │ ├── user │ │ └── update.ts │ ├── variants │ │ ├── delete.ts │ │ └── save.ts │ └── verifone │ │ ├── checkout-hosted-return.ts │ │ ├── create-checkout.ts │ │ └── webhook.ts ├── checkout-result.tsx ├── checkout.tsx ├── index.tsx ├── order │ └── [id].tsx ├── product │ └── [permalink].tsx ├── search │ └── [keyword].tsx └── styles.css ├── playwright.config.ts ├── prisma ├── products.ts ├── schema-mongodb-example.prisma ├── schema.prisma └── seed.mjs ├── public ├── drop-image.jpg ├── favicon.png ├── images │ ├── 5-panel-camp-hat │ │ ├── 1.jpg │ │ └── 2.jpg │ ├── derby-tier-backpack │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ └── 3.jpg │ ├── harriet-chambray-shirt │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ └── 4.jpg │ ├── hudderton-backpack │ │ ├── 1.jpg │ │ └── 2.jpg │ ├── red-wing-iron-ranger-boot │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ └── 3.jpg │ ├── scout-backpack │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ └── 3.jpg │ └── whitney-pullover │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ └── 4.jpg ├── placeholder.png └── screenshot.jpg ├── tsconfig.json ├── turbo.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VERIFONE_API_ENDPOINT=https://cst.test-gsc.vfims.com 2 | VERIFONE_PUBLIC_KEY=00000000-0000-0000-0000-000000000000 3 | VERIFONE_USER_UID=00000000-0000-0000-0000-000000000000 4 | VERIFONE_ENTITY_ID=00000000-0000-0000-0000-000000000000 5 | VERIFONE_PAYMENT_CONTRACT=00000000-0000-0000-0000-000000000000 6 | VERIFONE_THEME_ID=00000000-0000-0000-0000-000000000000 7 | STRIPE_SECRET_KEY=sk_test 8 | STRIPE_WEBHOOK_SECRET=we_.... 9 | SQUARE_ACCESS_TOKEN=xxxxxx..... 10 | SQUARE_LOCATION_ID=xxxxxxxxxxxxx 11 | SQUARE_WEBHOOK_URL=http://localhost:3000/api/square/webhook 12 | DATABASE_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/nextjs-checkout 13 | NEXTAUTH_URL=http://localhost:3000 14 | BASE_URL=http://localhost:3000 15 | NEXT_PUBLIC_PAYMENT_CURRENCY=AUD 16 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 17 | NEXT_PUBLIC_PAYMENT_CONFIG=stripe 18 | GITHUB_CLIENT_ID=clientid-here 19 | GITHUB_SECRET=secret-here 20 | NEXTAUTH_SECRET=a-random-string 21 | AWS_REGION=us-east-1 22 | AWS_S3_BUCKET_NAME=nextjs-checkout 23 | AWS_ACCESS_KEY_ID=my-key 24 | AWS_SECRET_ACCESS_KEY=my-key -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | VERIFONE_API_ENDPOINT=https://cst.test-gsc.vfims.com 2 | VERIFONE_PUBLIC_KEY=00000000-0000-0000-0000-000000000000 3 | VERIFONE_USER_UID=00000000-0000-0000-0000-000000000000 4 | VERIFONE_ENTITY_ID=00000000-0000-0000-0000-000000000000 5 | VERIFONE_PAYMENT_CONTRACT=00000000-0000-0000-0000-000000000000 6 | VERIFONE_THEME_ID=00000000-0000-0000-0000-000000000000 7 | STRIPE_SECRET_KEY=sk_test 8 | STRIPE_WEBHOOK_SECRET=we_.... 9 | SQUARE_ACCESS_TOKEN=xxxxxx..... 10 | SQUARE_LOCATION_ID=xxxxxxxxxxxxx 11 | SQUARE_WEBHOOK_URL=http://localhost:3000/api/square/webhook 12 | DATABASE_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/nextjs-checkout 13 | NEXTAUTH_URL=http://localhost:3000 14 | BASE_URL=http://localhost:3000 15 | NEXT_PUBLIC_PAYMENT_CURRENCY=AUD 16 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 17 | NEXT_PUBLIC_PAYMENT_CONFIG=square 18 | GITHUB_CLIENT_ID=clientid-here 19 | GITHUB_SECRET=secret-here 20 | NEXTAUTH_SECRET=a-random-string 21 | AWS_S3_BUCKET_NAME=nextjs-checkout-dev 22 | AWS_ACCESS_KEY_ID=my-key 23 | AWS_SECRET_ACCESS_KEY=my-key -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@typescript-eslint/utils').TSESLint.Linter.Config} */ 2 | const config = { 3 | rules: { 4 | 'no-restricted-syntax': [ 5 | 'warn', 6 | // Warn on nesting elements, {' '} 58 | 59 | 60 | ); 61 | } 62 | 63 | return ( 64 | <> 65 | 66 | 67 |

Cart

68 |
69 | {cart()} 70 |
71 | 72 | ); 73 | }; 74 | 75 | export default CheckoutSidebar; 76 | -------------------------------------------------------------------------------- /components/Customer.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger-with-children */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React, { useState } from 'react'; 5 | import Error from 'next/error'; 6 | import { Breadcrumb, Col, Row } from 'react-bootstrap'; 7 | import { toast } from 'react-toastify'; 8 | import { useSession } from 'next-auth/react'; 9 | import Spinner from './Spinner'; 10 | import CustomerForm from './CustomerForm'; 11 | import { Session } from '../lib/types'; 12 | 13 | const Customer = props => { 14 | const [loading, setLoading] = useState(false); 15 | const [customer, setCustomer] = useState(props.data); 16 | 17 | // Check for user session 18 | const { data: session } = useSession({ 19 | required: true, 20 | onUnauthenticated() { 21 | window.location.href = '/api/auth/signin'; 22 | }, 23 | }) as unknown as Session; 24 | 25 | // Return error if we don't have a customer 26 | if (props.data && Object.keys(props.data).length === 0) { 27 | return ; 28 | } 29 | 30 | const saveCustomerButton = ( 31 | 38 | ); 39 | 40 | async function saveCustomer(formData) { 41 | // Set spinner to loading 42 | setLoading(true); 43 | 44 | // Add the customer ID 45 | formData.id = customer.id; 46 | // fetch 47 | fetch('/api/customers/save', { 48 | method: 'POST', 49 | cache: 'no-cache', 50 | headers: { 51 | 'Content-Type': 'application/json', 52 | 'x-user-id': session.user.id, 53 | 'x-api-key': session.user.apiKey, 54 | }, 55 | body: JSON.stringify(formData), 56 | }) 57 | .then(function (response) { 58 | return response.json(); 59 | }) 60 | .then(function (data) { 61 | // Turn off the spinner 62 | setLoading(false); 63 | 64 | // Check for error 65 | if (data.error) { 66 | toast(data.error, { 67 | hideProgressBar: false, 68 | autoClose: 2000, 69 | type: 'error', 70 | }); 71 | return; 72 | } 73 | setCustomer(data); 74 | 75 | toast('Customer updated', { 76 | hideProgressBar: false, 77 | autoClose: 2000, 78 | type: 'success', 79 | }); 80 | }) 81 | .catch(function (err) { 82 | // There was an error 83 | setLoading(false); 84 | toast(err.error, { 85 | hideProgressBar: false, 86 | autoClose: 2000, 87 | type: 'error', 88 | }); 89 | }); 90 | } 91 | 92 | return ( 93 | 94 | 95 | 96 | 97 | 98 | Home 99 | 100 | 101 | Customers 102 | 103 | {customer.id} 104 | 105 | 110 | 111 | 112 | ); 113 | }; 114 | 115 | export default Customer; 116 | -------------------------------------------------------------------------------- /components/CustomersChart.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger-with-children */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React, { useEffect, useState } from 'react'; 5 | import { useRouter } from 'next/router'; 6 | import { Bar } from 'react-chartjs-2'; 7 | import { 8 | BarElement, 9 | CategoryScale, 10 | Chart as ChartJS, 11 | Colors, 12 | Legend, 13 | LinearScale, 14 | Title, 15 | Tooltip, 16 | } from 'chart.js'; 17 | import Error from 'next/error'; 18 | import Spinner from './Spinner'; 19 | 20 | ChartJS.register( 21 | CategoryScale, 22 | Colors, 23 | LinearScale, 24 | BarElement, 25 | Title, 26 | Tooltip, 27 | Legend, 28 | ); 29 | 30 | const CustomersChart = () => { 31 | const router = useRouter(); 32 | const [loading, setLoading] = useState(true); 33 | const [data, setData] = useState(); 34 | 35 | useEffect(() => { 36 | if (!router.isReady) { 37 | return; 38 | } 39 | getData(); 40 | }, [router.isReady]); 41 | 42 | function getData() { 43 | // fetch 44 | fetch('/api/dashboard/customers', { 45 | method: 'GET', 46 | cache: 'no-cache', 47 | headers: { 48 | 'Content-Type': 'application/json', 49 | }, 50 | }) 51 | .then(function (response) { 52 | return response.json(); 53 | }) 54 | .then(function (data) { 55 | setLoading(false); 56 | setData(data); 57 | }) 58 | .catch(function (err) { 59 | // There was an error 60 | console.log('Payload error:' + err); 61 | }); 62 | } 63 | 64 | // Check if data found 65 | if (!data) { 66 | return ; 67 | } 68 | 69 | // Return error if we don't have a product 70 | if (data && Object.keys(data).length === 0) { 71 | return ; 72 | } 73 | 74 | // Format the chart data 75 | const chartData = []; 76 | for (const row in data.results) { 77 | chartData.push({ 78 | x: row, 79 | y: data.results[row], 80 | }); 81 | } 82 | 83 | return ( 84 | 106 | ); 107 | }; 108 | 109 | export default CustomersChart; 110 | -------------------------------------------------------------------------------- /components/DataTable.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import React from 'react'; 3 | import { Table } from 'react-bootstrap'; 4 | import { format } from 'date-fns'; 5 | import { currency } from '../lib/helpers'; 6 | 7 | function lookupValue(object, key) { 8 | return key.split('.').reduce((o, i) => o[i], object); 9 | } 10 | 11 | const DataTable = props => { 12 | if (props.data.length === 0) { 13 | return

{props.datamessage || 'No results'}

; 14 | } 15 | 16 | function printProperty(item, column) { 17 | const value = lookupValue(item, column.name); 18 | if (column.format && column.format === 'date') { 19 | return format(new Date(value), 'dd/MM/yyyy KK:mmaaa'); 20 | } 21 | if (column.format && column.format === 'amount') { 22 | return currency(value / 100); 23 | } 24 | if (column.format && column.format === 'enabled') { 25 | if (value === true) { 26 | return Enabled; 27 | } 28 | return Disabled; 29 | } 30 | if (column.function) { 31 | return ( 32 | 38 | ); 39 | } 40 | if (column.link) { 41 | return
{value}; 42 | } 43 | return value; 44 | } 45 | 46 | return ( 47 | <> 48 | 49 | 50 | 51 | {props.columns.map(column => ( 52 | 53 | ))} 54 | 55 | 56 | 57 | {props.data.map(item => ( 58 | 59 | {props.columns.map(column => ( 60 | 63 | ))} 64 | 65 | ))} 66 | 67 |
{column.title}
61 | {printProperty(item, column)} 62 |
68 | 69 | ); 70 | }; 71 | 72 | export default DataTable; 73 | -------------------------------------------------------------------------------- /components/DiscountAdmin.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger-with-children */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React from 'react'; 5 | import Error from 'next/error'; 6 | import { Breadcrumb, Col, Row } from 'react-bootstrap'; 7 | import DiscountEdit from './DiscountEdit'; 8 | import DiscountNew from './DiscountNew'; 9 | 10 | const Discount = props => { 11 | // Return error if we don't have a discount 12 | if (props.discount && Object.keys(props.discount).length === 0) { 13 | return ; 14 | } 15 | 16 | const DiscountForm = () => { 17 | if (props.type === 'edit') { 18 | return ; 19 | } 20 | return ; 21 | }; 22 | 23 | const DiscountCode = () => { 24 | if (props.discount && props.discount.code) { 25 | return ( 26 | {props.discount.code} 27 | ); 28 | } 29 | return; 30 | }; 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | Home 38 | 39 | 40 | Discounts 41 | 42 | {DiscountCode()} 43 | 44 | {DiscountForm()} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Discount; 51 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import Head from 'next/head'; 3 | 4 | type Props = { 5 | children: ReactNode; 6 | title?: string; 7 | }; 8 | 9 | const Layout = ({ 10 | children, 11 | title = 'nextjs-checkout | A Next.js Shopping cart', 12 | }: Props) => ( 13 | <> 14 | 15 | {title} 16 | 17 | 21 | 25 | 26 | 27 |
{children}
28 | 29 | ); 30 | 31 | export default Layout; 32 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Button, Modal } from 'react-bootstrap'; 3 | 4 | type Props = { 5 | showmodal: boolean; 6 | modalTitle: string; 7 | modalText: string; 8 | onCancel: any; 9 | onConfirm: any; 10 | }; 11 | 12 | const PopUpConfirm = (props: Props) => { 13 | const [showmodal, setShowModal] = useState(false); 14 | 15 | useEffect(() => { 16 | setShowModal(props.showmodal); 17 | }); 18 | const handleModalClose = () => { 19 | setShowModal(false); 20 | props.onCancel(true); 21 | }; 22 | const handleConfirm = () => { 23 | props.onConfirm(); 24 | props.onCancel(true); 25 | }; 26 | 27 | return ( 28 | 29 | 30 | {props.modalTitle} 31 | 32 | 33 |
34 |
{props.modalText}
35 |
36 |
37 | 38 | 41 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | export default PopUpConfirm; 50 | -------------------------------------------------------------------------------- /components/ModalVariant.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Button, Form, Modal } from 'react-bootstrap'; 4 | import { Session } from '../lib/types'; 5 | import { toast } from 'react-toastify'; 6 | 7 | type Props = { 8 | showmodal: boolean; 9 | productId: string; 10 | onCancel: any; 11 | onConfirm: any; 12 | }; 13 | 14 | const ModalVariant = (props: Props) => { 15 | const [showmodal, setShowModal] = useState(false); 16 | const [modalData, setModalData] = useState({ 17 | title: '', 18 | values: '', 19 | }); 20 | 21 | useEffect(() => { 22 | setShowModal(props.showmodal); 23 | }); 24 | 25 | // Check for user session 26 | const { data: session } = useSession({ 27 | required: true, 28 | onUnauthenticated() { 29 | window.location.href = '/api/auth/signin'; 30 | }, 31 | }) as unknown as Session; 32 | 33 | const handleModalClose = () => { 34 | setShowModal(false); 35 | props.onCancel(true); 36 | }; 37 | 38 | function handleConfirm() { 39 | // fetch 40 | fetch('/api/variants/save', { 41 | method: 'POST', 42 | cache: 'no-cache', 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | 'x-user-id': session.user.id, 46 | 'x-api-key': session.user.apiKey, 47 | }, 48 | body: JSON.stringify({ 49 | title: modalData.title, 50 | values: modalData.values, 51 | productId: props.productId, 52 | }), 53 | }) 54 | .then(function (response) { 55 | return response.json(); 56 | }) 57 | .then(function (data) { 58 | // Check for error 59 | if (data.error) { 60 | toast(data.error, { 61 | hideProgressBar: false, 62 | autoClose: 2000, 63 | type: 'error', 64 | }); 65 | return; 66 | } 67 | toast('Variant added', { 68 | hideProgressBar: false, 69 | autoClose: 2000, 70 | type: 'success', 71 | }); 72 | setShowModal(false); 73 | props.onCancel(true); 74 | window.location.reload(); 75 | }) 76 | .catch(function (err) { 77 | toast(err.error, { 78 | hideProgressBar: false, 79 | autoClose: 2000, 80 | type: 'error', 81 | }); 82 | }); 83 | } 84 | 85 | return ( 86 | 87 | 88 | New Variant 89 | 90 | 91 |
92 | 96 | Title 97 | { 100 | setModalData({ 101 | ...modalData, 102 | title: event.target.value, 103 | }); 104 | }} 105 | placeholder="Size" 106 | type="text" 107 | value={modalData.title} 108 | /> 109 | Values (comma seperated) 110 | { 113 | setModalData({ 114 | ...modalData, 115 | values: event.target.value, 116 | }); 117 | }} 118 | placeholder="Small,Medium,Large" 119 | type="text" 120 | value={modalData.values} 121 | /> 122 | 123 |
124 |
125 | 126 | 129 | 132 | 133 |
134 | ); 135 | }; 136 | 137 | export default ModalVariant; 138 | -------------------------------------------------------------------------------- /components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent, useContext } from 'react'; 2 | import Link from 'next/link'; 3 | import { CartContext } from '../context/Cart'; 4 | import { useLocalStorage } from 'usehooks-ts'; 5 | import { Shop } from 'react-bootstrap-icons'; 6 | import { Button, Container, Navbar } from 'react-bootstrap'; 7 | import Search from './Search'; 8 | 9 | const Nav = () => { 10 | const [cartState, setCartState] = useLocalStorage('cartSidebarState', true); 11 | const { totalItems } = useContext(CartContext); 12 | 13 | const toggleSideCart = (e: MouseEvent) => { 14 | e.preventDefault(); 15 | setCartState(() => !cartState); 16 | }; 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 26 | 27 | nextjs-checkout 28 | 29 | 30 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default Nav; 52 | -------------------------------------------------------------------------------- /components/NavAdmin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import { Shop } from 'react-bootstrap-icons'; 4 | import { signOut, useSession } from 'next-auth/react'; 5 | import { Container, Navbar, NavDropdown } from 'react-bootstrap'; 6 | import { MenuButtonWide } from 'react-bootstrap-icons'; 7 | import Search from './Search'; 8 | import { Session } from '../lib/types'; 9 | 10 | const NavAdmin = () => { 11 | const { data: session } = useSession({ 12 | required: true, 13 | onUnauthenticated() { 14 | window.location.href = '/api/auth/signin'; 15 | }, 16 | }) as unknown as Session; 17 | 18 | // Check if session is retrieved 19 | if (session === undefined) { 20 | return <>; 21 | } 22 | 23 | function loggedIn() { 24 | if (!session) { 25 | return; 26 | } 27 | return ( 28 | <> 29 | 34 | } 35 | > 36 | 37 | Dashboard 38 | 39 | 40 | Products 41 | 42 | 43 | Orders 44 | 45 | 46 | Customers 47 | 48 | 49 | Discounts 50 | 51 | 52 | User 53 | 54 | 55 | signOut()}> 56 | Logout 57 | 58 | 59 | 60 | ); 61 | } 62 | 63 | return ( 64 | <> 65 | 66 | 67 | 71 | 72 | nextjs-checkout 73 | 74 | {loggedIn()} 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default NavAdmin; 83 | -------------------------------------------------------------------------------- /components/Order.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger-with-children */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React from 'react'; 5 | import Error from 'next/error'; 6 | import { Breadcrumb, Col, Row } from 'react-bootstrap'; 7 | import OrderForm from './OrderForm'; 8 | 9 | const Order = props => { 10 | // Return error if we don't have a product 11 | if (props.order && Object.keys(props.order).length === 0) { 12 | return ; 13 | } 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | Home 21 | 22 | 23 | Orders 24 | 25 | {props.order.id} 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default Order; 34 | -------------------------------------------------------------------------------- /components/OrdersChart.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger-with-children */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React, { useEffect, useState } from 'react'; 5 | import { useRouter } from 'next/router'; 6 | import { Bar } from 'react-chartjs-2'; 7 | import { 8 | BarElement, 9 | CategoryScale, 10 | Chart as ChartJS, 11 | Legend, 12 | LinearScale, 13 | Title, 14 | Tooltip, 15 | } from 'chart.js'; 16 | import Error from 'next/error'; 17 | import Spinner from './Spinner'; 18 | 19 | ChartJS.register( 20 | CategoryScale, 21 | LinearScale, 22 | BarElement, 23 | Title, 24 | Tooltip, 25 | Legend, 26 | ); 27 | ChartJS.defaults.color = '#212529'; 28 | ChartJS.defaults.font.size = 14; 29 | ChartJS.defaults.font.family = 30 | 'system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji'; 31 | 32 | const OrdersChart = () => { 33 | const router = useRouter(); 34 | const [loading, setLoading] = useState(true); 35 | const [data, setData] = useState(); 36 | 37 | useEffect(() => { 38 | if (!router.isReady) { 39 | return; 40 | } 41 | getData(); 42 | }, [router.isReady]); 43 | 44 | function getData() { 45 | // fetch 46 | fetch('/api/dashboard/orders', { 47 | method: 'GET', 48 | cache: 'no-cache', 49 | headers: { 50 | 'Content-Type': 'application/json', 51 | }, 52 | }) 53 | .then(function (response) { 54 | return response.json(); 55 | }) 56 | .then(function (data) { 57 | setLoading(false); 58 | setData(data); 59 | }) 60 | .catch(function (err) { 61 | // There was an error 62 | console.log('Payload error:' + err); 63 | }); 64 | } 65 | 66 | // Check if data found 67 | if (!data) { 68 | return ; 69 | } 70 | 71 | // Return error if we don't have a product 72 | if (data && Object.keys(data).length === 0) { 73 | return ; 74 | } 75 | 76 | // Format the chart data 77 | const chartData = []; 78 | for (const row in data.results) { 79 | chartData.push({ 80 | x: row, 81 | y: data.results[row], 82 | }); 83 | } 84 | 85 | return ( 86 | 114 | ); 115 | }; 116 | 117 | export default OrdersChart; 118 | -------------------------------------------------------------------------------- /components/PrintObject.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | content: object; 5 | }; 6 | 7 | const PrintObject = ({ content }: Props) => { 8 | const formattedContent: string = JSON.stringify(content, null, 2); 9 | return
{formattedContent}
; 10 | }; 11 | 12 | export default PrintObject; 13 | -------------------------------------------------------------------------------- /components/ProductAdmin.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger-with-children */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React from 'react'; 5 | import Error from 'next/error'; 6 | import { Breadcrumb, Col, Row } from 'react-bootstrap'; 7 | import ProductEdit from './ProductEdit'; 8 | import ProductNew from './ProductNew'; 9 | 10 | const Product = props => { 11 | // Return error if we don't have a product 12 | if (props.product && Object.keys(props.product).length === 0) { 13 | return ; 14 | } 15 | 16 | const ProductForm = () => { 17 | if (props.type === 'edit') { 18 | return ; 19 | } 20 | return ; 21 | }; 22 | 23 | const ProductName = () => { 24 | if (props.product && props.product.name) { 25 | return ( 26 | {props.product.name} 27 | ); 28 | } 29 | return; 30 | }; 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | Home 38 | 39 | 40 | Products 41 | 42 | {ProductName()} 43 | 44 | {ProductForm()} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Product; 51 | -------------------------------------------------------------------------------- /components/Search.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { Button, Form, InputGroup } from 'react-bootstrap'; 4 | 5 | type Props = { 6 | className?: string; 7 | }; 8 | 9 | const Search = (props: Props) => { 10 | const router = useRouter(); 11 | const [searchTerm, setSearchTerm] = useState(''); 12 | 13 | useEffect(() => { 14 | if (!router.isReady) { 15 | return; 16 | } 17 | 18 | // If search term in param, set input 19 | const searchQuery = router.query.keyword; 20 | if (searchQuery && searchQuery !== '') { 21 | setSearchTerm(searchQuery.toString()); 22 | } 23 | }, [router.isReady]); 24 | 25 | // Redirect to our search term 26 | function searchProducts() { 27 | if (searchTerm.trim() === '') { 28 | window.location.href = '/'; 29 | return; 30 | } 31 | window.location.href = `/search/${encodeURI(searchTerm)}`; 32 | } 33 | 34 | return ( 35 |
36 | 37 | setSearchTerm(e.target.value)} 40 | placeholder="Search products..." 41 | value={searchTerm} 42 | /> 43 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export default Search; 56 | -------------------------------------------------------------------------------- /components/SearchResult.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import React, { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import Link from 'next/link'; 6 | import { toast } from 'react-toastify'; 7 | import { currency } from '../lib/helpers'; 8 | 9 | const SearchResult = () => { 10 | const router = useRouter(); 11 | const [searchTerm, setSearchTerm] = useState(); 12 | const [searchResults, setSearchResults] = useState(); 13 | 14 | useEffect(() => { 15 | if (!router.isReady) { 16 | return; 17 | } 18 | 19 | searchProducts(); 20 | }, [router.isReady]); 21 | 22 | function searchProducts() { 23 | const searchQuery = router.query.keyword; 24 | setSearchTerm(searchQuery); 25 | fetch('/api/search', { 26 | method: 'POST', 27 | cache: 'no-cache', 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | }, 31 | body: JSON.stringify({ 32 | searchTerm: searchQuery, 33 | }), 34 | }) 35 | .then(function (response) { 36 | return response.json(); 37 | }) 38 | .then(function (data) { 39 | if (data.error) { 40 | toast(data.error, { 41 | hideProgressBar: false, 42 | autoClose: 2000, 43 | type: 'error', 44 | }); 45 | setSearchResults([]); 46 | return; 47 | } 48 | setSearchResults(data); 49 | }) 50 | .catch(function (err) { 51 | // There was an error 52 | console.log('Payload error:' + err); 53 | }); 54 | } 55 | 56 | if (!searchResults) { 57 | return <>; 58 | } 59 | 60 | function mainImage(images) { 61 | if (images.length === 0) { 62 | return ( 63 | {'Product 68 | ); 69 | } 70 | return ( 71 | {images[0].alt} 82 | ); 83 | } 84 | 85 | return ( 86 | <> 87 |
88 |
89 | Showing {searchResults.length} results for ' 90 | {searchTerm}' 91 |
92 |
93 |
94 | {searchResults.map(product => ( 95 |
96 |
97 | {mainImage(product.images)} 98 |
99 |
100 | 104 |

{product.name}

105 | 106 | 107 | {currency(product.price / 100)} 108 | 109 |
110 |
111 |
112 |
113 | ))} 114 |
115 | 116 | ); 117 | }; 118 | 119 | export default SearchResult; 120 | -------------------------------------------------------------------------------- /components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import React from 'react'; 3 | import ClipLoader from 'react-spinners/ClipLoader'; 4 | 5 | const spinnerStyle = { 6 | position: 'fixed' as any, 7 | top: '50%', 8 | left: '50%', 9 | transform: 'translate(-50%, -50%)', 10 | zIndex: '999', 11 | }; 12 | 13 | type Props = { 14 | loading: boolean; 15 | }; 16 | 17 | const Spinner = (props: Props) => { 18 | if (props.loading === true) { 19 | return ( 20 |
21 |
22 |
26 | 32 |
33 |
34 |
35 | ); 36 | } 37 | return <>; 38 | }; 39 | 40 | export default Spinner; 41 | -------------------------------------------------------------------------------- /components/login-btn.tsx: -------------------------------------------------------------------------------- 1 | import { signIn, signOut, useSession } from 'next-auth/react'; 2 | export default function Component() { 3 | const { data: session } = useSession(); 4 | if (session) { 5 | return ( 6 | <> 7 | Signed in as {session.user.email}
8 | 9 | 10 | ); 11 | } 12 | return ( 13 | <> 14 | Not signed in
15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | '**/*.{js,jsx,ts,tsx}', 4 | '!**/*.d.ts', 5 | '!**/node_modules/**', 6 | ], 7 | setupFilesAfterEnv: ['/jest.setup.js'], 8 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 9 | transform: { 10 | '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/babel-jest', 11 | }, 12 | transformIgnorePatterns: [ 13 | '/node_modules/', 14 | '^.+\\.module\\.(css|sass|scss)$', 15 | ], 16 | moduleNameMapper: { 17 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ 5 | adapter: new Adapter(), 6 | }); 7 | -------------------------------------------------------------------------------- /lib/customers.ts: -------------------------------------------------------------------------------- 1 | import prisma from './prisma'; 2 | 3 | /* eslint-disable import/no-anonymous-default-export */ 4 | export async function createCustomer(args) { 5 | try { 6 | const dbEntry = { 7 | email: args.email, 8 | phone: args.phone, 9 | firstName: args.firstName, 10 | lastName: args.lastName, 11 | address1: args.address1, 12 | suburb: args.suburb, 13 | state: args.state, 14 | postcode: args.postcode, 15 | country: args.country, 16 | }; 17 | 18 | // Check for existing customer 19 | const customer = await prisma.customers.findFirst({ 20 | where: { email: args.email }, 21 | }); 22 | 23 | // If customer with that email exists, return that customer 24 | if (customer) { 25 | return customer; 26 | } 27 | 28 | // Insert the order record 29 | const data = await prisma.customers.create({ 30 | data: dbEntry, 31 | }); 32 | 33 | return data; 34 | } catch (ex) { 35 | console.log('err', ex); 36 | return {}; 37 | } 38 | } 39 | 40 | export async function updateCustomer(id, args) { 41 | try { 42 | // Update the customers record 43 | const data = await prisma.customers.update({ 44 | where: { id: id }, 45 | data: args, 46 | }); 47 | 48 | return data; 49 | } catch (ex) { 50 | console.log('err', ex); 51 | return {}; 52 | } 53 | } 54 | 55 | export async function getCustomer(id) { 56 | try { 57 | // Select the customer record 58 | const data = await prisma.customers.findFirst({ 59 | where: { id: id }, 60 | }); 61 | 62 | return data; 63 | } catch (ex) { 64 | console.log('err', ex); 65 | return {}; 66 | } 67 | } 68 | 69 | export async function getCustomers() { 70 | try { 71 | // Select the customers 72 | const data = await prisma.customers.findMany({ 73 | take: 20, 74 | orderBy: { 75 | created_at: 'desc', 76 | }, 77 | }); 78 | return data; 79 | } catch (ex) { 80 | console.log('err', ex); 81 | return {}; 82 | } 83 | } 84 | 85 | export default { 86 | createCustomer, 87 | updateCustomer, 88 | getCustomer, 89 | getCustomers, 90 | }; 91 | -------------------------------------------------------------------------------- /lib/discounts.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | import prisma from './prisma'; 3 | 4 | export async function getDiscount(discountCode) { 5 | try { 6 | // Default to permalink 7 | const dbQuery = { 8 | where: { 9 | code: discountCode, 10 | enabled: true, 11 | }, 12 | }; 13 | 14 | // Select the discount record 15 | const data = await prisma.discounts.findFirst(dbQuery); 16 | 17 | return data; 18 | } catch (ex) { 19 | console.log('err', ex); 20 | return {}; 21 | } 22 | } 23 | 24 | export async function getAdminDiscount(discountId) { 25 | try { 26 | const dbQuery = { 27 | where: { 28 | id: discountId, 29 | }, 30 | }; 31 | 32 | // Select the product record 33 | const data = await prisma.discounts.findFirst(dbQuery); 34 | 35 | return data; 36 | } catch (ex) { 37 | console.log('err', ex); 38 | return {}; 39 | } 40 | } 41 | 42 | // When in admin, return all discounts 43 | export async function getAdminDiscounts() { 44 | try { 45 | // Select the products 46 | const data = await prisma.discounts.findMany({}); 47 | 48 | return data; 49 | } catch (ex) { 50 | console.log('err', ex); 51 | return {}; 52 | } 53 | } 54 | 55 | export async function createDiscount(args) { 56 | try { 57 | // Update the discount record 58 | const data = await prisma.discounts.create({ 59 | data: { 60 | name: args.name, 61 | code: args.code, 62 | type: args.type, 63 | value: args.value, 64 | enabled: args.enabled, 65 | start_at: new Date(args.start_at), 66 | end_at: new Date(args.end_at), 67 | }, 68 | }); 69 | 70 | return data; 71 | } catch (ex) { 72 | console.log('err', ex); 73 | return {}; 74 | } 75 | } 76 | 77 | export async function updateDiscount(id, args) { 78 | try { 79 | // Update the discount record 80 | const data = await prisma.discounts.update({ 81 | where: { id: id }, 82 | data: args, 83 | }); 84 | 85 | return data; 86 | } catch (ex) { 87 | console.log('err', ex); 88 | return {}; 89 | } 90 | } 91 | 92 | export async function deleteDiscount(id) { 93 | try { 94 | // Update the discount record 95 | const data = await prisma.discounts.delete({ 96 | where: { id: id }, 97 | }); 98 | 99 | return data; 100 | } catch (ex) { 101 | console.log('err', ex); 102 | return {}; 103 | } 104 | } 105 | 106 | export default { 107 | getDiscount, 108 | getAdminDiscounts, 109 | createDiscount, 110 | updateDiscount, 111 | deleteDiscount, 112 | }; 113 | -------------------------------------------------------------------------------- /lib/helpers.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | import { schemas } from './schemas'; 3 | import Ajv from 'ajv'; 4 | import addFormats from 'ajv-formats'; 5 | const ajv = new Ajv({ allErrors: true }); 6 | addFormats(ajv); 7 | 8 | ajv.addKeyword({ 9 | keyword: 'isNotEmpty', 10 | type: 'string', 11 | schemaType: 'boolean', 12 | compile: () => data => { 13 | return data.trim() !== ''; 14 | }, 15 | }); 16 | 17 | export function currency(amount) { 18 | const formatter = new Intl.NumberFormat('en-US', { 19 | style: 'currency', 20 | currency: process.env.NEXT_PUBLIC_PAYMENT_CURRENCY, 21 | }); 22 | return formatter.format(amount); 23 | } 24 | 25 | export function removeCurrency(price) { 26 | price = price.replace('$', ''); 27 | price = price.replace('.', ''); 28 | return price; 29 | } 30 | 31 | export function calculateCartTotal(cartTotal, cartMeta) { 32 | if (cartMeta.discount) { 33 | if (cartMeta.discount.type === 'amount') { 34 | cartTotal = cartTotal - cartMeta.discount.value; 35 | } 36 | if (cartMeta.discount.type === 'percent') { 37 | const discountAmount = cartTotal * (cartMeta.discount.value / 100); 38 | cartTotal = cartTotal - discountAmount; 39 | } 40 | } 41 | return cartTotal; 42 | } 43 | 44 | export function validateSchema(schema, data) { 45 | const validated = ajv.validate(schemas[schema], data); 46 | return { 47 | valid: validated, 48 | errors: ajv.errors, 49 | }; 50 | } 51 | 52 | export default { 53 | currency, 54 | }; 55 | -------------------------------------------------------------------------------- /lib/images.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | import { createReadStream } from 'fs'; 3 | import { 4 | DeleteObjectCommand, 5 | PutObjectCommand, 6 | S3Client, 7 | } from '@aws-sdk/client-s3'; 8 | import { v4 as uuidv4 } from 'uuid'; 9 | 10 | type FileUpload = { 11 | size: number; 12 | originalFilename: string; 13 | mimetype: string; 14 | filepath: string; 15 | }; 16 | 17 | export async function upload(file: FileUpload) { 18 | try { 19 | // Check file size (bytes) - Defaults to 2mb 20 | if (file.size > 2000000) { 21 | return { 22 | error: true, 23 | message: 'File size exceeds the 2mb limit', 24 | }; 25 | } 26 | 27 | const fileExt = (/[^./\\]*$/.exec(file.originalFilename) || [''])[0]; 28 | const s3Client = new S3Client({ 29 | region: process.env.AWS_REGION, 30 | }); 31 | const s3Bucket = process.env.AWS_S3_BUCKET_NAME; 32 | const fileUUID = uuidv4(); 33 | const fileName = `${fileUUID}.${fileExt}`; 34 | 35 | // Check the mimetype of the file upload 36 | const allowedTypes = [ 37 | 'image/jpeg', 38 | 'image/jpg', 39 | 'image/png', 40 | 'image/gif', 41 | 'image/bmp', 42 | ]; 43 | if (!allowedTypes.includes(file.mimetype)) { 44 | return { 45 | error: true, 46 | message: 47 | 'File type not supported. Supported types: jpeg, jpg, png, gif, bmp', 48 | }; 49 | } 50 | 51 | // Upload the file 52 | const uploadCommand = new PutObjectCommand({ 53 | Bucket: s3Bucket, 54 | Key: fileName, 55 | Body: createReadStream(file.filepath), 56 | ACL: 'public-read', 57 | ContentType: file.mimetype, 58 | ContentLength: file.size, 59 | }); 60 | await s3Client.send(uploadCommand); 61 | 62 | // File response 63 | const fileResponse = { 64 | url: `https://${s3Bucket}.s3.amazonaws.com/${fileName}`, 65 | filename: fileName, 66 | error: false, 67 | }; 68 | 69 | return fileResponse; 70 | } catch (ex) { 71 | console.log('Error uploading file to AWS', ex); 72 | return { 73 | error: true, 74 | }; 75 | } 76 | } 77 | 78 | export async function remove(imageKey) { 79 | try { 80 | const s3Client = new S3Client({ 81 | region: process.env.AWS_REGION, 82 | }); 83 | const s3Bucket = process.env.AWS_S3_BUCKET_NAME; 84 | 85 | // Remove the file from S3 86 | const removeCommand = new DeleteObjectCommand({ 87 | Bucket: s3Bucket, 88 | Key: imageKey, 89 | }); 90 | await s3Client.send(removeCommand); 91 | 92 | return { 93 | message: 'Successfully deleted', 94 | }; 95 | } catch (ex) { 96 | console.log('err', ex); 97 | return { 98 | message: 'Unable to delete file', 99 | }; 100 | } 101 | } 102 | 103 | export default { 104 | upload, 105 | }; 106 | -------------------------------------------------------------------------------- /lib/orders.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../lib/prisma'; 2 | 3 | /* eslint-disable import/no-anonymous-default-export */ 4 | export async function createOrder(args) { 5 | try { 6 | const dbEntry = { 7 | status: args.status, 8 | cart: args.cart, 9 | totalAmount: args.totalAmount, 10 | totalUniqueItems: args.totalUniqueItems, 11 | paid: false, 12 | gateway: args.gateway, 13 | customerId: args.customer, 14 | }; 15 | 16 | // Insert the order record 17 | const data = await prisma.orders.create({ 18 | data: dbEntry, 19 | }); 20 | 21 | return data; 22 | } catch (ex) { 23 | console.log('err', ex); 24 | return {}; 25 | } 26 | } 27 | 28 | export async function updateOrder(id, args) { 29 | try { 30 | // Update the order record 31 | const data = await prisma.orders.update({ 32 | where: { id: id }, 33 | data: args, 34 | }); 35 | 36 | return data; 37 | } catch (ex) { 38 | console.log('err', ex); 39 | return {}; 40 | } 41 | } 42 | 43 | export async function getOrder(id) { 44 | try { 45 | // Select the order record 46 | const data = await prisma.orders.findFirst({ 47 | where: { id: id }, 48 | include: { 49 | customer: true, 50 | }, 51 | }); 52 | 53 | return data; 54 | } catch (ex) { 55 | console.log('err', ex); 56 | return {}; 57 | } 58 | } 59 | 60 | export async function getOrderByCheckout(checkoutId) { 61 | try { 62 | // Select the order record 63 | const data = await prisma.orders.findFirst({ 64 | where: { checkout_id: checkoutId }, 65 | include: { 66 | customer: true, 67 | }, 68 | }); 69 | 70 | return data; 71 | } catch (ex) { 72 | console.log('err', ex); 73 | return {}; 74 | } 75 | } 76 | 77 | export async function getOrders() { 78 | try { 79 | // Select the orders 80 | const orders = await prisma.orders.findMany({ 81 | include: { 82 | customer: true, 83 | }, 84 | take: 20, 85 | orderBy: { 86 | created_at: 'desc', 87 | }, 88 | }); 89 | return orders; 90 | } catch (ex) { 91 | console.log('err', ex); 92 | return {}; 93 | } 94 | } 95 | 96 | export default { 97 | createOrder, 98 | updateOrder, 99 | getOrder, 100 | getOrderByCheckout, 101 | getOrders, 102 | }; 103 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | // Docs about instantiating `PrismaClient` with Next.js: 4 | // https://pris.ly/d/help/next-js-best-practices 5 | 6 | let prisma: PrismaClient; 7 | 8 | if (process.env.NODE_ENV === 'production') { 9 | prisma = new PrismaClient(); 10 | } else { 11 | if (!global.prisma) { 12 | global.prisma = new PrismaClient(); 13 | } 14 | prisma = global.prisma; 15 | } 16 | 17 | export default prisma; 18 | -------------------------------------------------------------------------------- /lib/schemas.ts: -------------------------------------------------------------------------------- 1 | export const schemas = { 2 | newDiscount: { 3 | type: 'object', 4 | properties: { 5 | name: { type: 'string', isNotEmpty: true }, 6 | code: { type: 'string', isNotEmpty: true }, 7 | type: { type: 'string', enum: ['amount', 'percent'] }, 8 | value: { type: 'integer' }, 9 | enabled: { type: 'boolean' }, 10 | start_at: { type: 'string', format: 'date-time' }, 11 | end_at: { type: 'string', format: 'date-time' }, 12 | }, 13 | required: [ 14 | 'name', 15 | 'code', 16 | 'type', 17 | 'value', 18 | 'enabled', 19 | 'start_at', 20 | 'end_at', 21 | ], 22 | additionalProperties: false, 23 | }, 24 | saveDiscount: { 25 | type: 'object', 26 | properties: { 27 | name: { type: 'string', isNotEmpty: true }, 28 | code: { type: 'string', isNotEmpty: true }, 29 | type: { type: 'string', enum: ['amount', 'percent'] }, 30 | value: { type: 'integer' }, 31 | enabled: { type: 'boolean' }, 32 | start_at: { type: 'string', format: 'date-time' }, 33 | end_at: { type: 'string', format: 'date-time' }, 34 | }, 35 | required: [ 36 | 'name', 37 | 'code', 38 | 'type', 39 | 'value', 40 | 'enabled', 41 | 'start_at', 42 | 'end_at', 43 | ], 44 | additionalProperties: false, 45 | }, 46 | newProduct: { 47 | type: 'object', 48 | properties: { 49 | name: { type: 'string', isNotEmpty: true }, 50 | permalink: { type: 'string', isNotEmpty: true }, 51 | summary: { type: 'string', isNotEmpty: true }, 52 | description: { type: 'string', isNotEmpty: true }, 53 | price: { type: 'integer' }, 54 | enabled: { type: 'boolean' }, 55 | }, 56 | required: [ 57 | 'name', 58 | 'permalink', 59 | 'summary', 60 | 'description', 61 | 'price', 62 | 'enabled', 63 | ], 64 | additionalProperties: false, 65 | }, 66 | saveProduct: { 67 | type: 'object', 68 | properties: { 69 | id: { type: 'string' }, 70 | created_at: { type: 'string' }, 71 | name: { type: 'string', isNotEmpty: true }, 72 | permalink: { type: 'string', isNotEmpty: true }, 73 | summary: { type: 'string', isNotEmpty: true }, 74 | description: { type: 'string', isNotEmpty: true }, 75 | price: { type: 'string', format: 'double' }, 76 | enabled: { type: 'boolean' }, 77 | variants: { type: 'array' }, 78 | }, 79 | required: [ 80 | 'id', 81 | 'created_at', 82 | 'name', 83 | 'permalink', 84 | 'summary', 85 | 'description', 86 | 'price', 87 | 'enabled', 88 | ], 89 | additionalProperties: false, 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Session = { 2 | data: { 3 | user: { 4 | id: string; 5 | created_at: string; 6 | name: string; 7 | image: string; 8 | email: string; 9 | apiKey: string; 10 | enabled: boolean; 11 | }; 12 | expires: string; 13 | }; 14 | }; 15 | 16 | export type txnPayload = { 17 | token_scope: string; 18 | payment_provider_contract: string; 19 | amount: number; 20 | merchant_reference: string; 21 | currency_code: string; 22 | encrypted_card: string; 23 | public_key_alias: string; 24 | }; 25 | 26 | export type notificationPayload = { 27 | data: { 28 | event_type: string; 29 | object_type: string; 30 | data: string; 31 | }; 32 | }; 33 | 34 | export type settingsType = { 35 | id: string; 36 | running: boolean; 37 | }; 38 | -------------------------------------------------------------------------------- /lib/user.ts: -------------------------------------------------------------------------------- 1 | import prisma from './prisma'; 2 | 3 | export async function createUser(args) { 4 | try { 5 | // Update the customers record 6 | const data = await prisma.users.create({ 7 | data: args, 8 | }); 9 | 10 | return data; 11 | } catch (ex) { 12 | console.log('err', ex); 13 | return {}; 14 | } 15 | } 16 | 17 | export async function updateUser(id, args) { 18 | try { 19 | // Update the customers record 20 | const data = await prisma.users.update({ 21 | where: { id: id }, 22 | data: args, 23 | }); 24 | return data; 25 | } catch (ex) { 26 | console.log('err', ex); 27 | return {}; 28 | } 29 | } 30 | 31 | export async function getUserById(id) { 32 | try { 33 | // Select the customer record 34 | const data = await prisma.users.findFirst({ 35 | where: { id: id }, 36 | }); 37 | 38 | return data; 39 | } catch (ex) { 40 | console.log('err', ex); 41 | return {}; 42 | } 43 | } 44 | 45 | export async function getUserByEmail(email) { 46 | try { 47 | // Select the customer record 48 | const data = await prisma.users.findFirst({ 49 | where: { email: email }, 50 | }); 51 | 52 | return data; 53 | } catch (ex) { 54 | console.log('err', ex); 55 | return {}; 56 | } 57 | } 58 | 59 | export async function checkApiAuth(userId, apiKey) { 60 | if (!userId) { 61 | return { 62 | error: true, 63 | message: 'User not authorised', 64 | }; 65 | } 66 | if (!apiKey) { 67 | return { 68 | error: true, 69 | message: 'User not authorised', 70 | }; 71 | } 72 | 73 | return { 74 | error: false, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /lib/variants.ts: -------------------------------------------------------------------------------- 1 | import prisma from './prisma'; 2 | 3 | /* eslint-disable import/no-anonymous-default-export */ 4 | export async function createVariant(args) { 5 | try { 6 | const dbEntry = { 7 | title: args.title, 8 | values: args.values, 9 | enabled: true, 10 | productId: args.productId, 11 | }; 12 | 13 | // Insert the variant record 14 | const data = await prisma.variants.create({ 15 | data: dbEntry, 16 | }); 17 | 18 | return data; 19 | } catch (ex) { 20 | console.log('err', ex); 21 | return {}; 22 | } 23 | } 24 | 25 | export async function deleteVariant(id) { 26 | try { 27 | // delete the variant record 28 | const data = await prisma.variants.delete({ 29 | where: { id: id }, 30 | }); 31 | return data; 32 | } catch (ex) { 33 | return { 34 | error: 'Failed to delete variant', 35 | }; 36 | } 37 | } 38 | 39 | export default { 40 | createVariant, 41 | }; 42 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build" -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('next').NextConfig} 3 | */ 4 | const nextConfig = { 5 | typescript: { 6 | ignoreBuildErrors: true, 7 | }, 8 | }; 9 | 10 | module.exports = nextConfig; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-checkout", 3 | "version": "1.0.0", 4 | "description": "A superfast and full featured shopping cart built with Next.js and Prisma ORM.", 5 | "scripts": { 6 | "dev": "next", 7 | "turbo": "turbo build", 8 | "build": "prisma generate && next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "db": "npx prisma db push", 12 | "db:local": "npx dotenv -e .env.local prisma db push", 13 | "db:test": "npx dotenv -e .env.test prisma db push", 14 | "seed": "npx ts-node prisma/seed.mjs", 15 | "seed:test": "npx dotenv -e .env.test ts-node prisma/seed.mjs", 16 | "test": "npx dotenv -e .env.test -- jest --verbose ./__tests__/specs --runInBand", 17 | "test:ui": "npx playwright test" 18 | }, 19 | "prisma": { 20 | "seed": "npx ts-node prisma/seed.mjs" 21 | }, 22 | "keywords": [ 23 | "cart", 24 | "shopping", 25 | "website", 26 | "stripe", 27 | "square", 28 | "verifone", 29 | "nextjs", 30 | "react", 31 | "typescript", 32 | "shopping cart" 33 | ], 34 | "packageManager": "yarn@1.22.19", 35 | "dependencies": { 36 | "@aws-sdk/client-s3": "^3.354.0", 37 | "@prisma/client": "^5.7.1", 38 | "ajv": "^8.12.0", 39 | "ajv-formats": "^2.1.1", 40 | "array-move": "^4.0.0", 41 | "axios": "^1.4.0", 42 | "chart.js": "^4.3.0", 43 | "date-fns": "^2.30.0", 44 | "formidable": "^3.4.0", 45 | "formik": "^2.2.9", 46 | "lodash": "^4.17.21", 47 | "micro-cors": "^0.1.1", 48 | "mime-types": "^2.1.35", 49 | "next": "^13.2.4", 50 | "next-auth": "^4.22.1", 51 | "object-hash": "^3.0.0", 52 | "quill": "^1.3.7", 53 | "react": "^18.2.0", 54 | "react-bootstrap": "^2.7.2", 55 | "react-bootstrap-icons": "^1.10.2", 56 | "react-chartjs-2": "^5.2.0", 57 | "react-cookie": "^4.1.1", 58 | "react-datepicker": "^4.25.0", 59 | "react-dom": "^18.2.0", 60 | "react-dropzone": "^14.2.3", 61 | "react-easy-sort": "^1.5.1", 62 | "react-number-format": "^5.2.2", 63 | "react-quill": "^2.0.0", 64 | "react-responsive-carousel": "^3.2.23", 65 | "react-spinners": "^0.13.8", 66 | "react-toastify": "^9.1.2", 67 | "redux": "4.2.0", 68 | "sharp": "^0.32.0", 69 | "square": "^33.1.0", 70 | "stripe": "^11.17.0", 71 | "swr": "^2.0.0", 72 | "usehooks-ts": "^2.9.1", 73 | "uuid": "^9.0.0" 74 | }, 75 | "devDependencies": { 76 | "@playwright/test": "^1.32.3", 77 | "@types/jest": "^29.5.0", 78 | "@types/micro-cors": "^0.1.2", 79 | "@types/node": "^18.16.2", 80 | "@types/react": "^18.0.31", 81 | "@types/react-dom": "^18.0.11", 82 | "@typescript-eslint/eslint-plugin": "^5.57.0", 83 | "@typescript-eslint/parser": "^5.0.0", 84 | "dotenv": "^16.0.3", 85 | "dotenv-cli": "^7.2.1", 86 | "enzyme": "^3.11.0", 87 | "enzyme-adapter-react-16": "^1.15.7", 88 | "eslint": "^8.37.0", 89 | "eslint-config-next": "^13.2.4", 90 | "eslint-config-prettier": "^8.8.0", 91 | "eslint-plugin-prettier": "^4.2.1", 92 | "eslint-plugin-react": "^7.32.2", 93 | "eslint-plugin-react-hooks": "^4.6.0", 94 | "eslint-plugin-validate-jsx-nesting": "^0.1.0", 95 | "jest": "^29.5.0", 96 | "next-test-api-route-handler": "^3.1.8", 97 | "prettier": "^2.8.7", 98 | "prisma": "^5.7.1", 99 | "supertest": "^6.3.3", 100 | "ts-dotenv": "^0.9.1", 101 | "ts-node": "^10.9.1", 102 | "typescript": "^5.0.3" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import { SessionProvider } from 'next-auth/react'; 3 | import { CartProvider } from '../context/Cart'; 4 | import { ToastContainer } from 'react-toastify'; 5 | import 'react-toastify/dist/ReactToastify.css'; 6 | import './styles.css'; 7 | 8 | function App({ Component, pageProps: { session, ...pageProps } }: AppProps) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /pages/admin/customer/[id].tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import Layout from '../../../components/Layout'; 6 | import NavAdmin from '../../../components/NavAdmin'; 7 | import Customer from '../../../components/Customer'; 8 | import Spinner from '../../../components/Spinner'; 9 | import { Session } from '../../../lib/types'; 10 | 11 | const CustomerPage: NextPage = () => { 12 | const router = useRouter(); 13 | const [customerData, setCustomerData] = useState(false); 14 | 15 | // Check for user session 16 | const { data: session } = useSession({ 17 | required: true, 18 | onUnauthenticated() { 19 | window.location.href = '/api/auth/signin'; 20 | }, 21 | }) as unknown as Session; 22 | 23 | useEffect(() => { 24 | if (session) { 25 | const customerId = router.query.id; 26 | getCustomer(customerId); 27 | } 28 | }, [session]); 29 | 30 | function getCustomer(customerId) { 31 | fetch('/api/customer', { 32 | method: 'POST', 33 | cache: 'no-cache', 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | 'x-user-id': session.user.id, 37 | 'x-api-key': session.user.apiKey, 38 | }, 39 | body: JSON.stringify({ 40 | customerId: customerId, 41 | }), 42 | }) 43 | .then(function (response) { 44 | return response.json(); 45 | }) 46 | .then(function (data) { 47 | setCustomerData(data); 48 | }) 49 | .catch(function (err) { 50 | console.log('Payload error:' + err); 51 | }); 52 | } 53 | 54 | // Check for customer 55 | if (!customerData) { 56 | return ; 57 | } 58 | 59 | return ( 60 | 61 | 62 |

Customer

63 | 64 |
65 | ); 66 | }; 67 | 68 | export default CustomerPage; 69 | -------------------------------------------------------------------------------- /pages/admin/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import { Col, Row } from 'react-bootstrap'; 4 | import Layout from '../../components/Layout'; 5 | import NavAdmin from '../../components/NavAdmin'; 6 | import OrdersChart from '../../components/OrdersChart'; 7 | import CustomersChart from '../../components/CustomersChart'; 8 | import Spinner from '../../components/Spinner'; 9 | 10 | const DashboardPage: NextPage = () => { 11 | // Check for user session 12 | const { status } = useSession({ 13 | required: true, 14 | onUnauthenticated() { 15 | window.location.href = '/api/auth/signin'; 16 | }, 17 | }); 18 | if (status === 'loading') { 19 | return ; 20 | } 21 | 22 | return ( 23 | 24 | 25 |

Dashboard

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | ); 36 | }; 37 | 38 | export default DashboardPage; 39 | -------------------------------------------------------------------------------- /pages/admin/discount-new.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React from 'react'; 4 | import Layout from '../../components/Layout'; 5 | import NavAdmin from '../../components/NavAdmin'; 6 | import Discount from '../../components/DiscountAdmin'; 7 | import Spinner from '../../components/Spinner'; 8 | 9 | const DiscountNew: NextPage = () => { 10 | // Check for user session 11 | const { status } = useSession({ 12 | required: true, 13 | onUnauthenticated() { 14 | window.location.href = '/api/auth/signin'; 15 | }, 16 | }); 17 | 18 | if (status === 'loading') { 19 | return ; 20 | } 21 | 22 | return ( 23 | 24 | 25 |

Discount

26 | 27 |
28 | ); 29 | }; 30 | 31 | export default DiscountNew; 32 | -------------------------------------------------------------------------------- /pages/admin/discount/[id].tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import Layout from '../../../components/Layout'; 6 | import NavAdmin from '../../../components/NavAdmin'; 7 | import Discount from '../../../components/DiscountAdmin'; 8 | import Spinner from '../../../components/Spinner'; 9 | import { Session } from '../../../lib/types'; 10 | 11 | const DiscountPage: NextPage = () => { 12 | const router = useRouter(); 13 | const [discount, setDiscount] = useState(false); 14 | useEffect(() => { 15 | const discountId = router.query.id; 16 | if (session) { 17 | getDiscount(discountId); 18 | } 19 | }); 20 | 21 | // Check for user session 22 | const { data: session } = useSession({ 23 | required: true, 24 | onUnauthenticated() { 25 | window.location.href = '/api/auth/signin'; 26 | }, 27 | }) as unknown as Session; 28 | 29 | function getDiscount(discountId) { 30 | fetch('/api/discount/get', { 31 | method: 'POST', 32 | cache: 'no-cache', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | 'x-user-id': session.user.id, 36 | 'x-api-key': session.user.apiKey, 37 | }, 38 | body: JSON.stringify({ 39 | id: discountId, 40 | }), 41 | }) 42 | .then(function (response) { 43 | return response.json(); 44 | }) 45 | .then(function (data) { 46 | setDiscount(data); 47 | }) 48 | .catch(function (err) { 49 | console.log('Payload error:' + err); 50 | }); 51 | } 52 | 53 | // Check for discount 54 | if (!discount) { 55 | return ; 56 | } 57 | 58 | return ( 59 | 60 | 61 |

Discount

62 | 63 |
64 | ); 65 | }; 66 | 67 | export default DiscountPage; 68 | -------------------------------------------------------------------------------- /pages/admin/discounts.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import { Breadcrumb } from 'react-bootstrap'; 6 | import Link from 'next/link'; 7 | import Layout from '../../components/Layout'; 8 | import NavAdmin from '../../components/NavAdmin'; 9 | import DataTable from '../../components/DataTable'; 10 | import Spinner from '../../components/Spinner'; 11 | 12 | const DiscountsPage: NextPage = () => { 13 | const router = useRouter(); 14 | const [discounts, setDiscounts] = useState(false); 15 | useEffect(() => { 16 | if (!router.isReady) { 17 | return; 18 | } 19 | 20 | getDiscounts(); 21 | }, [router.isReady]); 22 | 23 | function getDiscounts() { 24 | fetch('/api/dashboard/discounts', { 25 | method: 'GET', 26 | cache: 'no-cache', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | }, 30 | }) 31 | .then(function (response) { 32 | return response.json(); 33 | }) 34 | .then(function (data) { 35 | setDiscounts(data); 36 | }) 37 | .catch(function (err) { 38 | // There was an error 39 | console.log('Payload error:' + err); 40 | }); 41 | } 42 | 43 | // Check for user session 44 | const { status } = useSession({ 45 | required: true, 46 | onUnauthenticated() { 47 | window.location.href = '/api/auth/signin'; 48 | }, 49 | }); 50 | 51 | if (status === 'loading') { 52 | return ; 53 | } 54 | 55 | // Check for products 56 | if (!discounts) { 57 | return <>; 58 | } 59 | 60 | const columns = [ 61 | { 62 | name: 'id', 63 | title: 'Discount ID', 64 | link: '/admin/discount/', 65 | }, 66 | { 67 | name: 'code', 68 | title: 'Code', 69 | }, 70 | { 71 | name: 'type', 72 | title: 'Type', 73 | }, 74 | { 75 | name: 'value', 76 | title: 'Value', 77 | }, 78 | { 79 | name: 'enabled', 80 | title: 'Status', 81 | format: 'enabled', 82 | }, 83 | { 84 | name: 'start_at', 85 | title: 'Starts at', 86 | format: 'date', 87 | }, 88 | { 89 | name: 'end_at', 90 | title: 'Ends at', 91 | format: 'date', 92 | }, 93 | ]; 94 | 95 | return ( 96 | 97 | 98 |

Discounts

99 |
100 |
101 | 102 | 103 | Home 104 | 105 | Discounts 106 | 107 |
108 |
109 | 113 | New 114 | 115 |
116 |
117 | 122 |
123 | ); 124 | }; 125 | 126 | export default DiscountsPage; 127 | -------------------------------------------------------------------------------- /pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import { useSession } from 'next-auth/react'; 4 | 5 | const IndexPage: NextPage = () => { 6 | const { data: session } = useSession(); 7 | if (session === undefined) { 8 | return <>; 9 | } 10 | if (!session) { 11 | window.location.href = '/api/auth/signin'; 12 | return; 13 | } 14 | window.location.href = '/admin/dashboard'; 15 | }; 16 | 17 | export default IndexPage; 18 | -------------------------------------------------------------------------------- /pages/admin/order/[id].tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import Layout from '../../../components/Layout'; 6 | import NavAdmin from '../../../components/NavAdmin'; 7 | import Order from '../../../components/Order'; 8 | import Spinner from '../../../components/Spinner'; 9 | 10 | const OrdersPage: NextPage = () => { 11 | const router = useRouter(); 12 | const [order, setOrder] = useState(false); 13 | useEffect(() => { 14 | if (!router.isReady) { 15 | return; 16 | } 17 | const orderId = router.query.id; 18 | getOrder(orderId); 19 | }, [router.isReady]); 20 | 21 | function getOrder(orderId) { 22 | fetch('/api/order', { 23 | method: 'POST', 24 | cache: 'no-cache', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | }, 28 | body: JSON.stringify({ 29 | orderId: orderId, 30 | }), 31 | }) 32 | .then(function (response) { 33 | return response.json(); 34 | }) 35 | .then(function (data) { 36 | setOrder(data); 37 | }) 38 | .catch(function (err) { 39 | console.log('Payload error:' + err); 40 | }); 41 | } 42 | 43 | // Check for user session 44 | const { status } = useSession({ 45 | required: true, 46 | onUnauthenticated() { 47 | window.location.href = '/api/auth/signin'; 48 | }, 49 | }); 50 | 51 | if (status === 'loading') { 52 | return ; 53 | } 54 | 55 | // Check for orders 56 | if (!order) { 57 | return <>; 58 | } 59 | 60 | return ( 61 | 62 | 63 |

Order

64 | 65 |
66 | ); 67 | }; 68 | 69 | export default OrdersPage; 70 | -------------------------------------------------------------------------------- /pages/admin/product-new.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React from 'react'; 4 | import Layout from '../../components/Layout'; 5 | import NavAdmin from '../../components/NavAdmin'; 6 | import Product from '../../components/ProductAdmin'; 7 | import Spinner from '../../components/Spinner'; 8 | 9 | const ProductNew: NextPage = () => { 10 | // Check for user session 11 | const { status } = useSession({ 12 | required: true, 13 | onUnauthenticated() { 14 | window.location.href = '/api/auth/signin'; 15 | }, 16 | }); 17 | 18 | if (status === 'loading') { 19 | return ; 20 | } 21 | 22 | return ( 23 | 24 | 25 |

Product

26 | 27 |
28 | ); 29 | }; 30 | 31 | export default ProductNew; 32 | -------------------------------------------------------------------------------- /pages/admin/product/[id].tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import Layout from '../../../components/Layout'; 6 | import NavAdmin from '../../../components/NavAdmin'; 7 | import Product from '../../../components/ProductAdmin'; 8 | import Spinner from '../../../components/Spinner'; 9 | 10 | const ProductPage: NextPage = () => { 11 | const router = useRouter(); 12 | const [product, setProduct] = useState(false); 13 | useEffect(() => { 14 | if (!router.isReady) { 15 | return; 16 | } 17 | const productId = router.query.id; 18 | getProduct(productId); 19 | }, [router.isReady]); 20 | 21 | function getProduct(productId) { 22 | fetch('/api/product/admin', { 23 | method: 'POST', 24 | cache: 'no-cache', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | }, 28 | body: JSON.stringify({ 29 | id: productId, 30 | }), 31 | }) 32 | .then(function (response) { 33 | return response.json(); 34 | }) 35 | .then(function (data) { 36 | setProduct(data); 37 | }) 38 | .catch(function (err) { 39 | console.log('Payload error:' + err); 40 | }); 41 | } 42 | 43 | // Check for user session 44 | const { status } = useSession({ 45 | required: true, 46 | onUnauthenticated() { 47 | window.location.href = '/api/auth/signin'; 48 | }, 49 | }); 50 | 51 | if (status === 'loading') { 52 | return ; 53 | } 54 | 55 | // Check for product 56 | if (!product) { 57 | return <>; 58 | } 59 | 60 | return ( 61 | 62 | 63 |

Product

64 | 65 |
66 | ); 67 | }; 68 | 69 | export default ProductPage; 70 | -------------------------------------------------------------------------------- /pages/admin/user/[id].tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useSession } from 'next-auth/react'; 3 | import React, { useEffect } from 'react'; 4 | import { useRouter } from 'next/router'; 5 | import Layout from '../../../components/Layout'; 6 | import NavAdmin from '../../../components/NavAdmin'; 7 | import User from '../../../components/UserForm'; 8 | import Spinner from '../../../components/Spinner'; 9 | 10 | const UserPage: NextPage = () => { 11 | const router = useRouter(); 12 | useEffect(() => { 13 | if (!router.isReady) { 14 | return; 15 | } 16 | }, [router.isReady]); 17 | 18 | // Check for user session 19 | const { status, data: session } = useSession({ 20 | required: true, 21 | onUnauthenticated() { 22 | window.location.href = '/api/auth/signin'; 23 | }, 24 | }); 25 | 26 | if (status === 'loading') { 27 | return ; 28 | } 29 | 30 | return ( 31 | 32 | 33 |

User

34 | 35 |
36 | ); 37 | }; 38 | 39 | export default UserPage; 40 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from 'next-auth'; 2 | import GithubProvider from 'next-auth/providers/github'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import prisma from '../../../lib/prisma'; 5 | import { createUser, getUserByEmail } from '../../../lib/user'; 6 | 7 | /* DASHBOARD API */ 8 | export const authOptions: NextAuthOptions = { 9 | providers: [ 10 | GithubProvider({ 11 | clientId: process.env.GITHUB_CLIENT_ID, 12 | clientSecret: process.env.GITHUB_SECRET, 13 | }), 14 | ], 15 | callbacks: { 16 | async jwt({ token, user, account }) { 17 | // Persist the OAuth access_token to the token right after signin 18 | if (account) { 19 | token.accessToken = account.access_token; 20 | } 21 | user && (token.user = user); 22 | return token; 23 | }, 24 | async session({ session, token }) { 25 | if (token.user) { 26 | session.user = await getUserByEmail(session.user.email); 27 | } 28 | return session; 29 | }, 30 | async signIn({ user }) { 31 | // Check how many current users 32 | const userCount = await prisma.users.count(); 33 | 34 | // If not users, create first user 35 | if (userCount === 0) { 36 | await createUser({ 37 | name: user.name, 38 | email: user.email, 39 | enabled: true, 40 | apiKey: uuidv4(), 41 | }); 42 | return true; 43 | } 44 | 45 | // Check user account 46 | const userAccount = await prisma.users.findFirst({ 47 | where: { 48 | email: user.email, 49 | enabled: true, 50 | }, 51 | }); 52 | 53 | if (!userAccount) { 54 | return false; 55 | } 56 | return true; 57 | }, 58 | }, 59 | }; 60 | 61 | export default NextAuth(authOptions); 62 | -------------------------------------------------------------------------------- /pages/api/customer.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getCustomer } from '../../lib/customers'; 3 | import { checkApiAuth } from '../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | try { 29 | const customerId = body.customerId; 30 | const customer = await getCustomer(customerId); 31 | 32 | res.status(200).json(customer); 33 | } catch (ex) { 34 | console.log('err', ex); 35 | res.status(400).json({ 36 | error: 'Failed to get customer', 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pages/api/customers.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getCustomers } from '../../lib/customers'; 3 | import { checkApiAuth } from '../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'GET') { 11 | res.status(405).send({ message: 'Only GET requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | // Get the customers 28 | try { 29 | const customers = await getCustomers(); 30 | res.status(200).json(customers); 31 | } catch (ex) { 32 | console.log('err', ex); 33 | res.status(400).json({ 34 | error: 'Failed to get customers', 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pages/api/customers/create.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createCustomer } from '../../../lib/customers'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | try { 29 | const customer = await createCustomer(body); 30 | res.status(200).json(customer); 31 | } catch (ex) { 32 | console.log('err', ex); 33 | res.status(400).json({ 34 | error: 'Failed to create customer', 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pages/api/customers/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { updateCustomer } from '../../../lib/customers'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | const customerId = body.id; 29 | try { 30 | const payload = body; 31 | delete payload.id; 32 | const customer = await updateCustomer(customerId, payload); 33 | res.status(200).json(customer); 34 | } catch (ex) { 35 | console.log('err', ex); 36 | res.status(400).json({ 37 | error: 'Failed to save customer', 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/api/customers/search.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import prisma from '../../../lib/prisma'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | try { 29 | // Search the DB 30 | const searchTerm = body.searchTerm; 31 | const searchParameter = body.searchParameter; 32 | 33 | // Setup the filter 34 | let filter = {}; 35 | if (searchParameter === 'id') { 36 | filter = { 37 | id: searchTerm, 38 | }; 39 | } 40 | if (searchParameter === 'customerEmail') { 41 | filter = { 42 | email: searchTerm, 43 | }; 44 | } 45 | if (searchParameter === 'customerLastName') { 46 | filter = { 47 | lastName: searchTerm, 48 | }; 49 | } 50 | 51 | const data = await prisma.customers.findMany({ 52 | where: filter, 53 | }); 54 | // Setup the results 55 | let results = []; 56 | if (data.length > 0) { 57 | results = data; 58 | } 59 | 60 | res.status(200).json(results); 61 | } catch (ex) { 62 | console.log('err', ex); 63 | res.status(400).json({ 64 | error: 'Failed to search for customers', 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pages/api/dashboard/customers.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getServerSession } from 'next-auth/next'; 3 | import { authOptions } from '../auth/[...nextauth]'; 4 | import { format } from 'date-fns'; 5 | import prisma from '../../../lib/prisma'; 6 | 7 | /* DASHBOARD API */ 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(405).send({ message: 'Only GET requests allowed' }); 14 | return; 15 | } 16 | 17 | // Check session 18 | const session = await getServerSession(req, res, authOptions); 19 | if (!session) { 20 | res.status(404).send({ 21 | content: 22 | "This is protected content. You can't access this content because you are not signed in.", 23 | }); 24 | return; 25 | } 26 | 27 | // Get the customers 28 | try { 29 | // Setup last 7 days dates 30 | const data = {}; 31 | const labels = []; 32 | for (let i = 7; i >= 0; i--) { 33 | const currDate = new Date(); 34 | currDate.setDate(currDate.getDate() - i); 35 | labels.push(format(currDate, 'dd/MM')); 36 | data[format(currDate, 'dd/MM')] = 0; 37 | } 38 | 39 | // Get last 7 days of data from DB 40 | const d = new Date(); 41 | d.setDate(d.getDate() - 7); 42 | const results = await prisma.customers.groupBy({ 43 | by: ['created_at'], 44 | _count: true, 45 | where: { 46 | created_at: { 47 | gte: d, 48 | }, 49 | }, 50 | }); 51 | 52 | // Collate our results 53 | results.forEach(row => { 54 | const resultDate = format(new Date(row.created_at), 'dd/MM'); 55 | if (resultDate in data) { 56 | const currentCount = data[resultDate]; 57 | data[resultDate] = currentCount + row._count; 58 | } 59 | }); 60 | 61 | // Return results 62 | res.status(200).json({ 63 | labels, 64 | results: data, 65 | }); 66 | } catch (ex) { 67 | console.log('err', ex); 68 | res.status(400).json({ 69 | error: 'Failed to get customers', 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pages/api/dashboard/discounts.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getAdminDiscounts } from '../../../lib/discounts'; 3 | import { getServerSession } from 'next-auth/next'; 4 | import { authOptions } from '../../api/auth/[...nextauth]'; 5 | 6 | /* DASHBOARD API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'GET') { 12 | res.status(405).send({ message: 'Only GET requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check session 17 | const session = await getServerSession(req, res, authOptions); 18 | if (!session) { 19 | res.status(404).send({ 20 | content: 21 | 'This is protected content. You cant access this content because you are not signed in.', 22 | }); 23 | return; 24 | } 25 | 26 | try { 27 | const discounts = await getAdminDiscounts(); 28 | 29 | res.status(200).json(discounts); 30 | } catch (ex) { 31 | console.log('err', ex); 32 | res.status(400).json({ 33 | error: 'Failed to get discounts', 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pages/api/dashboard/files/remove/[id].tsx: -------------------------------------------------------------------------------- 1 | import { remove } from '../../../../../lib/images'; 2 | import { getAdminProduct } from '../../../../../lib/products'; 3 | import prisma from '../../../../../lib/prisma'; 4 | import { authOptions } from '../../../auth/[...nextauth]'; 5 | import { getServerSession } from 'next-auth/next'; 6 | 7 | export const config = { 8 | api: { 9 | bodyParser: false, 10 | }, 11 | }; 12 | 13 | /* DASHBOARD API */ 14 | export default async function handler(req, res) { 15 | if (req.method !== 'DELETE') { 16 | res.status(405).send({ message: 'Only DELETE requests allowed' }); 17 | return; 18 | } 19 | 20 | // Check session 21 | const session = await getServerSession(req, res, authOptions); 22 | if (!session) { 23 | res.status(404).send({ 24 | content: 25 | "This is protected content. You can't access this content because you are not signed in.", 26 | }); 27 | return; 28 | } 29 | 30 | try { 31 | const image = await prisma.images.findFirst({ 32 | where: { 33 | id: req.query.id, 34 | }, 35 | }); 36 | if (!image) { 37 | return res.status(400).send({ message: 'Cannot locate file' }); 38 | } 39 | 40 | // Remove image 41 | await remove(image.filename); 42 | 43 | // Remove image from DB 44 | await prisma.images.delete({ 45 | where: { 46 | id: image.id, 47 | }, 48 | }); 49 | 50 | // Get our updated product 51 | const product = await getAdminProduct(image.productId); 52 | 53 | // Return response 54 | return res.json(product); 55 | } catch (ex) { 56 | console.log('Error uploading file', ex); 57 | return res.status(400).json({ 58 | error: 'Unable to upload file. Please check your config and try again.', 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pages/api/dashboard/files/sort/index.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../../../../../lib/prisma'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | import { authOptions } from '../../../auth/[...nextauth]'; 4 | import { getServerSession } from 'next-auth/next'; 5 | 6 | /* DASHBOARD API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'PUT') { 12 | res.status(405).send({ message: 'Only PUT requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check session 17 | const session = await getServerSession(req, res, authOptions); 18 | if (!session) { 19 | res.status(404).send({ 20 | content: 21 | "This is protected content. You can't access this content because you are not signed in.", 22 | }); 23 | return; 24 | } 25 | 26 | const images = req.body.images; 27 | 28 | // Update the order 29 | for (let i = 0; i < images.length; i++) { 30 | const newOrder = i + 1; 31 | await prisma.images.update({ 32 | where: { 33 | id: images[i].id, 34 | }, 35 | data: { 36 | order: newOrder, 37 | }, 38 | }); 39 | } 40 | 41 | // Return response 42 | res.json('success'); 43 | } 44 | -------------------------------------------------------------------------------- /pages/api/dashboard/files/upload.ts: -------------------------------------------------------------------------------- 1 | import { formidable } from 'formidable'; 2 | import { getAdminProduct } from '../../../../lib/products'; 3 | import { upload } from '../../../../lib/images'; 4 | import prisma from '../../../../lib/prisma'; 5 | import { authOptions } from '../../auth/[...nextauth]'; 6 | import { getServerSession } from 'next-auth/next'; 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | }; 13 | 14 | /* DASHBOARD API */ 15 | export default async function handler(req, res) { 16 | if (req.method !== 'POST') { 17 | res.status(405).send({ message: 'Only POST requests allowed' }); 18 | return; 19 | } 20 | 21 | // Check session 22 | const session = await getServerSession(req, res, authOptions); 23 | if (!session) { 24 | res.status(404).send({ 25 | content: 26 | "This is protected content. You can't access this content because you are not signed in.", 27 | }); 28 | return; 29 | } 30 | 31 | // Parse the file input form 32 | try { 33 | const form = formidable({ multiples: false }); 34 | const formfields = await new Promise(function (resolve, reject) { 35 | form.parse(req, function (err, fields, files) { 36 | if (err) { 37 | reject(err); 38 | return; 39 | } 40 | resolve({ 41 | image: files.image[0], 42 | productId: fields.productId[0], 43 | }); 44 | }); 45 | }); 46 | 47 | const response = await upload(formfields.image); 48 | // Check for error 49 | if (response.error === true) { 50 | console.log('Error uploading file', response.message); 51 | return res.status(400).json({ 52 | error: 'Unable to upload file. Please check your config and try again.', 53 | }); 54 | } 55 | 56 | // Add image to DB 57 | await prisma.images.create({ 58 | data: { 59 | url: response.url, 60 | alt: formfields.image.originalFilename, 61 | filename: response.filename, 62 | order: 6, 63 | productId: formfields.productId, 64 | }, 65 | }); 66 | 67 | // Get our updated product 68 | const product = await getAdminProduct(formfields.productId); 69 | 70 | // Return response 71 | return res.json(product); 72 | } catch (ex) { 73 | console.log('Error uploading file', ex); 74 | return res.status(400).json({ 75 | error: 'Unable to upload file. Please check your config and try again.', 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /pages/api/dashboard/orders.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getServerSession } from 'next-auth/next'; 3 | import { authOptions } from '../../api/auth/[...nextauth]'; 4 | import { format } from 'date-fns'; 5 | import prisma from '../../../lib/prisma'; 6 | 7 | /* DASHBOARD API */ 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | if (req.method !== 'GET') { 13 | res.status(405).send({ message: 'Only GET requests allowed' }); 14 | return; 15 | } 16 | 17 | // Check session 18 | const session = await getServerSession(req, res, authOptions); 19 | if (!session) { 20 | res.status(404).send({ 21 | content: 22 | 'This is protected content. You cant access this content because you are not signed in.', 23 | }); 24 | return; 25 | } 26 | 27 | try { 28 | // Setup last 7 days dates 29 | const data = {}; 30 | const labels = []; 31 | for (let i = 7; i >= 0; i--) { 32 | const currDate = new Date(); 33 | currDate.setDate(currDate.getDate() - i); 34 | labels.push(format(currDate, 'dd/MM')); 35 | data[format(currDate, 'dd/MM')] = 0; 36 | } 37 | 38 | // Get last 7 days of data from DB 39 | const d = new Date(); 40 | d.setDate(d.getDate() - 7); 41 | const results = await prisma.orders.groupBy({ 42 | by: ['created_at'], 43 | _count: true, 44 | _sum: { 45 | totalAmount: true, 46 | }, 47 | where: { 48 | created_at: { 49 | gte: d, 50 | }, 51 | }, 52 | }); 53 | 54 | // Collate our results 55 | results.forEach(row => { 56 | const resultDate = format(new Date(row.created_at), 'dd/MM'); 57 | if (resultDate in data) { 58 | const currentCount = data[resultDate]; 59 | data[resultDate] = currentCount + row._count; 60 | } 61 | }); 62 | 63 | // Return results 64 | res.status(200).json({ 65 | labels, 66 | results: data, 67 | }); 68 | } catch (ex) { 69 | console.log('err', ex); 70 | res.status(400).json({ 71 | error: 'Failed to get orders', 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pages/api/dashboard/products.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getAdminProducts } from '../../../lib/products'; 3 | import { getServerSession } from 'next-auth/next'; 4 | import { authOptions } from '../../api/auth/[...nextauth]'; 5 | 6 | /* DASHBOARD API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'GET') { 12 | res.status(405).send({ message: 'Only GET requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check session 17 | const session = await getServerSession(req, res, authOptions); 18 | if (!session) { 19 | res.status(404).send({ 20 | content: 21 | "This is protected content. You can't access this content because you are not signed in.", 22 | }); 23 | return; 24 | } 25 | 26 | try { 27 | const products = await getAdminProducts(); 28 | 29 | res.status(200).json(products); 30 | } catch (ex) { 31 | console.log('err', ex); 32 | res.status(400).json({ 33 | error: 'Failed to get products', 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pages/api/dashboard/products/search.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getServerSession } from 'next-auth/next'; 3 | import { authOptions } from '../../auth/[...nextauth]'; 4 | import prisma from '../../../../lib/prisma'; 5 | 6 | /* DASHBOARD API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'POST') { 12 | res.status(405).send({ message: 'Only POST requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check session 17 | const session = await getServerSession(req, res, authOptions); 18 | if (!session) { 19 | res.status(404).send({ 20 | content: 21 | "This is protected content. You can't access this content because you are not signed in.", 22 | }); 23 | return; 24 | } 25 | 26 | const body = req.body; 27 | try { 28 | // Search the DB 29 | const searchTerm = body.searchTerm; 30 | const searchParameter = body.searchParameter; 31 | 32 | // Setup the filter 33 | let filter = {}; 34 | if (searchParameter === 'id') { 35 | filter = { 36 | id: searchTerm, 37 | }; 38 | } 39 | if (searchParameter === 'name') { 40 | filter = { 41 | name: { 42 | contains: searchTerm, 43 | }, 44 | }; 45 | } 46 | 47 | const data = await prisma.products.findMany({ 48 | where: filter, 49 | include: { 50 | images: { 51 | orderBy: { 52 | order: 'asc', 53 | }, 54 | }, 55 | }, 56 | }); 57 | // Setup the results 58 | let results = []; 59 | if (data.length > 0) { 60 | results = data; 61 | } 62 | 63 | res.status(200).json(results); 64 | } catch (ex) { 65 | console.log('err', ex); 66 | res.status(400).json({ 67 | error: 'Failed to search for product', 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pages/api/discount/checkcode.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import prisma from '../../../lib/prisma'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'POST') { 10 | res.status(405).send({ message: 'Only POST requests allowed' }); 11 | return; 12 | } 13 | 14 | const body = req.body; 15 | try { 16 | // Check code 17 | const discountCode = body.discountCode; 18 | if (!discountCode || discountCode === '') { 19 | return res.status(400).json({ 20 | error: 'Discount not found', 21 | }); 22 | } 23 | 24 | const discount = await prisma.discounts.findFirst({ 25 | where: { 26 | code: discountCode, 27 | enabled: true, 28 | AND: [ 29 | { 30 | start_at: { 31 | lte: new Date(), 32 | }, 33 | }, 34 | { 35 | end_at: { 36 | gte: new Date(), 37 | }, 38 | }, 39 | ], 40 | }, 41 | }); 42 | res.status(200).json(discount); 43 | } catch (ex) { 44 | console.log('err', ex); 45 | res.status(400).json({ 46 | error: 'Discount not found', 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pages/api/discount/create.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createDiscount, getDiscount } from '../../../lib/discounts'; 3 | import { validateSchema } from '../../../lib/helpers'; 4 | import { checkApiAuth } from '../../../lib/user'; 5 | 6 | /* AUTHENTICATED API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'POST') { 12 | res.status(405).send({ message: 'Only POST requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check API 17 | const authCheck = await checkApiAuth( 18 | req.headers['x-user-id'], 19 | req.headers['x-api-key'], 20 | ); 21 | if (authCheck.error === true) { 22 | res.status(404).send({ 23 | error: authCheck.message, 24 | }); 25 | return; 26 | } 27 | 28 | const body = req.body; 29 | 30 | // Validate discount 31 | const schemaCheck = validateSchema('newDiscount', body); 32 | if (schemaCheck.valid === false) { 33 | return res.status(400).json({ 34 | error: 'Please check inputs', 35 | detail: schemaCheck.errors, 36 | }); 37 | } 38 | 39 | // Duplicate check 40 | const duplicateCheck = await getDiscount(body.code); 41 | if (duplicateCheck !== null) { 42 | return res.status(400).json({ 43 | error: 'Code already in use', 44 | }); 45 | } 46 | 47 | try { 48 | await createDiscount(body); 49 | res.status(200).json(body); 50 | } catch (ex) { 51 | console.log('err', ex); 52 | res.status(400).json({ 53 | error: 'Failed to create discount', 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pages/api/discount/delete.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { deleteDiscount } from '../../../lib/discounts'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only DELETE requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | 29 | try { 30 | await deleteDiscount(body.id); 31 | res.status(200).json({}); 32 | } catch (ex) { 33 | console.log('err', ex); 34 | res.status(400).json({ 35 | error: 'Failed to delete', 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/api/discount/get.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getAdminDiscount } from '../../../lib/discounts'; 3 | import { getServerSession } from 'next-auth/next'; 4 | import { authOptions } from '../../api/auth/[...nextauth]'; 5 | 6 | /* DASHBOARD API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'POST') { 12 | res.status(405).send({ message: 'Only POST requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check session 17 | const session = await getServerSession(req, res, authOptions); 18 | if (!session) { 19 | res.status(404).send({ 20 | content: 21 | "This is protected content. You can't access this content because you are not signed in.", 22 | }); 23 | return; 24 | } 25 | 26 | const body = req.body; 27 | try { 28 | const discountId = body.id; 29 | const discount = await getAdminDiscount(discountId); 30 | if (!discount) { 31 | return res.status(200).json({}); 32 | } 33 | 34 | res.status(200).json(discount); 35 | } catch (ex) { 36 | console.log('err', ex); 37 | res.status(400).json({ 38 | error: 'Failed to get discount', 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pages/api/discount/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { updateDiscount } from '../../../lib/discounts'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | const discountId = body.id; 29 | try { 30 | const payload = Object.assign({}, body); 31 | delete payload.id; 32 | await updateDiscount(discountId, payload); 33 | res.status(200).json(body); 34 | } catch (ex) { 35 | console.log('err', ex); 36 | res.status(400).json({ 37 | error: 'Failed to save discount', 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/api/order.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getOrder } from '../../lib/orders'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'POST') { 10 | res.status(405).send({ message: 'Only POST requests allowed' }); 11 | return; 12 | } 13 | const body = req.body; 14 | try { 15 | const orderId = body.orderId; 16 | const order = await getOrder(orderId); 17 | 18 | res.status(200).json(order); 19 | } catch (ex) { 20 | console.log('err', ex); 21 | res.status(400).json({ 22 | error: 'Failed to get order', 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pages/api/orders.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getOrders } from '../../lib/orders'; 3 | import { checkApiAuth } from '../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'GET') { 11 | res.status(405).send({ message: 'Only GET requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | // Get the orders 28 | try { 29 | const orders = await getOrders(); 30 | res.status(200).json(orders); 31 | } catch (ex) { 32 | console.log('err', ex); 33 | res.status(400).json({ 34 | error: 'Failed to get orders', 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pages/api/orders/search.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import prisma from '../../../lib/prisma'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* DASHBOARD API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | try { 29 | // Search the DB 30 | const searchTerm = body.searchTerm; 31 | const searchParameter = body.searchParameter; 32 | 33 | // Setup the filter 34 | let filter = {}; 35 | if (searchParameter === 'id') { 36 | filter = { 37 | id: searchTerm, 38 | }; 39 | } 40 | if (searchParameter === 'customerEmail') { 41 | filter = { 42 | customer: { 43 | email: searchTerm, 44 | }, 45 | }; 46 | } 47 | if (searchParameter === 'customerLastName') { 48 | filter = { 49 | customer: { 50 | lastName: searchTerm, 51 | }, 52 | }; 53 | } 54 | if (searchParameter === 'result') { 55 | filter = { 56 | result: searchTerm, 57 | }; 58 | } 59 | 60 | const data = await prisma.orders.findMany({ 61 | where: filter, 62 | include: { 63 | customer: true, 64 | }, 65 | }); 66 | // Setup the results 67 | let results = []; 68 | if (data.length > 0) { 69 | results = data; 70 | } 71 | 72 | res.status(200).json(results); 73 | } catch (ex) { 74 | console.log('err', ex); 75 | res.status(400).json({ 76 | error: 'Failed to search for customer', 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pages/api/product/admin.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getAdminProduct } from '../../../lib/products'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'POST') { 10 | res.status(405).send({ message: 'Only POST requests allowed' }); 11 | return; 12 | } 13 | const body = req.body; 14 | try { 15 | const productId = body.id; 16 | const product = await getAdminProduct(productId); 17 | if (!product) { 18 | return res.status(200).json({}); 19 | } 20 | 21 | res.status(200).json(product); 22 | } catch (ex) { 23 | console.log('err', ex); 24 | res.status(400).json({ 25 | error: 'Failed to get product', 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pages/api/product/create.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createProduct, getProduct } from '../../../lib/products'; 3 | import { validateSchema } from '../../../lib/helpers'; 4 | import { checkApiAuth } from '../../../lib/user'; 5 | 6 | /* AUTHENTICATED API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'POST') { 12 | res.status(405).send({ message: 'Only POST requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check API 17 | const authCheck = await checkApiAuth( 18 | req.headers['x-user-id'], 19 | req.headers['x-api-key'], 20 | ); 21 | if (authCheck.error === true) { 22 | res.status(404).send({ 23 | error: authCheck.message, 24 | }); 25 | return; 26 | } 27 | 28 | const body = req.body; 29 | 30 | // Validate product 31 | const schemaCheck = validateSchema('newProduct', body); 32 | if (schemaCheck.valid === false) { 33 | return res.status(400).json({ 34 | error: 'Please check inputs', 35 | detail: schemaCheck.errors, 36 | }); 37 | } 38 | 39 | // Duplicate check 40 | const duplicateCheck = await getProduct(body.permalink); 41 | if (duplicateCheck !== null) { 42 | return res.status(400).json({ 43 | error: 'Product permalink already in use', 44 | }); 45 | } 46 | 47 | try { 48 | await createProduct(body); 49 | res.status(200).json(body); 50 | } catch (ex) { 51 | console.log('err', ex); 52 | res.status(400).json({ 53 | error: 'Failed to create product', 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pages/api/product/delete.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { deleteProduct } from '../../../lib/products'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only DELETE requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | 29 | try { 30 | await deleteProduct(body.id); 31 | res.status(200).json({}); 32 | } catch (ex) { 33 | console.log('err', ex); 34 | res.status(400).json({ 35 | error: 'Failed to delete', 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/api/product/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getProduct } from '../../../lib/products'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'POST') { 10 | res.status(405).send({ message: 'Only POST requests allowed' }); 11 | return; 12 | } 13 | const body = req.body; 14 | try { 15 | const permalink = body.permalink; 16 | const product = await getProduct(permalink); 17 | 18 | if (!product) { 19 | return res.status(200).json({}); 20 | } 21 | 22 | res.status(200).json(product); 23 | } catch (ex) { 24 | console.log('err', ex); 25 | res.status(400).json({ 26 | error: 'Failed to get product', 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pages/api/product/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { updateProduct } from '../../../lib/products'; 3 | import { removeCurrency } from '../../../lib/helpers'; 4 | import { validateSchema } from '../../../lib/helpers'; 5 | import { checkApiAuth } from '../../../lib/user'; 6 | 7 | /* AUTHENTICATED API */ 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | if (req.method !== 'POST') { 13 | res.status(405).send({ message: 'Only POST requests allowed' }); 14 | return; 15 | } 16 | 17 | // Check API 18 | const authCheck = await checkApiAuth( 19 | req.headers['x-user-id'], 20 | req.headers['x-api-key'], 21 | ); 22 | if (authCheck.error === true) { 23 | res.status(404).send({ 24 | error: authCheck.message, 25 | }); 26 | return; 27 | } 28 | 29 | const body = req.body; 30 | // Fix values 31 | body.price = removeCurrency(body.price); 32 | delete body.images; 33 | 34 | // Validate product 35 | const schemaCheck = validateSchema('saveProduct', body); 36 | if (schemaCheck.valid === false) { 37 | return res.status(400).json({ 38 | error: 'Please check inputs', 39 | detail: schemaCheck.errors, 40 | }); 41 | } 42 | 43 | const productId = body.id; 44 | try { 45 | const payload = Object.assign({}, body); 46 | delete payload.id; 47 | await updateProduct(productId, payload); 48 | res.status(200).json(body); 49 | } catch (ex) { 50 | console.log('err', ex); 51 | res.status(400).json({ 52 | error: 'Cannot save product', 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/api/products.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getProducts } from '../../lib/products'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'GET') { 10 | res.status(405).send({ message: 'Only GET requests allowed' }); 11 | return; 12 | } 13 | try { 14 | const products = await getProducts(); 15 | 16 | res.status(200).json(products); 17 | } catch (ex) { 18 | console.log('err', ex); 19 | res.status(400).json({ 20 | error: 'Failed to get products', 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pages/api/products/admin.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getAdminProducts } from '../../../lib/products'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'GET') { 10 | res.status(405).send({ message: 'Only GET requests allowed' }); 11 | return; 12 | } 13 | try { 14 | const products = await getAdminProducts(); 15 | if (!products) { 16 | return res.status(200).json({}); 17 | } 18 | 19 | res.status(200).json(products); 20 | } catch (ex) { 21 | console.log('err', ex); 22 | res.status(400).json({ 23 | error: 'Failed to get products', 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pages/api/search.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import prisma from '../../lib/prisma'; 3 | 4 | /* PUBLIC API */ 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'POST') { 10 | res.status(405).send({ message: 'Only POST requests allowed' }); 11 | return; 12 | } 13 | const body = req.body; 14 | if (req.body.searchTerm.trim() === '') { 15 | res.status(400).json({ 16 | error: 'Please enter a search term', 17 | }); 18 | } 19 | try { 20 | // Search the DB 21 | const searchTerm = body.searchTerm.replace(/[\s\n\t]/g, '_'); 22 | const data = await prisma.products.findMany({ 23 | where: { 24 | OR: [ 25 | { 26 | name: { 27 | search: searchTerm, 28 | }, 29 | }, 30 | { 31 | description: { 32 | search: searchTerm, 33 | }, 34 | }, 35 | ], 36 | }, 37 | include: { 38 | images: { 39 | orderBy: { 40 | order: 'asc', 41 | }, 42 | }, 43 | }, 44 | }); 45 | // Setup the results 46 | let results = []; 47 | if (data.length > 0) { 48 | results = data; 49 | } 50 | 51 | res.status(200).json(results); 52 | } catch (ex) { 53 | console.log('err', ex); 54 | res.status(400).json({ 55 | error: 'Failed to search for product', 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pages/api/square/checkout-hosted-return.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Client, Environment } from 'square'; 3 | import { updateOrder } from '../../../lib/orders'; 4 | 5 | // Set the Square env 6 | let SquareEnv = Environment.Sandbox; 7 | if (process.env.NODE_ENV === 'production') { 8 | SquareEnv = Environment.Production; 9 | } 10 | 11 | // Setup Square client 12 | const client = new Client({ 13 | accessToken: process.env.SQUARE_ACCESS_TOKEN, 14 | environment: SquareEnv, 15 | }); 16 | 17 | export default async function handler( 18 | req: NextApiRequest, 19 | res: NextApiResponse, 20 | ) { 21 | if (req.method !== 'POST') { 22 | res.status(405).send({ message: 'Only GET requests allowed' }); 23 | return; 24 | } 25 | 26 | // Extract the data 27 | const squareOrderId = req.body.orderId; 28 | const squareOrder = await client.ordersApi.retrieveOrder(squareOrderId); 29 | const orderId = squareOrder.result.order.referenceId; 30 | const squarePaymentId = squareOrder.tenders[0].payment_id; 31 | const squarePayment = await client.paymentsApi.getPayment(squarePaymentId); 32 | 33 | // Create paid indicator 34 | const approvedStatus = ['COMPLETED', 'COMPLETED']; 35 | let paidResult = false; 36 | if (approvedStatus.includes(squarePayment.result.payment.status)) { 37 | paidResult = true; 38 | } 39 | 40 | // Update our order with the transaction response 41 | const order = await updateOrder(orderId, { 42 | transaction_id: squarePayment.result.payment.id, 43 | status: squarePayment.result.payment.status, 44 | paid: paidResult, 45 | }); 46 | 47 | const txnResponse = { 48 | id: squareOrderId, 49 | orderId: order.id, 50 | transactionId: squarePaymentId, 51 | status: squarePayment.result.payment.status, 52 | }; 53 | 54 | res.status(200).json(txnResponse); 55 | } 56 | -------------------------------------------------------------------------------- /pages/api/square/create-checkout.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Client, Environment } from 'square'; 3 | import { createOrder, updateOrder } from '../../../lib/orders'; 4 | import { createCustomer } from '../../../lib/customers'; 5 | 6 | // Set the Square env 7 | let SquareEnv = Environment.Sandbox; 8 | if (process.env.NODE_ENV === 'production') { 9 | SquareEnv = Environment.Production; 10 | } 11 | 12 | // Setup Square client 13 | const client = new Client({ 14 | accessToken: process.env.SQUARE_ACCESS_TOKEN, 15 | environment: SquareEnv, 16 | }); 17 | 18 | export default async function handler( 19 | req: NextApiRequest, 20 | res: NextApiResponse, 21 | ) { 22 | if (req.method !== 'POST') { 23 | res.status(405).send({ message: 'Only POST requests allowed' }); 24 | return; 25 | } 26 | const body = req.body; 27 | 28 | // Setup default payload 29 | const checkoutPayload = { 30 | checkout_options: { 31 | redirect_url: `${process.env.BASE_URL}/checkout-result`, 32 | }, 33 | order: { 34 | locationId: process.env.SQUARE_LOCATION_ID, 35 | lineItems: [], 36 | }, 37 | }; 38 | 39 | try { 40 | // Build line items 41 | const line_items = []; 42 | for (const item of body.cart) { 43 | line_items.push({ 44 | name: item.name, 45 | quantity: item.quantity.toString(), 46 | basePriceMoney: { 47 | amount: item.price, 48 | currency: process.env.NEXT_PUBLIC_PAYMENT_CURRENCY, 49 | }, 50 | }); 51 | } 52 | checkoutPayload.order.lineItems = line_items; 53 | 54 | // Create customer 55 | const customer = await createCustomer(body.customer); 56 | 57 | // Create the order 58 | const order = await createOrder({ 59 | status: 'unpaid', 60 | cart: body.cart, 61 | totalAmount: body.totalAmount, 62 | totalUniqueItems: body.totalUniqueItems, 63 | gateway: 'square', 64 | customer: customer.id, 65 | }); 66 | 67 | checkoutPayload.order.referenceId = order.id; 68 | checkoutPayload.order.metadata = { 69 | orderId: order.id, 70 | }; 71 | checkoutPayload.idempotencyKey = order.id; 72 | checkoutPayload.prePopulatedData = { 73 | buyerEmail: customer.email, 74 | }; 75 | 76 | // Call the API to create checkout 77 | const checkoutResponse = await client.checkoutApi.createPaymentLink( 78 | checkoutPayload, 79 | ); 80 | 81 | // Update the order with the checkout reference 82 | await updateOrder(order.id, { 83 | checkout_id: checkoutResponse.result.paymentLink.orderId, 84 | }); 85 | 86 | // Setup the response 87 | const response = { 88 | checkout: { 89 | url: checkoutResponse.result.paymentLink.url, 90 | }, 91 | }; 92 | 93 | res.status(200).json(response); 94 | } catch (ex) { 95 | console.log('err', ex); 96 | res.status(400).json({ 97 | error: 'Failed to create checkout', 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pages/api/square/webhook.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Client, Environment } from 'square'; 3 | import { updateOrder } from '../../../lib/orders'; 4 | 5 | const client = new Client({ 6 | accessToken: process.env.SQUARE_ACCESS_TOKEN, 7 | environment: Environment.Sandbox, 8 | }); 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse, 13 | ) { 14 | if (req.method !== 'POST') { 15 | res.status(405).send({ message: 'Only POST requests allowed' }); 16 | return; 17 | } 18 | 19 | // Check for Webhook type 20 | if (req.body.type !== 'payment.updated') { 21 | res.status(405).send({ message: 'Webhook type not supported' }); 22 | return; 23 | } 24 | 25 | const squarePaymentId = req.body.data.id; 26 | const squarePayment = await client.paymentsApi.getPayment(squarePaymentId); 27 | const squareOrderId = squarePayment.result.payment.orderId; 28 | const squareOrder = await client.ordersApi.retrieveOrder(squareOrderId); 29 | const orderId = squareOrder.result.order.referenceId; 30 | 31 | // Create paid indicator 32 | const approvedStatus = ['COMPLETED', 'COMPLETED']; 33 | let paidResult = false; 34 | if (approvedStatus.includes(squarePayment.result.payment.status)) { 35 | paidResult = true; 36 | } 37 | 38 | // Update our order with the transaction response 39 | const order = await updateOrder(orderId, { 40 | transaction_id: squarePayment.result.payment.id, 41 | status: squarePayment.result.payment.status, 42 | paid: paidResult, 43 | }); 44 | 45 | console.log( 46 | `Square webhook received - Status: ${squarePayment.result.payment.status}, Paid: ${paidResult}`, 47 | ); 48 | 49 | // Update the order with the status of the hook 50 | await updateOrder(order.id, { 51 | status: squarePayment.result.payment.status, 52 | paid: paidResult, 53 | }); 54 | 55 | res.status(200).json({}); 56 | } 57 | -------------------------------------------------------------------------------- /pages/api/stripe/checkout-hosted-return.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import Stripe from 'stripe'; 3 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { 4 | apiVersion: '2022-11-15', 5 | }); 6 | import { updateOrder } from '../../../lib/orders'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | if (req.method !== 'POST') { 13 | res.status(405).send({ message: 'Only GET requests allowed' }); 14 | return; 15 | } 16 | const session = await stripe.checkout.sessions.retrieve( 17 | req.body.session_id, 18 | ); 19 | 20 | // Create paid indicator 21 | let paidResult = false; 22 | if (session.status === 'complete') { 23 | paidResult = true; 24 | } 25 | 26 | // Update our order with the transaction response 27 | const order = await updateOrder(session.client_reference_id, { 28 | transaction_id: session.id, 29 | status: session.status, 30 | paid: paidResult, 31 | }); 32 | 33 | const response = { 34 | id: session.id, 35 | orderId: order.id, 36 | transactionId: session.payment_intent, 37 | status: session.payment_status, 38 | }; 39 | 40 | res.status(200).json(response); 41 | return; 42 | } 43 | -------------------------------------------------------------------------------- /pages/api/stripe/create-checkout.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | import Stripe from 'stripe'; 4 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { 5 | apiVersion: '2022-11-15', 6 | }); 7 | import { createOrder, updateOrder } from '../../../lib/orders'; 8 | import { createCustomer } from '../../../lib/customers'; 9 | 10 | // Define Types 11 | type Customer = { 12 | id: string; 13 | email: string; 14 | }; 15 | 16 | type Order = { 17 | id: string; 18 | status: string; 19 | cart: object; 20 | totalAmount: number; 21 | totalUniqueItems: number; 22 | gateway: string; 23 | customer: string; 24 | }; 25 | 26 | export default async function handler( 27 | req: NextApiRequest, 28 | res: NextApiResponse, 29 | ) { 30 | if (req.method !== 'POST') { 31 | res.status(405).send({ message: 'Only POST requests allowed' }); 32 | return; 33 | } 34 | const body = req.body; 35 | try { 36 | // Build line items 37 | const line_items = []; 38 | const baseUrl = process.env.BASE_URL; 39 | const currency = process.env.NEXT_PUBLIC_PAYMENT_CURRENCY; 40 | for (const item of body.cart) { 41 | line_items.push({ 42 | price_data: { 43 | currency: currency, 44 | unit_amount: item.price, 45 | product_data: { 46 | name: item.name, 47 | }, 48 | }, 49 | quantity: item.quantity, 50 | }); 51 | } 52 | 53 | // Create customer 54 | const customer = (await createCustomer(body.customer)) as Customer; 55 | 56 | // Create the order 57 | const order = (await createOrder({ 58 | status: 'unpaid', 59 | cart: body.cart, 60 | totalAmount: body.totalAmount, 61 | totalUniqueItems: body.totalUniqueItems, 62 | gateway: 'stripe', 63 | customer: customer.id, 64 | })) as Order; 65 | 66 | // Setup overly complicated Stripe discounts 67 | const stripeDiscounts = []; 68 | if (body.meta.discount) { 69 | const discountPayload = { 70 | duration: 'once', 71 | } as any; 72 | if (body.meta.discount.type === 'percent') { 73 | discountPayload.percent_off = body.meta.discount.value; 74 | } 75 | if (body.meta.discount.type === 'amount') { 76 | discountPayload.amount_off = body.meta.discount.value; 77 | discountPayload.currency = currency; 78 | discountPayload.duration = 'once'; 79 | } 80 | // Create the once off discount 81 | // eslint-disable-next-line 82 | const discount = await stripe.coupons.create(discountPayload); 83 | stripeDiscounts.push({ 84 | coupon: discount.id, 85 | }); 86 | } 87 | 88 | // Setup Checkout request 89 | const session = await stripe.checkout.sessions.create({ 90 | line_items: line_items, 91 | mode: 'payment', 92 | customer_email: customer.email, 93 | client_reference_id: order.id, 94 | discounts: stripeDiscounts, 95 | success_url: `${baseUrl}/checkout-result?session_id={CHECKOUT_SESSION_ID}`, 96 | cancel_url: `${baseUrl}/checkout-result?session_id={CHECKOUT_SESSION_ID}`, 97 | }); 98 | 99 | // Update the order with the checkout reference 100 | await updateOrder(order.id, { 101 | checkout_id: session.id, 102 | }); 103 | 104 | // Setup the response 105 | const response = { 106 | checkout: { 107 | url: session.url, 108 | }, 109 | }; 110 | 111 | res.status(200).json(response); 112 | } catch (ex) { 113 | console.log('ex', ex); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /pages/api/stripe/webhook.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { getOrderByCheckout, updateOrder } from '../../../lib/orders'; 4 | 5 | // Define Types 6 | type stripeOrder = { 7 | id: string; 8 | payment_status: string; 9 | metadata: { 10 | orderId: string; 11 | }; 12 | amount_total: number; 13 | }; 14 | 15 | type Order = { 16 | id: string; 17 | totalAmount: number; 18 | }; 19 | 20 | const handler = async ( 21 | req: NextApiRequest, 22 | res: NextApiResponse, 23 | ): Promise => { 24 | const stripe = new Stripe(process.env.STRIPE_WEBHOOK_SECRET, { 25 | apiVersion: '2022-11-15', 26 | }); 27 | 28 | const webhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET; 29 | 30 | // Only accept POST 31 | if (req.method !== 'POST') { 32 | res.setHeader('Allow', 'POST'); 33 | res.status(405).end('Method Not Allowed'); 34 | return; 35 | } 36 | 37 | // Get the Stripe sig 38 | const sig = req.headers['stripe-signature']; 39 | 40 | let event: Stripe.Event; 41 | try { 42 | const body = await buffer(req); 43 | event = stripe.webhooks.constructEvent(body, sig, webhookSecret); 44 | } catch (err) { 45 | // On error, log and return the error message 46 | console.log(`❌ Error message: ${err.message}`); 47 | res.status(400).send(`Webhook Error: ${err.message}`); 48 | return; 49 | } 50 | 51 | // Check event type is what we are waiting for, else return 52 | if (event.type !== 'checkout.session.completed') { 53 | console.log('here?', event.type); 54 | res.json({ received: true }); 55 | return; 56 | } 57 | 58 | // Get Checkout ID 59 | const checkoutId = event.data.object.id; 60 | 61 | // Get order 62 | const order = (await getOrderByCheckout(checkoutId)) as Order; 63 | 64 | // Check order is found 65 | if (!order) { 66 | return res 67 | .status(400) 68 | .json({ error: `Order not found: ${checkoutId}` }); 69 | } 70 | 71 | const stripeOrder = event.data.object as stripeOrder; 72 | 73 | // Setup status from hook 74 | let status = ''; 75 | let paid; 76 | if (stripeOrder.payment_status === 'paid') { 77 | status = 'COMPLETED'; 78 | paid = true; 79 | } else { 80 | status = 'FAILED'; 81 | paid = false; 82 | } 83 | 84 | /* 85 | If the values of the checkout don't match the DB, we 86 | override the `status` and `paid` values with a validation 87 | error and update the DB 88 | */ 89 | 90 | // Validate the orderId to order.id 91 | if (stripeOrder.metadata.orderId !== order.id) { 92 | console.log('didnt validate orderId'); 93 | status = 'FAILED VALIDATION'; 94 | paid = false; 95 | } 96 | 97 | // Validate the amount to order.amount 98 | if (stripeOrder.amount_total !== order.totalAmount) { 99 | console.log('didnt validate amount'); 100 | status = 'FAILED VALIDATION'; 101 | paid = false; 102 | } 103 | 104 | console.log(`Checkout received - Status: ${status}, Paid: ${paid}`); 105 | 106 | // Update the order with the status of the hook 107 | await updateOrder(order.id, { 108 | status, 109 | paid, 110 | }); 111 | 112 | // Return a response to acknowledge receipt of the event 113 | res.json({ received: true }); 114 | }; 115 | 116 | export const config = { 117 | api: { 118 | bodyParser: false, 119 | }, 120 | }; 121 | 122 | const buffer = (req: NextApiRequest) => { 123 | return new Promise((resolve, reject) => { 124 | const chunks: Buffer[] = []; 125 | 126 | req.on('data', (chunk: Buffer) => { 127 | chunks.push(chunk); 128 | }); 129 | 130 | req.on('end', () => { 131 | resolve(Buffer.concat(chunks)); 132 | }); 133 | 134 | req.on('error', reject); 135 | }); 136 | }; 137 | 138 | export default handler; 139 | -------------------------------------------------------------------------------- /pages/api/user/update.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getServerSession } from 'next-auth/next'; 3 | import { authOptions } from '../auth/[...nextauth]'; 4 | import { updateUser } from '../../../lib/user'; 5 | 6 | /* DASHBOARD API */ 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ) { 11 | if (req.method !== 'POST') { 12 | res.status(405).send({ message: 'Only POST requests allowed' }); 13 | return; 14 | } 15 | 16 | // Check session 17 | const session = await getServerSession(req, res, authOptions); 18 | if (!session) { 19 | res.status(404).send({ 20 | content: 21 | "This is protected content. You can't access this content because you are not signed in.", 22 | }); 23 | return; 24 | } 25 | 26 | const body = req.body; 27 | try { 28 | const user = await updateUser(body.id, { 29 | apiKey: body.apiKey, 30 | }); 31 | res.status(200).json(user); 32 | } catch (ex) { 33 | console.log('err', ex); 34 | res.status(400).json({ 35 | error: 'Failed to update user', 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/api/variants/delete.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { deleteVariant } from '../../../lib/variants'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only DELETE requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | 29 | try { 30 | const response = await deleteVariant(body.id); 31 | if (response.error) { 32 | return res.status(400).json({ 33 | error: response.error, 34 | }); 35 | } 36 | return res.status(200).json({ 37 | message: 'Variant deleted', 38 | }); 39 | } catch (ex) { 40 | res.status(400).json({ 41 | error: 'Failed to delete', 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pages/api/variants/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createVariant } from '../../../lib/variants'; 3 | import { checkApiAuth } from '../../../lib/user'; 4 | 5 | /* AUTHENTICATED API */ 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | 15 | // Check API 16 | const authCheck = await checkApiAuth( 17 | req.headers['x-user-id'], 18 | req.headers['x-api-key'], 19 | ); 20 | if (authCheck.error === true) { 21 | res.status(404).send({ 22 | error: authCheck.message, 23 | }); 24 | return; 25 | } 26 | 27 | const body = req.body; 28 | try { 29 | await createVariant(body); 30 | res.status(200).json(body); 31 | } catch (ex) { 32 | console.log('err', ex); 33 | res.status(400).json({ 34 | error: 'Failed to create variant', 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pages/api/verifone/checkout-hosted-return.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { updateOrder } from '../../../lib/orders'; 3 | import axios from 'axios'; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | ) { 9 | if (req.method !== 'POST') { 10 | res.status(405).send({ message: 'Only GET requests allowed' }); 11 | return; 12 | } 13 | const checkoutId = req.body.checkout_id; 14 | 15 | // Validate the checkout 16 | const endpoint = `${process.env.VERIFONE_API_ENDPOINT}/oidc/checkout-service/v2/checkout/${checkoutId}`; 17 | const request = axios.create({ 18 | auth: { 19 | username: process.env.VERIFONE_USER_UID, 20 | password: process.env.VERIFONE_PUBLIC_KEY, 21 | }, 22 | }); 23 | const response = await request.get(endpoint); 24 | 25 | // Create paid indicator 26 | let paidResult = false; 27 | if (response.data.status === 'COMPLETED') { 28 | paidResult = true; 29 | } 30 | 31 | // Update our order with the transaction response 32 | const order = await updateOrder(response.data.merchant_reference, { 33 | transaction_id: response.data.transaction_id, 34 | status: response.data.status, 35 | paid: paidResult, 36 | }); 37 | 38 | if (order.id !== response.data.merchant_reference) { 39 | console.log('Checkout ID differs'); 40 | } 41 | 42 | const txnResponse = { 43 | id: response.data.id, 44 | orderId: order.id, 45 | transactionId: response.data.transaction_id, 46 | status: response.data.status, 47 | }; 48 | 49 | res.status(200).json(txnResponse); 50 | } 51 | -------------------------------------------------------------------------------- /pages/api/verifone/create-checkout.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import axios from 'axios'; 3 | import { createOrder, updateOrder } from '../../../lib/orders'; 4 | import { createCustomer } from '../../../lib/customers'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).send({ message: 'Only POST requests allowed' }); 12 | return; 13 | } 14 | const body = req.body; 15 | 16 | // Setup default payload 17 | const checkoutPayload = { 18 | amount: 0, 19 | currency_code: process.env.NEXT_PUBLIC_PAYMENT_CURRENCY, 20 | entity_id: process.env.VERIFONE_ENTITY_ID, 21 | configurations: { 22 | card: { 23 | payment_contract_id: process.env.VERIFONE_PAYMENT_CONTRACT, 24 | }, 25 | }, 26 | line_items: [], 27 | theme_id: process.env.VERIFONE_THEME_ID, 28 | interaction_type: 'HPP', 29 | shop_url: `${process.env.BASE_URL}/checkout`, 30 | return_url: `${process.env.BASE_URL}/checkout-result`, 31 | }; 32 | 33 | try { 34 | // Setup Checkout request 35 | checkoutPayload.amount = body.totalAmount; 36 | checkoutPayload.entity_id = process.env.VERIFONE_ENTITY_ID; 37 | checkoutPayload.theme_id = process.env.VERIFONE_THEME_ID; 38 | checkoutPayload.configurations.card.payment_contract_id = 39 | process.env.VERIFONE_PAYMENT_CONTRACT; 40 | 41 | // Build line items 42 | const line_items = []; 43 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 44 | for (const item of body.cart) { 45 | line_items.push({ 46 | name: item.name, 47 | quantity: item.quantity, 48 | unit_price: item.price, 49 | total_amount: item.itemTotal, 50 | image_url: `${baseUrl}${item.images[0].url}`, 51 | }); 52 | } 53 | checkoutPayload.line_items = line_items; 54 | 55 | // Create customer 56 | const customer = await createCustomer(body.customer); 57 | 58 | // Create the order 59 | const order = await createOrder({ 60 | status: 'unpaid', 61 | cart: body.cart, 62 | totalAmount: body.totalAmount, 63 | totalUniqueItems: body.totalUniqueItems, 64 | gateway: 'verifone', 65 | customer: customer.id, 66 | }); 67 | 68 | checkoutPayload.merchant_reference = order.id; 69 | 70 | // Call the API to create checkout 71 | const checkoutEndpoint = `${process.env.VERIFONE_API_ENDPOINT}/oidc/checkout-service/v2/checkout`; 72 | const checkoutRequest = axios.create({ 73 | auth: { 74 | username: process.env.VERIFONE_USER_UID, 75 | password: process.env.VERIFONE_PUBLIC_KEY, 76 | }, 77 | }); 78 | const checkoutResponse = await checkoutRequest.post( 79 | checkoutEndpoint, 80 | checkoutPayload, 81 | ); 82 | 83 | // Update the order with the checkout reference 84 | await updateOrder(order.id, { 85 | checkout_id: checkoutResponse.data.id, 86 | }); 87 | 88 | // Setup the response 89 | const response = { 90 | checkout: checkoutResponse.data, 91 | }; 92 | 93 | res.status(200).json(response); 94 | } catch (ex) { 95 | console.log('err', ex.response.data); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pages/api/verifone/webhook.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import axios from 'axios'; 3 | import { getOrderByCheckout, updateOrder } from '../../../lib/orders'; 4 | 5 | async function getCheckout(checkoutId) { 6 | // Call the API to get checkout 7 | const checkoutEndpoint = `${process.env.VERIFONE_API_ENDPOINT}/oidc/checkout-service/v2/checkout/${checkoutId}`; 8 | const checkoutRequest = axios.create({ 9 | auth: { 10 | username: process.env.VERIFONE_USER_UID, 11 | password: process.env.VERIFONE_PUBLIC_KEY, 12 | }, 13 | }); 14 | const checkoutResponse = await checkoutRequest.get(checkoutEndpoint); 15 | return checkoutResponse.data; 16 | } 17 | 18 | export default async function handler( 19 | req: NextApiRequest, 20 | res: NextApiResponse, 21 | ) { 22 | if (req.method !== 'POST') { 23 | res.status(405).send({ message: 'Only POST requests allowed' }); 24 | return; 25 | } 26 | 27 | // Get Checkout ID 28 | const checkoutId = req.body.itemId; 29 | 30 | // Get order 31 | const order = await getOrderByCheckout(checkoutId); 32 | 33 | // Check order is found 34 | if (!order) { 35 | return res 36 | .status(400) 37 | .json({ error: `Order not found: ${checkoutId}` }); 38 | } 39 | 40 | // Setup status from hook 41 | let status = ''; 42 | let paid; 43 | if (req.body.eventType === 'CheckoutTransactionSuccess') { 44 | status = 'COMPLETED'; 45 | paid = true; 46 | } else { 47 | status = 'FAILED'; 48 | paid = false; 49 | } 50 | 51 | // Get the checkout to validate 52 | const validateCheckout = await getCheckout(checkoutId); 53 | 54 | /* 55 | If the values of the checkout don't match the DB, we 56 | override the `status` and `paid` values with a validation 57 | error and update the DB 58 | */ 59 | 60 | // Validate the merchant_reference to order.id 61 | if (validateCheckout.merchant_reference !== order.id) { 62 | console.log('didnt validate merchant_reference'); 63 | status = 'FAILED VALIDATION'; 64 | paid = false; 65 | } 66 | 67 | // Validate the amount to order.amount 68 | if (validateCheckout.amount !== order.totalAmount) { 69 | console.log('didnt validate amount'); 70 | status = 'FAILED VALIDATION'; 71 | paid = false; 72 | } 73 | 74 | console.log(`Checkout received - Status: ${status}, Paid: ${paid}`); 75 | 76 | // Update the order with the status of the hook 77 | await updateOrder(order.id, { 78 | status, 79 | paid, 80 | }); 81 | 82 | res.status(200).json({}); 83 | } 84 | -------------------------------------------------------------------------------- /pages/checkout-result.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import Layout from '../components/Layout'; 3 | import Navbar from '../components/Nav'; 4 | import Spinner from '../components/Spinner'; 5 | import { useEffect } from 'react'; 6 | import { useRouter } from 'next/router'; 7 | 8 | const CartPage: NextPage = () => { 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | if (!router.isReady) { 13 | return; 14 | } 15 | 16 | checkResult(); 17 | }, [router.isReady]); 18 | 19 | function checkResult() { 20 | const payload = router.query; 21 | 22 | // fetch 23 | const paymentConfig = process.env.NEXT_PUBLIC_PAYMENT_CONFIG; 24 | fetch(`/api/${paymentConfig}/checkout-hosted-return`, { 25 | method: 'POST', 26 | cache: 'no-cache', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | }, 30 | body: JSON.stringify(payload), 31 | }) 32 | .then(function (response) { 33 | return response.json(); 34 | }) 35 | .then(function (data) { 36 | window.location.href = `/order/${data.orderId}`; 37 | }) 38 | .catch(function (err) { 39 | // There was an error 40 | console.log('Payload error:' + err); 41 | }); 42 | } 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default CartPage; 53 | -------------------------------------------------------------------------------- /pages/checkout.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { useEffect } from 'react'; 3 | import { useRouter } from 'next/router'; 4 | import Layout from '../components/Layout'; 5 | import Navbar from '../components/Nav'; 6 | import Checkout from '../components/Checkout'; 7 | import CheckoutSidebar from '../components/CheckoutSidebar'; 8 | 9 | const CartPage: NextPage = () => { 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | if (!router.isReady) { 14 | return; 15 | } 16 | }, [router.isReady]); 17 | 18 | return ( 19 | 20 | 21 |

Checkout

22 | 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default CartPage; 29 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import Layout from '../components/Layout'; 4 | import Products from '../components/Products'; 5 | import Navbar from '../components/Nav'; 6 | import CheckoutSidebar from '../components/CheckoutSidebar'; 7 | 8 | const IndexPage: NextPage = () => { 9 | return ( 10 | 11 | 12 |

Shop

13 | 14 | 15 |
16 | ); 17 | }; 18 | 19 | export default IndexPage; 20 | -------------------------------------------------------------------------------- /pages/order/[id].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import Layout from '../../components/Layout'; 4 | import Navbar from '../../components/Nav'; 5 | import CheckoutResult from '../../components/CheckoutResult'; 6 | import CheckoutSidebar from '../../components/CheckoutSidebar'; 7 | 8 | const ResultPage: NextPage = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default ResultPage; 19 | -------------------------------------------------------------------------------- /pages/product/[permalink].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import Layout from '../../components/Layout'; 4 | import Navbar from '../../components/Nav'; 5 | import Product from '../../components/Product'; 6 | import CheckoutSidebar from '../../components/CheckoutSidebar'; 7 | 8 | const ProductPage: NextPage = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default ProductPage; 19 | -------------------------------------------------------------------------------- /pages/search/[keyword].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import Layout from '../../components/Layout'; 4 | import Navbar from '../../components/Nav'; 5 | import SearchResult from '../../components/SearchResult'; 6 | import CheckoutSidebar from '../../components/CheckoutSidebar'; 7 | 8 | const SearchPage: NextPage = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default SearchPage; 19 | -------------------------------------------------------------------------------- /pages/styles.css: -------------------------------------------------------------------------------- 1 | .product-card { 2 | border: none; 3 | } 4 | 5 | .border-right { 6 | border-right: 1px solid #dee2e6; 7 | } 8 | 9 | .checkout-right { 10 | border-left: 1px solid #dee2e6; 11 | } 12 | 13 | .cartItems { 14 | border-bottom-left-radius: 0; 15 | border-bottom-right-radius: 0; 16 | } 17 | 18 | .cartTotals li { 19 | border: none; 20 | } 21 | 22 | .carousel-status { 23 | color: #000 !important; 24 | font-size: 16px !important; 25 | text-shadow: none !important; 26 | } 27 | 28 | .thumb { 29 | /* width: 150px !important; */ 30 | max-width: 100% !important;; 31 | height: auto !important;; 32 | } 33 | 34 | .thumbs-wrapper { 35 | margin: 5px !important; 36 | } 37 | 38 | .thumb.selected { 39 | border: 1px solid #ddd !important; 40 | } 41 | 42 | .dropzone { 43 | background: #f5f5f5; 44 | border: 1px dashed #c2c2c2; 45 | border-radius: 3px; 46 | text-align: center; 47 | padding: 20px; 48 | } 49 | 50 | .ql-toolbar { 51 | border: 1px solid #ddd !important; 52 | border-bottom: 0px; 53 | border-top-left-radius: .375rem; 54 | border-top-right-radius: .375rem; 55 | } 56 | 57 | .ql-container { 58 | border-color: #ddd !important; 59 | border-top: 0px; 60 | border-bottom-left-radius: .375rem; 61 | border-bottom-right-radius: .375rem; 62 | } 63 | 64 | .react-datepicker-wrapper { 65 | width: 100%; 66 | } 67 | 68 | @media only screen and (max-width: 980px) {} 69 | 70 | @media only screen and (max-width: 600px) {} -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './__e2e_tests__', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: 'http://127.0.0.1:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | testIdAttribute: 'data-test-id', 32 | }, 33 | 34 | globalTimeout: 60 * 60 * 1000, 35 | 36 | /* Configure projects for major browsers */ 37 | projects: [ 38 | { 39 | name: 'chromium', 40 | use: { ...devices['Desktop Chrome'] }, 41 | }, 42 | // { 43 | // name: 'Mobile Safari', 44 | // use: { ...devices['iPhone 13'] }, 45 | // }, 46 | // { 47 | // name: 'firefox', 48 | // use: { ...devices['Desktop Firefox'] }, 49 | // }, 50 | // { 51 | // name: 'webkit', 52 | // use: { ...devices['Desktop Safari'] }, 53 | // }, 54 | 55 | /* Test against mobile viewports. */ 56 | // { 57 | // name: 'Mobile Chrome', 58 | // use: { ...devices['Pixel 5'] }, 59 | // }, 60 | // { 61 | // name: 'Mobile Safari', 62 | // use: { ...devices['iPhone 12'] }, 63 | // }, 64 | 65 | /* Test against branded browsers. */ 66 | // { 67 | // name: 'Microsoft Edge', 68 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 69 | // }, 70 | // { 71 | // name: 'Google Chrome', 72 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 73 | // }, 74 | ], 75 | 76 | /* Run your local dev server before starting the tests */ 77 | webServer: { 78 | command: 'yarn dev', 79 | url: 'http://127.0.0.1:3000', 80 | reuseExistingServer: !process.env.CI, 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /prisma/schema-mongodb-example.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["fullTextSearch"] 4 | } 5 | 6 | datasource db { 7 | provider = "mongodb" 8 | url = env("DATABASE_CONNECTION_STRING") 9 | } 10 | 11 | model orders { 12 | id String @id @default(auto()) @map("_id") @db.ObjectId 13 | created_at DateTime @default(now()) @db.Timestamp() 14 | status String 15 | cart Json 16 | transaction_id String? 17 | checkout_id String? 18 | paid Boolean? 19 | gateway String 20 | totalUniqueItems Int 21 | totalAmount Int 22 | customerId String @db.ObjectId 23 | customer customers? @relation(fields: [customerId], references: [id]) 24 | } 25 | 26 | model products { 27 | id String @id @default(auto()) @map("_id") @db.ObjectId 28 | created_at DateTime? @default(now()) @db.Timestamp() 29 | name String 30 | permalink String 31 | summary String 32 | description String 33 | price Float 34 | images images[] 35 | enabled Boolean 36 | } 37 | 38 | model customers { 39 | id String @id @default(auto()) @map("_id") @db.ObjectId 40 | created_at DateTime @default(now()) @db.Timestamp() 41 | email String 42 | phone String 43 | firstName String 44 | lastName String 45 | address1 String 46 | suburb String 47 | state String 48 | postcode String 49 | country String 50 | orders orders[] 51 | } 52 | 53 | model users { 54 | id String @id @default(auto()) @map("_id") @db.ObjectId 55 | created_at DateTime @default(now()) @db.Timestamp() 56 | name String 57 | email String 58 | apiKey String? @default(uuid()) 59 | enabled Boolean 60 | } 61 | 62 | model images { 63 | id String @id @default(auto()) @map("_id") @db.ObjectId 64 | created_at DateTime @default(now()) @db.Timestamp() 65 | url String 66 | alt String 67 | filename String 68 | order Int 69 | productId String @db.ObjectId 70 | product products? @relation(fields: [productId], references: [id], onDelete: Cascade) 71 | } 72 | 73 | model discounts { 74 | id String @id @default(auto()) @map("_id") @db.ObjectId 75 | created_at DateTime @default(now()) @db.Timestamp() 76 | name String 77 | code String 78 | type discountType @default(amount) 79 | value Float 80 | enabled Boolean 81 | start_at DateTime @default(now()) @db.Timestamp() 82 | end_at DateTime @default(now()) @db.Timestamp() 83 | } 84 | 85 | enum discountType { 86 | amount 87 | percent 88 | } -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["fullTextSearch"] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_CONNECTION_STRING") 9 | } 10 | 11 | model orders { 12 | id String @id(map: "orders_pkey1") @default(uuid()) 13 | created_at DateTime @default(now()) @db.Timestamptz(6) 14 | status String 15 | cart Json @db.Json 16 | transaction_id String? 17 | checkout_id String? 18 | paid Boolean? 19 | gateway String 20 | totalUniqueItems Int 21 | totalAmount Int 22 | customerId String @default(uuid()) 23 | customer customers? @relation(fields: [customerId], references: [id]) 24 | } 25 | 26 | model products { 27 | id String @id @default(uuid()) 28 | created_at DateTime? @default(now()) @db.Timestamptz(6) 29 | name String 30 | permalink String 31 | summary String 32 | description String 33 | price Decimal @db.Decimal 34 | images images[] 35 | variants variants[] 36 | enabled Boolean 37 | } 38 | 39 | model variants { 40 | id String @id @default(uuid()) 41 | created_at DateTime? @default(now()) @db.Timestamptz(6) 42 | title String 43 | values String 44 | enabled Boolean 45 | productId String @default(uuid()) 46 | product products? @relation(fields: [productId], references: [id]) 47 | } 48 | 49 | model customers { 50 | id String @id @default(uuid()) 51 | created_at DateTime @default(now()) @db.Timestamptz(6) 52 | email String 53 | phone String 54 | firstName String 55 | lastName String 56 | address1 String 57 | suburb String 58 | state String 59 | postcode String 60 | country String 61 | orders orders[] 62 | } 63 | 64 | model users { 65 | id String @id @default(uuid()) 66 | created_at DateTime @default(now()) @db.Timestamptz(6) 67 | name String 68 | email String 69 | apiKey String? @default(uuid()) 70 | enabled Boolean 71 | } 72 | 73 | model images { 74 | id String @id @default(uuid()) 75 | created_at DateTime @default(now()) @db.Timestamptz(6) 76 | url String 77 | alt String 78 | filename String 79 | order Int 80 | productId String @default(uuid()) 81 | product products? @relation(fields: [productId], references: [id], onDelete: Cascade) 82 | } 83 | 84 | model discounts { 85 | id String @id @default(uuid()) 86 | created_at DateTime @default(now()) @db.Timestamptz(6) 87 | name String 88 | code String 89 | type discountType @default(amount) 90 | value Decimal @db.Decimal @default(0) 91 | enabled Boolean 92 | start_at DateTime @default(now()) @db.Timestamptz(6) 93 | end_at DateTime @default(now()) @db.Timestamptz(6) 94 | } 95 | 96 | enum discountType { 97 | amount 98 | percent 99 | } -------------------------------------------------------------------------------- /prisma/seed.mjs: -------------------------------------------------------------------------------- 1 | import { products } from './products'; 2 | import { createReadStream, statSync } from 'fs'; 3 | import { lookup } from 'mime-types'; 4 | import { 5 | DeleteObjectCommand, 6 | ListObjectsV2Command, 7 | PutObjectCommand, 8 | S3Client, 9 | } from '@aws-sdk/client-s3'; 10 | import { v4 as uuidv4 } from 'uuid'; 11 | import { PrismaClient } from '@prisma/client'; 12 | const prisma = new PrismaClient(); 13 | 14 | async function main() { 15 | // Remove any old images from S3 16 | await removeImages(); 17 | 18 | // Remove images from DB 19 | await prisma.images.deleteMany({}); 20 | 21 | // Remove existing 22 | await prisma.products.deleteMany({}); 23 | await prisma.images.deleteMany({}); 24 | 25 | // Loop and add our products 26 | for (const product of products) { 27 | const addedProduct = await prisma.products.create({ 28 | data: { 29 | name: product.name, 30 | permalink: product.permalink, 31 | summary: product.summary, 32 | description: product.description, 33 | price: product.price, 34 | enabled: product.enabled, 35 | }, 36 | }); 37 | 38 | // Add the images to the product 39 | for (const image of product.images) { 40 | // Upload image 41 | const { url, filename } = await uploadImage(image); 42 | 43 | // Add to DB 44 | await prisma.images.create({ 45 | data: { 46 | url: url, 47 | alt: image.attr, 48 | filename: filename, 49 | order: image.order, 50 | productId: addedProduct.id, 51 | }, 52 | }); 53 | } 54 | } 55 | console.log('Seed complete!!'); 56 | } 57 | 58 | const uploadImage = async image => { 59 | try { 60 | console.log('Uploading image...'); 61 | const fileExt = (/[^./\\]*$/.exec(image.url) || [''])[0]; 62 | const s3Client = new S3Client({}); 63 | const s3Bucket = process.env.AWS_S3_BUCKET_NAME; 64 | const fileUUID = uuidv4(); 65 | const fileName = `${fileUUID}.${fileExt}`; 66 | const fileSize = await statSync(image.url).size; 67 | 68 | // Upload the file 69 | const uploadCommand = new PutObjectCommand({ 70 | Bucket: s3Bucket, 71 | Key: fileName, 72 | Body: createReadStream(image.url), 73 | ACL: 'public-read', 74 | ContentType: lookup(image.url), 75 | ContentLength: fileSize, 76 | }); 77 | await s3Client.send(uploadCommand); 78 | return { 79 | url: `https://${s3Bucket}.s3.amazonaws.com/${fileName}`, 80 | filename: fileName, 81 | }; 82 | } catch (ex) { 83 | console.log('ex', ex); 84 | } 85 | }; 86 | 87 | const removeImages = async () => { 88 | try { 89 | console.log('Removing old images...'); 90 | const s3Client = new S3Client({}); 91 | const s3Bucket = process.env.AWS_S3_BUCKET_NAME; 92 | 93 | // Get a file list 94 | const listCommand = new ListObjectsV2Command({ 95 | Bucket: s3Bucket, // the bucket 96 | }); 97 | const list = await s3Client.send(listCommand); 98 | 99 | // Delete files 100 | for (const file of list.Contents) { 101 | // Remove the file from S3 102 | const removeCommand = new DeleteObjectCommand({ 103 | Bucket: s3Bucket, 104 | Key: file.Key, 105 | }); 106 | await s3Client.send(removeCommand); 107 | } 108 | } catch (ex) { 109 | return { 110 | message: 'Unable to delete file', 111 | }; 112 | } 113 | }; 114 | 115 | main() 116 | .then(async () => { 117 | await prisma.$disconnect(); 118 | }) 119 | .catch(async e => { 120 | console.error(e); 121 | await prisma.$disconnect(); 122 | process.exit(1); 123 | }); 124 | -------------------------------------------------------------------------------- /public/drop-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/drop-image.jpg -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/favicon.png -------------------------------------------------------------------------------- /public/images/5-panel-camp-hat/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/5-panel-camp-hat/1.jpg -------------------------------------------------------------------------------- /public/images/5-panel-camp-hat/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/5-panel-camp-hat/2.jpg -------------------------------------------------------------------------------- /public/images/derby-tier-backpack/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/derby-tier-backpack/1.jpg -------------------------------------------------------------------------------- /public/images/derby-tier-backpack/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/derby-tier-backpack/2.jpg -------------------------------------------------------------------------------- /public/images/derby-tier-backpack/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/derby-tier-backpack/3.jpg -------------------------------------------------------------------------------- /public/images/harriet-chambray-shirt/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/harriet-chambray-shirt/1.jpg -------------------------------------------------------------------------------- /public/images/harriet-chambray-shirt/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/harriet-chambray-shirt/2.jpg -------------------------------------------------------------------------------- /public/images/harriet-chambray-shirt/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/harriet-chambray-shirt/3.jpg -------------------------------------------------------------------------------- /public/images/harriet-chambray-shirt/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/harriet-chambray-shirt/4.jpg -------------------------------------------------------------------------------- /public/images/hudderton-backpack/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/hudderton-backpack/1.jpg -------------------------------------------------------------------------------- /public/images/hudderton-backpack/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/hudderton-backpack/2.jpg -------------------------------------------------------------------------------- /public/images/red-wing-iron-ranger-boot/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/red-wing-iron-ranger-boot/1.jpg -------------------------------------------------------------------------------- /public/images/red-wing-iron-ranger-boot/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/red-wing-iron-ranger-boot/2.jpg -------------------------------------------------------------------------------- /public/images/red-wing-iron-ranger-boot/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/red-wing-iron-ranger-boot/3.jpg -------------------------------------------------------------------------------- /public/images/scout-backpack/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/scout-backpack/1.jpg -------------------------------------------------------------------------------- /public/images/scout-backpack/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/scout-backpack/2.jpg -------------------------------------------------------------------------------- /public/images/scout-backpack/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/scout-backpack/3.jpg -------------------------------------------------------------------------------- /public/images/whitney-pullover/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/whitney-pullover/1.jpg -------------------------------------------------------------------------------- /public/images/whitney-pullover/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/whitney-pullover/2.jpg -------------------------------------------------------------------------------- /public/images/whitney-pullover/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/whitney-pullover/3.jpg -------------------------------------------------------------------------------- /public/images/whitney-pullover/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/images/whitney-pullover/4.jpg -------------------------------------------------------------------------------- /public/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/placeholder.png -------------------------------------------------------------------------------- /public/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrvautin/nextjs-checkout/0e4179367a8a1f1a7f2fbd3d1db75e302bd2414a/public/screenshot.jpg -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "allowSyntheticDefaultImports": true, 15 | "isolatedModules": true, 16 | "incremental": true, 17 | "strictNullChecks": false, 18 | "jsx": "preserve", 19 | }, 20 | "ts-node": { 21 | "esm": true, 22 | "experimentalSpecifierResolution": "node", 23 | }, 24 | "exclude": ["node_modules"], 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "prisma/seed.mjs"] 26 | } 27 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": [ 6 | ".next/**", 7 | "!.next/cache/**" 8 | ] 9 | }, 10 | "lint": {} 11 | } 12 | } --------------------------------------------------------------------------------