Repository: zeekrey/teini Branch: main Commit: 53800d9740a8 Files: 44 Total size: 235.9 KB Directory structure: gitextract_jnytts6o/ ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── README.md ├── __mocks__/ │ └── fileMock.js ├── __tests__/ │ ├── api/ │ │ └── products.http │ └── unit/ │ ├── cart.test.tsx │ ├── productsDb.test.ts │ └── shipping.test.tsx ├── components/ │ ├── Button.tsx │ ├── Footer.tsx │ ├── Layout.tsx │ ├── MenuBar.tsx │ ├── PageHeadline.tsx │ ├── ProductCard.tsx │ └── ProductCardCart.tsx ├── jest.config.js ├── jest.setup.js ├── lib/ │ ├── cart.tsx │ ├── fetcher.tsx │ ├── mapToObject.tsx │ └── stripeHelpers.tsx ├── license.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages/ │ ├── _app.tsx │ ├── _document.tsx │ ├── api/ │ │ ├── checkout_sessions/ │ │ │ ├── [id].ts │ │ │ └── index.ts │ │ └── products/ │ │ └── index.ts │ ├── cart.tsx │ ├── confirmation.tsx │ ├── index.tsx │ └── products/ │ ├── [slug].tsx │ └── index.tsx ├── prisma/ │ ├── migrations/ │ │ ├── 20210923095130_init/ │ │ │ └── migration.sql │ │ └── migration_lock.toml │ ├── schema.prisma │ └── seed.ts ├── stitches.config.ts ├── tsconfig.json ├── types.d.ts └── yarn-error.log ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": ["next", "next/core-web-vitals", "prettier"] } ================================================ FILE: .gitignore ================================================ node_modules # Keep environment variables out of version control .env .env.local .next ================================================ FILE: .prettierrc.json ================================================ {} ================================================ FILE: README.md ================================================ # Teini > Teini (tiny, [ˈtīnē]) is an extremely small webshop leveraging awesome and free solutions like Github and Vercel. Main purpose is to get you started fast and cheap. Hit the deploy button to create your own version of Teini or see a working demo here: https://teini.vercel.app/ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzeekrey%2Fteini&env=STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,SHOP_NAME,SHOP_CONTACT,SHOP_HEADLINE,SHOP_SUBHEADLINE&envDescription=You'll%20need%20Stripe%20API%20key.&envLink=https%3A%2F%2Fstripe.com%2Fdocs%2Fkeys&project-name=teini-copy&repo-name=teini-copy&redirect-url=https%3A%2F%2Fkrey.io&demo-title=Teini%20-%20The%20smallest%20eShop%20in%20the%20world&demo-description=A%20real%20online%20store.%20But%20without%20the%20costs%20and%20without%20complexity.&demo-url=https%3A%2F%2Fteini.co&demo-image=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1494256997604-768d1f608cac%3Fixid%3DMnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8%26ixlib%3Drb-1.2.1%26auto%3Dformat%26fit%3Dcrop%26w%3D1829%26q%3D80) ## Installation ### ...if you're not a developer > 💡 If you need help at any stage contact me. If you want me to setup Teini contact me as well. #### Accounts needed Running Teini should be easy and for free. Although you'll need to create some accounts to make it work: | Account | Description/What it does | Link | | ------------- | -------------------------------------------------------------------------- | ------------------- | | Vercel | Deploys and keeps the actual website running. It's awesome. | https://vercel.com/ | | Stripe | Provides the whole checkout and payment infrastructure. It's awesome, too. | https://stripe.com | | Github/Gitlab | The place where the source code is stored. Awesome - yep. | https://github.com | > 🤑 While Vercel and Github should be free while respecting their fair use policies, Stripe will cost some money. Fortunately, these are transaction-dependent. #### Environment Variables To configure your store you need to set some meta data and credentials upfront. The following data needs to be set: | Environment Variable | Description | Default | | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | ------- | | STRIPE_SECRET_KEY | The Stripe secret key: https://stripe.com/docs/keys#obtain-api-keys | | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | The Stripe publishable key: https://stripe.com/docs/keys#obtain-api-keys | | SHOP_NAME | Will show up in the browser tab and in the seo config. | | SHOP_CONTACT | A way customers can contact your. Could be an email or a Twitter handle. Will show up in the footer. | | SHOP_HEADLINE | Will show up on the index (start) page and in the seo config. | | SHOP_SUBHEADLINE | Will show up on the index (start) page and in the seo config. | Once you got everything together you can finally deploy your own version for Teini: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzeekrey%2Fteini&env=STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,SHOP_NAME,SHOP_CONTACT,SHOP_HEADLINE,SHOP_SUBHEADLINE&envDescription=You'll%20need%20Stripe%20API%20key.&envLink=https%3A%2F%2Fstripe.com%2Fdocs%2Fkeys&project-name=teini-copy&repo-name=teini-copy&redirect-url=https%3A%2F%2Fkrey.io&demo-title=Teini%20-%20The%20smallest%20eShop%20in%20the%20world&demo-description=A%20real%20online%20store.%20But%20without%20the%20costs%20and%20without%20complexity.&demo-url=https%3A%2F%2Fteini.co&demo-image=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1494256997604-768d1f608cac%3Fixid%3DMnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8%26ixlib%3Drb-1.2.1%26auto%3Dformat%26fit%3Dcrop%26w%3D1829%26q%3D80) ## Usage Once your store is up and running you definitly what to add your own products. Here is how to do this: ### 1. Access the repository To make changes you need to access the repository and change the actual source code. To do this you'll need the following tools (all of them are for free): - Git -> https://git-scm.com/ - VSCode -> https://code.visualstudio.com/ - Prisma Studio -> https://www.prisma.io/studio Once you got everything installed open a terminal and type the following command: ```bash git clone https://github.com/username/reponame ``` > 💡 The repo url depends on your choosen service, username and repo name. ### 2. Make changes to the product database Open Prisma Studio and open the product.db file. It is located at the root level of your repo and called `products.db`. Once the database add, update or delete products. ### 3. Add product images Teini holds all its static files like product images in the `public` folder. Product images in particular are store under `public/prodcuts/[productid]`. To add product images you just need to add a folder with the corresponding product-id (see your products.db) and put all product images in there. > 💡 Google recommends using the WebP as image format. You can convert your files here: https://cloudconvert.com/webp-converter ### 3. Make changes to the store itself Open a terminal and navigate (cd) to your local repository copy. Run this command: ```bash code . ``` Now VSCode should open and you can change what ever you want. ### 4.Push your changes To make your changes visible you need to run the following commands: ```bash git add . git commit -m "A message describing your work like; Added images for product 1." git push ``` If your go to https://vercel.com and open your project you should see that a deployment is started. If it is successfull you customers can see your changes. If it failed feel free to create an issue: https://github.com/zeekrey/teini/issues/new/choose ### ...if you're a developer Clone, Edit, Push. Do what ever you want. ## Notes Credits for the used photos: Product photos Photo by Boxed Water Is Better on Unsplash Success Page photo Photo by Jason Dent on Unsplash ================================================ FILE: __mocks__/fileMock.js ================================================ module.exports = "test-file-stub"; ================================================ FILE: __tests__/api/products.http ================================================ GET http://localhost:3000/api/products HTTP/1.1 ### GET http://localhost:3000/api/products?page=1 HTTP/1.1 ### ================================================ FILE: __tests__/unit/cart.test.tsx ================================================ /** * @jest-environment jsdom */ /** * This test file should test the cart feature. * Currently, there are three scenarios to be tested: * * 1. Add a product to cart * 2. Change amount of a product in cart * 3. Remove product */ import React from "react"; import { render, fireEvent } from "@testing-library/react"; import CartPage, { getStaticProps } from "../../pages/cart"; import { Tmeta } from "../../types"; import { PrismaClient, Prisma } from "@prisma/client"; import { useCart, CartProvider } from "../../lib/cart"; const prisma = new PrismaClient(); let product: null | Required; const TestWrapper: React.FunctionComponent<{ product: Required; }> = ({ children, product }) => { const { cart, dispatch } = useCart(); React.useEffect(() => { dispatch({ type: "addItem", item: { product: product, images: [], count: 1 }, }); }, []); return <>{children}; }; beforeAll(() => { // Otherwise useEffect hooks won't work: https://github.com/testing-library/react-testing-library/issues/215 jest.spyOn(React, "useEffect").mockImplementation(React.useLayoutEffect); return new Promise(async (resolve) => { const _product = await prisma.product.findFirst({ where: { name: { equals: "The first product", }, }, include: { brand: true, }, }); product = _product; resolve(product); }); }); // @ts-ignore afterAll(() => React.useEffect.mockRestore()); describe("Test cart", () => { it("should add a product to cart", async () => { /** * Get the actual cart page props. */ const { props } = (await getStaticProps({ params: undefined, })) as unknown as { props: { meta: Tmeta; shippingOptions: Required[]; }; }; const { queryByText } = render( []; })} /> , { wrapper: (props) => , } ); const productContainer = queryByText("The first product"); expect(productContainer).toBeInTheDocument(); }); it("should add a product of same type (count++)", async () => { /** * Get the actual cart page props. */ const { props } = (await getStaticProps({ params: undefined, })) as unknown as { props: { meta: Tmeta; shippingOptions: Required[]; }; }; const { getByText, getByTestId } = render( []; })} /> , { wrapper: (props) => , } ); fireEvent.click(getByText("+")); const productCount = getByTestId("productCount"); expect(productCount.textContent).toEqual("2"); }); it("should remove a product of same type (count--)", async () => { /** * Get the actual cart page props. */ const { props } = (await getStaticProps({ params: undefined, })) as unknown as { props: { meta: Tmeta; shippingOptions: Required[]; }; }; const { getByText, queryByText } = render( []; })} /> , { wrapper: (props) => , } ); fireEvent.click(getByText("-")); // product shouldn't be visibile because count === 0 const productContainer = queryByText("The first product"); expect(productContainer).not.toBeInTheDocument(); }); }); ================================================ FILE: __tests__/unit/productsDb.test.ts ================================================ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); afterAll(async () => { await prisma.$disconnect(); }); test("product.db should have at least one product", async () => { const allProducts = await prisma.product.findMany(); expect(allProducts.length).toBeGreaterThan(0); }); test("product.db should have at least one product in stock", async () => { const productsInStock = await prisma.product.findMany({ where: { availability: "inStock" }, }); expect(productsInStock.length).toBeGreaterThan(0); }); ================================================ FILE: __tests__/unit/shipping.test.tsx ================================================ /** * @jest-environment jsdom */ /** * This test file should test the shipping option feature. * Currently, there are three scenarios to be tested: * * 1. A product needs shipping * 2. A product doesn't need shipping * 3. Shipping costs are set to $0.00 because of isFreeFrom attribute */ import React from "react"; import { render } from "@testing-library/react"; import CartPage, { getStaticProps } from "../../pages/cart"; import { Tmeta } from "../../types"; import { PrismaClient, Prisma } from "@prisma/client"; import { useCart, CartProvider } from "../../lib/cart"; const prisma = new PrismaClient(); let products: Map< "productWithShipping" | "productWithoutShipping" | "productHasFreeShipping", Required > = new Map(); const TestWrapper: React.FunctionComponent<{ product: Required; }> = ({ children, product }) => { const { cart, dispatch } = useCart(); React.useEffect(() => { dispatch({ type: "addItem", item: { product: product, images: [], count: 1 }, }); }, []); return
{children}
; }; beforeAll(() => { // Otherwise useEffect hooks won't work: https://github.com/testing-library/react-testing-library/issues/215 jest.spyOn(React, "useEffect").mockImplementation(React.useLayoutEffect); return new Promise(async (resolve) => { const productWithShipping = await prisma.product.findFirst({ where: { needsShipping: { equals: true, }, }, include: { brand: true, }, }); const productWithoutShipping = await prisma.product.findFirst({ where: { needsShipping: { equals: false, }, }, include: { brand: true, }, }); const productHasFreeShipping = await prisma.product.findFirst({ where: { price: { gt: 10000, }, needsShipping: { equals: true, }, }, include: { brand: true, }, }); products.set( "productWithShipping", productWithShipping as unknown as Required ); products.set( "productWithoutShipping", productWithoutShipping as unknown as Required ); products.set( "productHasFreeShipping", productHasFreeShipping as unknown as Required ); resolve(products); }); }); // @ts-ignore afterAll(() => React.useEffect.mockRestore()); describe("Test shipping methods", () => { it("should not show shipping options (product doesn't need shipping).", async () => { /** * Get the actual cart page props. */ const { props } = (await getStaticProps({ params: undefined, })) as unknown as { props: { meta: Tmeta; shippingOptions: Required[]; }; }; const { queryByText } = render( []; })} /> , { wrapper: CartProvider, } ); const shippingOption = queryByText("Free Shipping"); expect(shippingOption).not.toBeInTheDocument(); }); it("should show shipping options with 0.00 as price.", async () => { /** * Get the actual cart page props. */ const { props } = (await getStaticProps({ params: undefined, })) as unknown as { props: { meta: Tmeta; shippingOptions: Required[]; }; }; const { queryAllByText } = render( []; })} /> , { wrapper: (props) => , } ); const shippingOptionPrice = queryAllByText("$ 0.00"); expect(shippingOptionPrice[0]).toBeInTheDocument(); }); it("should show shipping options (product needs shipping).", async () => { /** * Get the actual cart page props. */ const { props } = (await getStaticProps({ params: undefined, })) as unknown as { props: { meta: Tmeta; shippingOptions: Required[]; }; }; const { queryByText, debug } = render( []; })} /> , { wrapper: (props) => , } ); const shippingOption = queryByText("Free Shipping"); expect(shippingOption).toBeInTheDocument(); }); }); ================================================ FILE: components/Button.tsx ================================================ import { styled, keyframes } from "../stitches.config"; const Button = styled("button", { all: "unset", background: "$crimson3", fontFamily: "Work Sans, sans serif", color: "$crimson11", padding: "18px 24px", borderRadius: "1px", fontSize: "$3", cursor: "pointer", boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.06), 0px 0px 1px rgba(0, 0, 0, 0.04)", "&:hover": { background: "$crimson4", }, "&:focus": { boxShadow: "0px 0px 2px 0px $crimson11", }, '&:disabled': { background: '$crimson2', color: '$crimson6', cursor: 'wait' } }); export const Loading = () => { const skBouncedelay = keyframes({ "0%, 80%, 100%": { transform: "scale(0)", }, "40%": { transform: "scale(1.0)", }, }); const Spinner = styled("div", { textAlign: "center", margin: '0 $3', "& > div": { width: " 8px", height: "8px", backgroundColor: "$crimson11", marginLeft: '4px', borderRadius: "100%", display: "inline-block", animation: `${skBouncedelay} 1.4s infinite ease-in-out both`, }, "div:nth-child(1)": { animationDelay: "-0.32s", }, "div:nth-child(2)": { animationDelay: "-0.16s", }, }); return ( {[...Array(3).keys()].map((i) => (
))} ); }; export default Button; ================================================ FILE: components/Footer.tsx ================================================ import { styled } from "../stitches.config"; import type { Tmeta } from "../types"; const Wrapper = styled("footer", { padding: "$4", }); const Footer: React.FunctionComponent = ({ name, contact }) => { return ( {name}

{contact}

); }; export default Footer; ================================================ FILE: components/Layout.tsx ================================================ import { styled, Box } from "../stitches.config"; const LayoutWrapper = styled("div", { background: "$mauve1", "@small": { padding: "10% 10%", }, "@medium": { padding: "10% 15%", }, "@large": { padding: "5% 20%", }, }); const PageWrapper = styled("div", { margin: "0 $4", borderLeft: "1px solid $mauve4", borderRight: "1px solid $mauve4", }); const Layout: React.FunctionComponent = ({ children }) => { return ( {children} ); }; export default Layout; ================================================ FILE: components/MenuBar.tsx ================================================ import Link from "next/link"; import { styled, keyframes, Box } from "../stitches.config"; import { useCart } from "../lib/cart"; import { HomeIcon, ArchiveIcon, GearIcon, SunIcon, MoonIcon, } from "@modulz/radix-icons"; import * as Popover from "@radix-ui/react-popover"; import * as Switch from "@radix-ui/react-switch"; import { useTheme } from "next-themes"; import { useEffect, useRef, useState } from "react"; const scaleUp = keyframes({ "0%": { transform: "scale(1)", background: "$crimson10" }, "50%": { transform: "scale(1.5)" }, "100%": { transform: "scale(1)" }, }); const Wrapper = styled("div", { paddingLeft: "$4", marginLeft: "-$4", paddingRight: "$4", marginRight: "-$4", borderBottom: "1px solid $mauve4", borderTop: "1px solid $mauve4", }); const MenuBarBox = styled("div", { display: "flex", justifyContent: "space-between", alignItems: "center", boxShadow: "0px 10px 20px rgba(0, 0, 0, 0.04), 0px 2px 6px rgba(0, 0, 0, 0.04), 0px 0px 1px rgba(0, 0, 0, 0.04)", background: "$mauve1", "&>svg": { cursor: "pointer", color: "$mauve11", }, }); const CartSizeIcon = styled("div", { position: "absolute", bottom: "-4px", right: "-8px", background: "$crimson1", border: "1px solid $mauve5", width: "16px", height: "16px", fontSize: "6px", borderRadius: "9999px", display: "grid", placeContent: "center", variants: { animate: { true: { animation: `${scaleUp} 200ms`, }, false: {}, }, }, defaultVariants: { animate: false, }, }); const Item = styled("div", { flex: 1, display: "grid", placeContent: "center", padding: "$4", cursor: "pointer", "&:hover": { background: "$mauve2", }, "&:focus, &:active": { boxShadow: "0px 0px 2px 0px $mauve10", }, }); const StyledContent = styled(Popover.Content, { borderRadius: 1, padding: "20px", fontSize: 14, backgroundColor: "$mauve1", border: "1px solid $mauve4", color: "black", }); const StyledTrigger = styled(Popover.Trigger, { all: "unset", }); const StyledSwitch = styled(Switch.Root, { all: "unset", width: 42, height: 25, backgroundColor: "$crimson8", borderRadius: "9999px", position: "relative", WebkitTapHighlightColor: "rgba(0, 0, 0, 0)", "&:focus": { boxShadow: `0 0 0 2px $crimson9` }, '&[data-state="checked"]': { backgroundColor: "$crimson9" }, }); const StyledThumb = styled(Switch.Thumb, { display: "grid", placeContent: "center", width: 21, height: 21, backgroundColor: "$crimson4", color: "$crimson11", borderRadius: "9999px", transition: "transform 100ms", transform: "translateX(2px)", willChange: "transform", '&[data-state="checked"]': { transform: "translateX(19px)" }, }); const Flex = styled("div", { display: "flex" }); const Label = styled("label", { color: "$crimson12", fontSize: 15, lineHeight: 1, userSelect: "none", }); const MenuBar: React.FunctionComponent = () => { const { cart } = useCart(); const { theme, setTheme } = useTheme(); const [animate, setAnimate] = useState(false); return ( {cart && ( setAnimate(false)} > {cart.length} )} setTheme(theme === "light" ? "dark" : "light")} aria-label="Theme switch" > {theme === "light" ? : } {/* */} {/* */} ); }; export default MenuBar; ================================================ FILE: components/PageHeadline.tsx ================================================ import { styled, Box } from "../stitches.config"; const Headline = styled("h1", { all: "unset", color: "$crimson12", fontSize: "36px", lineHeight: "34px", fontFamily: "Work Sans, sans serif", }); const PageHeadline: React.FunctionComponent = ({ children }) => ( {children} ); export default PageHeadline; ================================================ FILE: components/ProductCard.tsx ================================================ import Link from "next/link"; import Image from "next/image"; import { styled, Box } from "../stitches.config"; import { Prisma } from "@prisma/client"; import { currencyCodeToSymbol } from "../lib/stripeHelpers"; import PlaceholderImage from "../public/placeholder.png"; const Wrapper = styled("div", { display: "flex", background: "$crimson1", cursor: "pointer", a: { flex: 1, }, }); const ProductName = styled("div", { fontFamily: "Work Sans, sans serif", color: "$crimson12", fontSize: "22px", }); const ProductPrice = styled("div", { display: "grid", placeContent: "center", }); const ProductBrand = styled("div", { color: "$crimson11", fontSize: '12px', }); const ImageContainer = styled("div", { position: "relative", height: "240px", width: "100%", }); const AnimatedImage = styled(Image, { transition: ".3s", }); const ProductCard: React.FunctionComponent<{ product: Required< Prisma.ProductUncheckedCreateInput & { brand: Prisma.BrandUncheckedCreateInput; } >; images?: { id: number; images: { paths: string[]; blurDataURLs: string[] }; }; }> = ({ product, images }) => { return ( {images ? ( ) : ( placeholder )}
{product.brand.name} {product.name}
{currencyCodeToSymbol(product.currency)} {product.price / 100}
); }; export default ProductCard; ================================================ FILE: components/ProductCardCart.tsx ================================================ import Link from "next/link"; import Image from "next/image"; import { styled, Box } from "../stitches.config"; import { Prisma } from "@prisma/client"; import { useCart } from "../lib/cart"; import { currencyCodeToSymbol } from "../lib/stripeHelpers"; import PlaceholderImage from "../public/placeholder.png"; import type { CartItem } from "../lib/cart"; import { useEffect, useState, useCallback } from "react"; const Wrapper = styled("div", { boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.06), 0px 0px 1px rgba(0, 0, 0, 0.04)", borderRadius: "1px", display: "flex", background: "$crimson1", }); const ProductName = styled("strong", { all: "unset", fontSize: "18px", lineHeight: "18px", color: "$crimson12", fontFamily: "Work Sans, sans-serif", }); const ProductPrice = styled("div", { fontSize: "20px", lineHeight: "20px", color: "$crimson12", fontFamily: "Work Sans, sans-serif", }); const ProductDescription = styled("p", { color: "$mauve10", fontSize: "12px", padding: 0, margin: 0, }); const CountButton = styled("button", { all: "unset", width: 30, height: 30, background: "$mauve3", color: "$mauve10", borderRadius: "$small", display: "inline-grid", placeContent: "center", cursor: "pointer", "&:hover": { background: "$mauve4", }, "&:focus": { boxShadow: "0px 0px 2px 0px $mauve10", }, }); const ImageContainer = styled("a", { all: "unset", position: "relative", height: "130px", width: "110px", cursor: "pointer", }); const ProductCardCart: React.FunctionComponent<{ item: CartItem; cart: CartItem[]; }> = ({ item }) => { const { cart, dispatch } = useCart(); const { product, images } = item; const count = cart.find((p) => p.product.id === item.product.id)?.count ?? 0; const handleAddItem = () => { dispatch({ type: "addItem", item: { product: product, images: [...(images ?? [])], count: 1 }, }); }; const handleRemoveItem = () => { dispatch({ type: "removeItem", item: { product: product, images: [...(images ?? [])], count: 1 }, }); }; return ( {images?.length ? ( {images[0].path} ) : ( placeholder )} {product.name} {product.description.substr(0, 40)}... {currencyCodeToSymbol(product.currency)} {product.price / 100} + {count} - ); }; export default ProductCardCart; ================================================ FILE: jest.config.js ================================================ module.exports = { collectCoverageFrom: [ "**/*.{js,jsx,ts,tsx}", "!**/*.d.ts", "!**/node_modules/**", ], moduleNameMapper: { /* Handle CSS imports (with CSS modules) https://jestjs.io/docs/webpack#mocking-css-modules */ // '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', // Handle CSS imports (without CSS modules) // '^.+\\.(css|sass|scss)$': '/__mocks__/styleMock.js', /* Handle image imports https://jestjs.io/docs/webpack#handling-static-assets */ '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': '/__mocks__/fileMock.js', }, testPathIgnorePatterns: ["/node_modules/", "/.next/"], transform: { /* Use babel-jest to transpile tests with the next/babel preset https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object */ "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }], }, transformIgnorePatterns: [ "/node_modules/", "^.+\\.module\\.(css|sass|scss)$", ], setupFilesAfterEnv: ['/jest.setup.js'] }; ================================================ FILE: jest.setup.js ================================================ import "@testing-library/jest-dom/extend-expect"; jest.mock("next/image", () => ({ __esModule: true, default: (props) => { // Use div instead of img tag. Img tag would require props. // eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element return
; }, })); ================================================ FILE: lib/cart.tsx ================================================ import { Prisma } from "@prisma/client"; import { createContext, useReducer, FunctionComponent, useContext, } from "react"; export type CartItem = { product: Required; count: number; images?: { path: string; blurDataURL: string }[]; }; type CartAction = | { type: "removeItem"; item: CartItem } | { type: "addItem"; item: CartItem } | { type: "clearCart" }; type Dispatch = (action: CartAction) => void; type CartState = CartItem[] | []; const CartContext = createContext< { cart: CartState; dispatch: Dispatch } | undefined >(undefined); const cartReducer = (cart: CartState, action: CartAction) => { switch (action.type) { case "addItem": { // Find the index of the given product const foundProductIndex = cart.findIndex( (_item) => _item.product.id === action.item.product.id ); // If the product was found, increse the count by 1 if (foundProductIndex > -1) { cart[foundProductIndex].count++; // Return a copy of the array, otherwise react won't rerender. return [...cart]; } // If the product wasn't found, add it to the cart array else { return [...cart, { ...action.item, count: 1 }]; } } case "removeItem": { // Find the index of the given product const foundProductIndex = cart.findIndex( (_item) => _item.product.id === action.item.product.id ); // If the product has a count > 1, reduce the count by one if (foundProductIndex > -1 && cart[foundProductIndex].count > 1) { cart[foundProductIndex].count--; // Return a copy of the array, otherwise react won't rerender. return [...cart]; } // If the product has a count === 1, remove the product from cart else { cart.splice(foundProductIndex, 1); // Return a copy of the array, otherwise react won't rerender. return [...cart]; } } case "clearCart": { return [] } default: { throw new Error(`Unhandled action type: ${JSON.stringify(action)}`); } } }; const CartProvider: FunctionComponent = ({ children }) => { const [cart, dispatch] = useReducer(cartReducer, []); const value = { cart, dispatch }; return {children}; }; const useCart = () => { const context = useContext(CartContext); if (context === undefined) throw new Error("useCart must be used within CartProvider"); // Get the total price for all products in cart // @ts-ignore const productsTotal: number = context.cart.reduce( (total: number, item: CartItem) => total + item.product.price * item.count, 0 ); const needsShipping = !!context.cart.filter( (item) => item.product.needsShipping ).length; return { ...context, productsTotal, needsShipping }; }; export { CartProvider, useCart }; ================================================ FILE: lib/fetcher.tsx ================================================ export async function fetchGetJSON(url: string) { try { const data = await fetch(url).then((res) => res.json()); return data; } catch (err) { console.error(err); throw new Error(); } } export async function fetchPostJSON(url: string, data?: {}) { try { // Default options are marked with * const response = await fetch(url, { method: "POST", // *GET, POST, PUT, DELETE, etc. mode: "cors", // no-cors, *cors, same-origin cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached credentials: "same-origin", // include, *same-origin, omit headers: { "Content-Type": "application/json", // 'Content-Type': 'application/x-www-form-urlencoded', }, redirect: "follow", // manual, *follow, error referrerPolicy: "no-referrer", // no-referrer, *client body: JSON.stringify(data || {}), // body data type must match "Content-Type" header }); return await response.json(); // parses JSON response into native JavaScript objects } catch (err) { console.error(err); throw new Error(); } } ================================================ FILE: lib/mapToObject.tsx ================================================ export const replacer = (key: any, value: any) => { if (value instanceof Map) { return { dataType: "Map", value: Array.from(value.entries()), // or with spread: value: [...value] }; } else { return value; } }; export const reviver = (key: any, value: any) => { if (typeof value === "object" && value !== null) { if (value.dataType === "Map") { return new Map(value.value); } } return value; }; ================================================ FILE: lib/stripeHelpers.tsx ================================================ import { Prisma } from "@prisma/client"; import type { CartItem } from "./cart"; export const cartItemToLineItem = ({ cartItem, images, }: { cartItem: CartItem; images: string[]; }) => { return { price_data: { currency: cartItem.product.currency, //ISO Code https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/amendments/lists/list_one.xml unit_amount_decimal: cartItem.product.price, product_data: { name: cartItem.product.name, description: cartItem.product.description, images: ["urlToImage"], // meta: { key: "value" }, // tax_code: "dqwd", // https://stripe.com/docs/tax/tax-codes }, }, adjustable_quantity: { enabled: true, }, // dynamic_tax_rates quantity: cartItem.count, }; }; /** * This is a singleton to ensure we only instantiate Stripe once. * Use @stripe/stripe-js/pure to delay loading of Stripe.js until Checkout. */ import { loadStripe } from "@stripe/stripe-js/pure"; import type { Stripe } from "@stripe/stripe-js"; let stripePromise: Promise; export const getStripe = () => { if (!stripePromise) { stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); } return stripePromise; }; const currencies = new Map([ ["usd", "$"], ["eur", "€"], ]); export const currencyCodeToSymbol = (currencyCode: string) => { return currencies.get(currencyCode); }; ================================================ FILE: license.md ================================================ MIT License Copyright (c) 2021 zeekrey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: next-env.d.ts ================================================ /// /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: next.config.js ================================================ /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, serverRuntimeConfig: { PROJECT_ROOT: __dirname, }, swcMinify: true, }; ================================================ FILE: package.json ================================================ { "name": "my-app", "version": "0.2.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "test": "jest", "release:patch": "release patch", "release:minor": "release minor", "release:major": "release major", "gh:release": "gh release create" }, "dependencies": { "@modulz/radix-icons": "^4.0.0", "@prisma/client": "^3.4.1", "@radix-ui/colors": "^0.1.7", "@radix-ui/react-popover": "^0.1.1", "@radix-ui/react-radio-group": "^0.1.1", "@radix-ui/react-switch": "^0.1.1", "@stitches/react": "^1.2.5", "@stripe/stripe-js": "^1.21.1", "blurhash": "^1.1.4", "next": "12.0.3", "next-seo": "^4.28.1", "next-themes": "^0.0.15", "plaiceholder": "^2.2.0", "react": "17.0.2", "react-dom": "17.0.2", "sharp": "^0.29.2", "stripe": "^8.186.1", "swr": "^1.0.1" }, "devDependencies": { "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/jest": "^27.0.2", "@types/react": "17.0.34", "babel-jest": "^27.3.1", "eslint": "7.32.0", "eslint-config-next": "12.0.3", "eslint-config-prettier": "^8.3.0", "jest": "^27.3.1", "prettier": "2.4.1", "prisma": "^3.4.1", "react-test-renderer": "^17.0.2", "release": "^6.3.0", "ts-node": "^10.4.0", "typescript": "4.4.4" }, "prisma": { "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" } } ================================================ FILE: pages/_app.tsx ================================================ import type { NextPage } from "next"; import type { AppProps } from "next/app"; import { globalStyles, darkTheme } from "../stitches.config"; import { ThemeProvider } from "next-themes"; import { IdProvider } from "@radix-ui/react-id"; import { CartProvider } from "../lib/cart"; type NextPageWithLayout = NextPage & { layout: React.FunctionComponent; }; type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout; }; function MyApp({ Component, pageProps }: AppPropsWithLayout) { const PageLayout = Component.layout ?? (({ children }) => children); globalStyles(); return ( ); } export default MyApp; ================================================ FILE: pages/_document.tsx ================================================ // eslint-disable-next-line @next/next/no-document-import-in-page import Document, { Html, Head, Main, NextScript } from "next/document"; import { getCssText } from "../stitches.config"; class MyDocument extends Document { render() { return (