├── .gitignore ├── .nvmrc ├── README.md ├── astro.config.mjs ├── functions ├── add-to-cart.js ├── get-cart.js ├── get-product-list.js ├── get-product.js ├── remove-from-cart.js └── utils │ ├── addItemToCart.js │ ├── createCartWithItem.js │ ├── postToShopify.js │ └── removeItemFromCart.js ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── assets │ ├── logo.svg │ └── testimonial-bg.jpeg ├── favicon.svg ├── robots.txt └── style │ └── global.css ├── snowpack.config.mjs └── src ├── components ├── Cart.jsx ├── CartTable.jsx ├── CartTotal.jsx ├── Footer.astro ├── Header.jsx ├── ProductListing.jsx └── ProductPageContent.jsx ├── pages ├── cart.astro ├── index.astro └── product │ └── [pid].astro └── utilityFunctions.js /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist 3 | 4 | # dependencies 5 | node_modules/ 6 | .snowpack/ 7 | 8 | # logs 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # environment variables 14 | .env 15 | .env.production 16 | 17 | # macOS-specific files 18 | .DS_Store 19 | 20 | # Deployment 21 | .netlify 22 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.15.1 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify + Astro + React 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/00c79ab2-364d-4c1d-923b-ed0a9a3b4d2b/deploy-status)](https://app.netlify.com/sites/shopify-astro/deploys) 4 | 5 | A demo of a Shopify site using [Astro](https://astro.build) and React. If you'd like to learn how it's built and how you can do the same, [check out this blog post](https://dev.to/netlify/build-a-modern-shopping-site-with-astro-and-serverless-functions-5326)! 6 | 7 | ## Customize and make it your own 8 | 9 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/cassidoo/shopify-react-astro) 10 | 11 | Clicking this button will clone the repo to your GitHub account and instantly deploy to Netlify. You will need to have the [Netlify CLI](https://cli.netlify.com/) installed, and a `.env` file at the top level of your project (after you clone) with the following: 12 | 13 | ```bash 14 | SHOPIFY_STOREFRONT_API_TOKEN=example 15 | SHOPIFY_API_ENDPOINT=https://exampleshopify/graphql.json 16 | ``` 17 | 18 | ## Commands 19 | 20 | All commands are run from the root of the project, from a terminal. Make sure you have the [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed so the serverless functions can work properly! 21 | 22 | | Command | Action | 23 | | :-------------- | :-------------------------------------- | 24 | | `npm install` | Installs dependencies | 25 | | `npm run start` | Starts local dev server | 26 | | `npm run build` | Build your production site to `./dist/` | 27 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | buildOptions: { 3 | site: 'https://shopify-astro.netlify.app/', 4 | }, 5 | renderers: ['@astrojs/renderer-react'], 6 | }; 7 | -------------------------------------------------------------------------------- /functions/add-to-cart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add to Cart API Endpoint 3 | * 4 | * * Purpose: Add a single item to the cart 5 | * @param {string} cartId (Optional) 6 | * @param {string} itemId - Usually it's the product variant id 7 | * @param {number} quantity - Minimum 1 8 | * 9 | * @returns {object} cart that contains lines of items inside 10 | * See './utils/createCartWithItem' for the data structure 11 | * 12 | * Examples: 13 | * 14 | * If a cart does not exist yet, 15 | * ``` 16 | * fetch('/.netlify/functions/add-to-cart', { 17 | * method: 'POST', 18 | * body: JSON.stringify({ 19 | * cardId: '', // cardId can also be omitted if desired 20 | * itemId: 'Z2lkOi8vc2hvcGlmFyaWFudC8zOTc0NDEyMDEyNzY5NA==', 21 | * quantity: 4 22 | * }) 23 | * }) 24 | * ``` 25 | * 26 | * Add item to an existing cart 27 | * ``` 28 | * fetch('/.netlify/functions/add-to-cart', { 29 | * method: 'POST', 30 | * body: JSON.stringify({ 31 | * cartId: 'S9Qcm9kdWN0VmFyaWFudC8zOTc0NDEyMDEyNzY5NA', 32 | * itemId: 'Z2lkOi8vc2hvcGlmFyaWFudC8zOTc0NDEyMDEyNzY5NA==', 33 | * quantity: 4 34 | * }) 35 | * }) 36 | * ``` 37 | */ 38 | 39 | const { createCartWithItem } = require('./utils/createCartWithItem') 40 | const { addItemToCart } = require('./utils/addItemToCart') 41 | 42 | exports.handler = async (event) => { 43 | const { cartId, itemId, quantity } = JSON.parse(event.body) 44 | 45 | if (cartId) { 46 | console.log('--------------------------------') 47 | console.log('Adding item to existing cart...') 48 | console.log('--------------------------------') 49 | 50 | const shopifyResponse = await addItemToCart({ 51 | cartId, 52 | itemId, 53 | quantity, 54 | }) 55 | 56 | return { 57 | statusCode: 200, 58 | body: JSON.stringify(shopifyResponse.cartLinesAdd.cart), 59 | } 60 | } else { 61 | console.log('--------------------------------') 62 | console.log('Creating new cart with item...') 63 | console.log('--------------------------------') 64 | const createCartResponse = await createCartWithItem({ 65 | itemId, 66 | quantity, 67 | }) 68 | 69 | return { 70 | statusCode: 200, 71 | body: JSON.stringify(createCartResponse.cartCreate.cart), 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /functions/get-cart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API Endpoint 3 | * 4 | * * Purpose: Get items from an existing cart 5 | * @param {string} cartId 6 | * 7 | * Example: 8 | *``` 9 | * fetch('/.netlify/functions/get-cart', { 10 | * method: 'POST', 11 | * body: JSON.stringify({ cartId: '12345' }) 12 | * }) 13 | * ``` 14 | * 15 | * ! POST method is intentional for future enhancement 16 | * 17 | * TODO: Add enhancement for pagination 18 | */ 19 | 20 | const { postToShopify } = require("./utils/postToShopify"); 21 | 22 | exports.handler = async (event) => { 23 | const { cartId } = JSON.parse(event.body); 24 | 25 | try { 26 | console.log("--------------------------------"); 27 | console.log("Retrieving existing cart..."); 28 | console.log("--------------------------------"); 29 | const shopifyResponse = await postToShopify({ 30 | query: ` 31 | query getCart($cartId: ID!) { 32 | cart(id: $cartId) { 33 | id 34 | lines(first: 10) { 35 | edges { 36 | node { 37 | id 38 | quantity 39 | merchandise { 40 | ... on ProductVariant { 41 | id 42 | title 43 | priceV2 { 44 | amount 45 | currencyCode 46 | } 47 | product { 48 | title 49 | handle 50 | images(first: 1) { 51 | edges { 52 | node { 53 | src 54 | altText 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | estimatedCost { 65 | totalAmount { 66 | amount 67 | currencyCode 68 | } 69 | subtotalAmount { 70 | amount 71 | currencyCode 72 | } 73 | totalTaxAmount { 74 | amount 75 | currencyCode 76 | } 77 | totalDutyAmount { 78 | amount 79 | currencyCode 80 | } 81 | } 82 | } 83 | } 84 | `, 85 | variables: { 86 | cartId, 87 | }, 88 | }); 89 | 90 | return { 91 | statusCode: 200, 92 | body: JSON.stringify(shopifyResponse), 93 | }; 94 | } catch (error) { 95 | console.log(error); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /functions/get-product-list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API Endpoint 3 | * 4 | * Purpose: Fetch first 100 products of the store 5 | * 6 | * Example: 7 | * ``` 8 | * fetch('/.netlify/functions/get-product-list', { 9 | * method: 'POST' 10 | * }) 11 | * ``` 12 | * 13 | * ! POST method is intentional for future enhancement 14 | * 15 | * TODO: Add enhancement for pagination 16 | */ 17 | 18 | const { postToShopify } = require('./utils/postToShopify'); 19 | 20 | exports.handler = async () => { 21 | try { 22 | console.log('--------------------------------'); 23 | console.log('Retrieving product list...'); 24 | console.log('--------------------------------'); 25 | const shopifyResponse = await postToShopify({ 26 | query: ` 27 | query getProductList { 28 | products(sortKey: TITLE, first: 100) { 29 | edges { 30 | node { 31 | id 32 | handle 33 | description 34 | title 35 | totalInventory 36 | variants(first: 5) { 37 | edges { 38 | node { 39 | id 40 | title 41 | quantityAvailable 42 | priceV2 { 43 | amount 44 | currencyCode 45 | } 46 | } 47 | } 48 | } 49 | priceRange { 50 | maxVariantPrice { 51 | amount 52 | currencyCode 53 | } 54 | minVariantPrice { 55 | amount 56 | currencyCode 57 | } 58 | } 59 | images(first: 1) { 60 | edges { 61 | node { 62 | src 63 | altText 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | `, 72 | }); 73 | 74 | return { 75 | statusCode: 200, 76 | body: JSON.stringify(shopifyResponse), 77 | }; 78 | } catch (error) { 79 | console.log(error); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /functions/get-product.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get Product API Endpoint 3 | * 4 | * * Purpose: Retrieve data on a specific product 5 | * @param {string} itemHandle - kebab-cased-product-name 6 | * 7 | * Example: 8 | * ``` 9 | * fetch('/.netlify/functions/get-product', { 10 | * method: 'POST', 11 | * body: JSON.stringify({ itemHandle: 'my-product' }) 12 | * }) 13 | * ``` 14 | */ 15 | 16 | const { postToShopify } = require('./utils/postToShopify') 17 | 18 | exports.handler = async (event) => { 19 | const { itemHandle } = JSON.parse(event.body) 20 | 21 | try { 22 | console.log('--------------------------------') 23 | console.log('Retrieving product details...') 24 | console.log('--------------------------------') 25 | const shopifyResponse = await postToShopify({ 26 | query: ` 27 | query getProduct($handle: String!) { 28 | productByHandle(handle: $handle) { 29 | id 30 | handle 31 | description 32 | title 33 | totalInventory 34 | variants(first: 5) { 35 | edges { 36 | node { 37 | id 38 | title 39 | quantityAvailable 40 | priceV2 { 41 | amount 42 | currencyCode 43 | } 44 | } 45 | } 46 | } 47 | priceRange { 48 | maxVariantPrice { 49 | amount 50 | currencyCode 51 | } 52 | minVariantPrice { 53 | amount 54 | currencyCode 55 | } 56 | } 57 | images(first: 1) { 58 | edges { 59 | node { 60 | src 61 | altText 62 | } 63 | } 64 | } 65 | } 66 | } 67 | `, 68 | variables: { 69 | handle: itemHandle, 70 | }, 71 | }) 72 | 73 | return { 74 | statusCode: 200, 75 | body: JSON.stringify(shopifyResponse), 76 | } 77 | } catch (error) { 78 | console.log(error) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /functions/remove-from-cart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove Item From Cart API Endpoint 3 | * 4 | * * Purpose: Remove a single item from the cart 5 | * @param {string} cartId 6 | * @param {string} lineId - Not the item or variant id 7 | * 8 | * Example: 9 | * ``` 10 | * fetch('/.netlify/functions/remove-from-cart', { 11 | * method: 'POST', 12 | * body: JSON.stringify({ 13 | * cartId: 'S9Qcm9kdWN0VmFyaWFudC8zOTc0NDEyMDEyNzY5NA', 14 | * lineId: 'RIJC3mn0c862e2fc3314ba5971bf22d73d7accb' 15 | * }) 16 | * }) 17 | * ``` 18 | */ 19 | 20 | const { removeItemFromCart } = require('./utils/removeItemFromCart'); 21 | 22 | exports.handler = async (event) => { 23 | const { cartId, lineId } = JSON.parse(event.body); 24 | 25 | try { 26 | console.log('--------------------------------'); 27 | console.log('Removing item from cart...'); 28 | console.log('--------------------------------'); 29 | const shopifyResponse = await removeItemFromCart({ 30 | cartId, 31 | lineId, 32 | }); 33 | 34 | return { 35 | statusCode: 200, 36 | body: JSON.stringify(shopifyResponse.cartLinesRemove.cart), 37 | }; 38 | } catch (error) { 39 | console.log(error); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /functions/utils/addItemToCart.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require('./postToShopify') 2 | 3 | exports.addItemToCart = async ({ cartId, itemId, quantity }) => { 4 | try { 5 | const shopifyResponse = postToShopify({ 6 | query: ` 7 | mutation addItemToCart($cartId: ID!, $lines: [CartLineInput!]!) { 8 | cartLinesAdd(cartId: $cartId, lines: $lines) { 9 | cart { 10 | id 11 | lines(first: 10) { 12 | edges { 13 | node { 14 | id 15 | quantity 16 | merchandise { 17 | ... on ProductVariant { 18 | id 19 | title 20 | priceV2 { 21 | amount 22 | currencyCode 23 | } 24 | product { 25 | title 26 | handle 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | estimatedCost { 34 | totalAmount { 35 | amount 36 | currencyCode 37 | } 38 | subtotalAmount { 39 | amount 40 | currencyCode 41 | } 42 | totalTaxAmount { 43 | amount 44 | currencyCode 45 | } 46 | totalDutyAmount { 47 | amount 48 | currencyCode 49 | } 50 | } 51 | } 52 | } 53 | } 54 | `, 55 | variables: { 56 | cartId, 57 | lines: [ 58 | { 59 | merchandiseId: itemId, 60 | quantity, 61 | }, 62 | ], 63 | }, 64 | }) 65 | 66 | return shopifyResponse 67 | } catch (error) { 68 | console.log(error) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /functions/utils/createCartWithItem.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require('./postToShopify') 2 | 3 | // Creates a cart with a single item 4 | exports.createCartWithItem = async ({ itemId, quantity }) => { 5 | try { 6 | const response = await postToShopify({ 7 | query: ` 8 | mutation createCart($cartInput: CartInput) { 9 | cartCreate(input: $cartInput) { 10 | cart { 11 | id 12 | createdAt 13 | updatedAt 14 | lines(first:10) { 15 | edges { 16 | node { 17 | id 18 | quantity 19 | merchandise { 20 | ... on ProductVariant { 21 | id 22 | title 23 | priceV2 { 24 | amount 25 | currencyCode 26 | } 27 | product { 28 | id 29 | title 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | estimatedCost { 37 | totalAmount { 38 | amount 39 | currencyCode 40 | } 41 | subtotalAmount { 42 | amount 43 | currencyCode 44 | } 45 | totalTaxAmount { 46 | amount 47 | currencyCode 48 | } 49 | totalDutyAmount { 50 | amount 51 | currencyCode 52 | } 53 | } 54 | } 55 | } 56 | } 57 | `, 58 | variables: { 59 | cartInput: { 60 | lines: [ 61 | { 62 | quantity, 63 | merchandiseId: itemId, 64 | }, 65 | ], 66 | }, 67 | }, 68 | }) 69 | 70 | return response 71 | } catch (error) { 72 | console.log(error) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /functions/utils/postToShopify.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | 3 | exports.postToShopify = async ({ query, variables }) => { 4 | try { 5 | const result = await fetch(process.env.SHOPIFY_API_ENDPOINT, { 6 | method: 'POST', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | 'X-Shopify-Storefront-Access-Token': 10 | process.env.SHOPIFY_STOREFRONT_API_TOKEN, 11 | }, 12 | body: JSON.stringify({ query, variables }), 13 | }).then((res) => res.json()) 14 | 15 | if (result.errors) { 16 | console.log({ errors: result.errors }) 17 | } else if (!result || !result.data) { 18 | console.log({ result }) 19 | return 'No results found.' 20 | } 21 | 22 | return result.data 23 | } catch (error) { 24 | console.log(error) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /functions/utils/removeItemFromCart.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require('./postToShopify') 2 | 3 | /** 4 | * @param {string} cartId - Target cart to update 5 | * @param lineId - Line id that the item belongs to 6 | */ 7 | exports.removeItemFromCart = async ({ cartId, lineId }) => { 8 | try { 9 | const shopifyResponse = await postToShopify({ 10 | query: ` 11 | mutation removeItemFromCart($cartId: ID!, $lineIds: [ID!]!) { 12 | cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { 13 | cart { 14 | id 15 | lines(first: 10) { 16 | edges { 17 | node { 18 | id 19 | quantity 20 | merchandise { 21 | ... on ProductVariant { 22 | id 23 | title 24 | priceV2 { 25 | amount 26 | currencyCode 27 | } 28 | product { 29 | title 30 | handle 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | estimatedCost { 38 | totalAmount { 39 | amount 40 | currencyCode 41 | } 42 | subtotalAmount { 43 | amount 44 | currencyCode 45 | } 46 | totalTaxAmount { 47 | amount 48 | currencyCode 49 | } 50 | totalDutyAmount { 51 | amount 52 | currencyCode 53 | } 54 | } 55 | } 56 | } 57 | } 58 | `, 59 | variables: { 60 | cartId, 61 | lineIds: [lineId], 62 | }, 63 | }) 64 | 65 | return shopifyResponse 66 | } catch (error) { 67 | console.log(error) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "dist" 4 | 5 | [dev] 6 | command = "npm run dev" 7 | framework = "#custom" 8 | port = 8888 9 | targetPort = 3000 10 | autoLaunch = true 11 | 12 | [functions] 13 | directory = "functions/" 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-react-astro", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "netlify dev", 7 | "dev": "astro dev", 8 | "build": "astro build" 9 | }, 10 | "devDependencies": { 11 | "astro": "^0.19.0-next.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/assets/testimonial-bg.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cassidoo/shopify-react-astro/337ec1f34294fea1618ce33738a0514e583f4c06/public/assets/testimonial-bg.jpeg -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /public/style/global.css: -------------------------------------------------------------------------------- 1 | *, 2 | :after, 3 | :before { 4 | box-sizing: border-box; 5 | margin: 0; 6 | } 7 | body, 8 | html { 9 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 10 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 11 | font-size: 16px; 12 | word-spacing: 1px; 13 | -ms-text-size-adjust: 100%; 14 | -webkit-text-size-adjust: 100%; 15 | -moz-osx-font-smoothing: grayscale; 16 | -webkit-font-smoothing: antialiased; 17 | box-sizing: border-box; 18 | } 19 | body { 20 | border: 10px solid #ccc; 21 | min-height: 100vh; 22 | line-height: 1.4; 23 | } 24 | h1, 25 | h2, 26 | h3 { 27 | font-family: Domine, 'PT Serif', -apple-system, BlinkMacSystemFont, Segoe UI, 28 | Roboto, Helvetica Neue, Arial, sans-serif; 29 | font-weight: 400; 30 | } 31 | h1 { 32 | font-size: 2.5rem; 33 | } 34 | p { 35 | margin: 20px 0; 36 | } 37 | a, 38 | a:active, 39 | a:visited { 40 | color: #d96528; 41 | text-decoration: none; 42 | transition: all 0.3s ease; 43 | } 44 | button { 45 | border: 1px solid #ccc; 46 | background: #fff; 47 | padding: 10px 14px; 48 | cursor: pointer; 49 | color: #000; 50 | font-weight: 700; 51 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 52 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 53 | transition: all 0.3s ease; 54 | } 55 | button:hover { 56 | background: #000; 57 | border: 1px solid #000; 58 | color: #fff; 59 | } 60 | hr { 61 | border-top: 1px solid #eee; 62 | margin: 30px 0; 63 | } 64 | input { 65 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 66 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 67 | font-size: 16px; 68 | padding: 5px 10px; 69 | } 70 | .app-header { 71 | flex-direction: column; 72 | padding: 40px 40px 0; 73 | } 74 | .app-header, 75 | .main-nav { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | } 80 | .app-header h1 a { 81 | color: #000; 82 | } 83 | .app-header h1 a:hover { 84 | color: #d96528; 85 | } 86 | .main-nav { 87 | width: 80vw; 88 | margin-top: 30px; 89 | border-top: 1px solid #ccc; 90 | border-bottom: 1px solid #ccc; 91 | padding: 8px 0; 92 | } 93 | .main-nav ul { 94 | padding-left: 0; 95 | } 96 | .main-nav-item { 97 | position: relative; 98 | display: inline; 99 | padding: 0 3px; 100 | font-size: 0.6rem; 101 | letter-spacing: 0.1em; 102 | text-transform: uppercase; 103 | } 104 | @media screen and (min-width: 414px) { 105 | .main-nav-item { 106 | padding: 0 8px; 107 | border-left: 1px solid #ddd; 108 | border-right: 1px solid #ddd; 109 | font-size: 0.7rem; 110 | } 111 | } 112 | @media screen and (min-width: 640px) { 113 | .main-nav-item { 114 | padding: 0 10px; 115 | font-size: 0.8rem; 116 | } 117 | } 118 | .main-nav-item a { 119 | color: #000; 120 | } 121 | .main-nav-item a:hover { 122 | color: #d96528; 123 | } 124 | .cart-size { 125 | position: absolute; 126 | top: -18px; 127 | right: -20px; 128 | width: 25px; 129 | height: 25px; 130 | padding: 6px 10px; 131 | border-radius: 1000px; 132 | background: #000; 133 | text-align: center; 134 | color: #fff; 135 | font-size: 10px; 136 | font-weight: 700; 137 | } 138 | @media screen and (min-width: 768px) { 139 | .cart-size { 140 | right: -18px; 141 | } 142 | } 143 | .testimonial { 144 | width: 100%; 145 | height: 280px; 146 | background: url(/assets/testimonial-bg.jpeg) 50% no-repeat; 147 | background-size: cover; 148 | display: flex; 149 | justify-content: center; 150 | align-items: center; 151 | flex-direction: column; 152 | color: #fff; 153 | } 154 | .testimonial h2 { 155 | padding: 0 30px; 156 | text-align: center; 157 | } 158 | .project-credit { 159 | width: 100%; 160 | padding: 10px 30px; 161 | background: #000; 162 | color: #fff; 163 | text-align: center; 164 | } 165 | .project-credit a, 166 | .project-credit a:active, 167 | .project-credit a:visited { 168 | color: #2af; 169 | font-weight: 700; 170 | } 171 | .app-footer-links { 172 | width: 80%; 173 | padding: 40px 0; 174 | margin-left: 10%; 175 | display: grid; 176 | grid-template-columns: 1fr 1fr; 177 | grid-template-rows: 1fr 1fr; 178 | grid-row-gap: 30px; 179 | } 180 | @media screen and (min-width: 1024px) { 181 | .app-footer-links { 182 | grid-template-columns: 1fr 1fr 2fr; 183 | grid-template-rows: 1fr; 184 | grid-row-gap: 0; 185 | } 186 | } 187 | .app-footer-links ul { 188 | list-style: none; 189 | padding-left: 0; 190 | } 191 | .newsletter { 192 | width: 100%; 193 | grid-column: 1 / span 2; 194 | } 195 | @media screen and (min-width: 1024px) { 196 | .newsletter { 197 | grid-column: 3; 198 | } 199 | } 200 | .newsletter-title { 201 | margin-bottom: 1rem; 202 | } 203 | .newsletter-input { 204 | width: 100%; 205 | padding: 10px; 206 | } 207 | .cart-page { 208 | width: 80vw; 209 | margin: 0 auto; 210 | } 211 | .cart-page-button.is-dark { 212 | background: #222; 213 | color: #f8f8f8; 214 | padding: 10px 14px; 215 | display: inline-block; 216 | } 217 | .cart-page-content { 218 | margin: 2rem 0 3rem; 219 | text-align: center; 220 | } 221 | .cart-page-message { 222 | margin-bottom: 1.5rem; 223 | } 224 | .cart-table { 225 | width: 100%; 226 | margin-top: 20px; 227 | margin-bottom: 30px; 228 | } 229 | .cart-table-cell { 230 | padding: 8px 0; 231 | border-bottom: 1px solid #ccc; 232 | } 233 | .cart-table-heading { 234 | padding: 10px 0; 235 | border-bottom: 1px solid #ccc; 236 | } 237 | .cart-table-row { 238 | text-align: center; 239 | } 240 | .cart-total { 241 | display: grid; 242 | grid-template-columns: repeat(5, 1fr); 243 | } 244 | .cart-total-content { 245 | grid-column: 1 / span 5; 246 | display: grid; 247 | grid-template-columns: repeat(2, 1fr); 248 | } 249 | @media screen and (min-width: 1024px) { 250 | .cart-total-content { 251 | grid-column: 4 / span 2; 252 | } 253 | } 254 | .cart-total-column p { 255 | padding: 10px; 256 | margin: 0; 257 | text-align: right; 258 | } 259 | .cart-total-column p:last-child { 260 | font-weight: 700; 261 | background: #f2eee2; 262 | } 263 | .product-page { 264 | margin: 60px 0; 265 | } 266 | .product-page-content { 267 | width: 80%; 268 | margin: 30px auto 0; 269 | } 270 | @media screen and (min-width: 1024px) { 271 | .product-page-content { 272 | display: grid; 273 | justify-content: space-between; 274 | justify-items: center; 275 | align-items: center; 276 | grid-template-columns: 1fr 1fr; 277 | grid-column-gap: 30px; 278 | } 279 | } 280 | .product-page-image { 281 | width: 100%; 282 | margin-bottom: 30px; 283 | } 284 | @media screen and (min-width: 1024px) { 285 | .product-page-image { 286 | width: 100%; 287 | margin-bottom: 0; 288 | } 289 | } 290 | .product-page-price { 291 | color: #d96528; 292 | font-size: 1.2rem; 293 | margin: 5px 0; 294 | font-weight: 400; 295 | font-family: Domine, 'PT Serif', -apple-system, BlinkMacSystemFont, Segoe UI, 296 | Roboto, Helvetica Neue, Arial, sans-serif; 297 | } 298 | .product-page-price-list, 299 | .product-page-price.is-solo { 300 | margin-bottom: 30px; 301 | } 302 | .product-page-quantity-input { 303 | width: 70px; 304 | } 305 | .product-page-quantity-row { 306 | display: flex; 307 | } 308 | main { 309 | margin: 30px 0 45px; 310 | } 311 | .product-grid { 312 | max-width: 60vw; 313 | margin: 0 auto; 314 | display: grid; 315 | grid-template-columns: 1fr; 316 | grid-template-rows: 1fr; 317 | grid-column-gap: 40px; 318 | grid-row-gap: 0; 319 | } 320 | @media screen and (min-width: 640px) { 321 | .product-grid { 322 | grid-template-columns: repeat(2, 1fr); 323 | } 324 | } 325 | @media screen and (min-width: 1024px) { 326 | .product-grid { 327 | grid-template-columns: repeat(3, 1fr); 328 | } 329 | } 330 | @media screen and (min-width: 1280px) { 331 | .product-grid { 332 | grid-template-columns: repeat(4, 1fr); 333 | } 334 | } 335 | .product-card { 336 | max-height: 500px; 337 | display: flex; 338 | justify-content: space-between; 339 | align-items: center; 340 | flex-direction: column; 341 | margin: 20px 0; 342 | } 343 | .product-card-description { 344 | margin-top: 0; 345 | margin-bottom: 1rem; 346 | overflow: hidden; 347 | width: 100%; 348 | display: -webkit-box; 349 | -webkit-box-orient: vertical; 350 | -webkit-line-clamp: 2; 351 | } 352 | .product-card-frame { 353 | height: 120px; 354 | margin-bottom: 0.5rem; 355 | display: flex; 356 | align-content: center; 357 | align-items: center; 358 | border-radius: 10px; 359 | overflow: hidden; 360 | } 361 | .product-card-frame img { 362 | width: 100%; 363 | border-radius: 10px; 364 | -o-object-fit: cover; 365 | object-fit: cover; 366 | height: 100%; 367 | } 368 | .product-card-text { 369 | margin: 0.5rem 0; 370 | } 371 | .product-card-title { 372 | margin: 0.5rem 0; 373 | font-weight: 700; 374 | } 375 | -------------------------------------------------------------------------------- /snowpack.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | /** 3 | * Snowpack automatically exposes these values on `import.meta.env` 4 | */ 5 | env: { 6 | NETLIFY_URL: process.env.NETLIFY 7 | ? process.env.URL 8 | : 'http://localhost:8888', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/Cart.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import CartTable from './CartTable'; 3 | import CartTotal from './CartTotal'; 4 | 5 | export default function Cart() { 6 | const [showProducts, setShowProducts] = useState(true); 7 | const [products, setProducts] = useState([]); 8 | const [cost, setCost] = useState({}); 9 | const [cartId, setCartId] = useState(null); 10 | 11 | useEffect(() => { 12 | const localCart = window.localStorage.getItem('astroCartId'); 13 | 14 | let data; 15 | 16 | if (localCart === null) { 17 | setShowProducts(false); 18 | } else { 19 | setCartId(localCart); 20 | data = fetch( 21 | `${import.meta.env.NETLIFY_URL}/.netlify/functions/get-cart`, 22 | { 23 | method: 'post', 24 | body: JSON.stringify({ 25 | cartId: localCart, 26 | }), 27 | headers: { 'Content-Type': 'application/json' }, 28 | } 29 | ) 30 | .then((res) => res.json()) 31 | .then((response) => { 32 | setProducts(response.cart.lines.edges); 33 | setCost(response.cart.estimatedCost); 34 | return response; 35 | }); 36 | } 37 | }, []); 38 | 39 | return ( 40 |
41 | {showProducts && products.length > 0 ? ( 42 |
43 | 48 | 49 |
50 | ) : ( 51 |
52 | No products to show! Get shopping! 53 |
54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/CartTable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { formatPrice, itemTotal } from '../utilityFunctions'; 3 | 4 | export default function CartTable({ cartItems, cartId, removeItem }) { 5 | let removeItemFromCart = (itemId) => { 6 | fetch( 7 | `${import.meta.env.NETLIFY_URL}/.netlify/functions/remove-from-cart`, 8 | { 9 | method: 'POST', 10 | body: JSON.stringify({ 11 | cartId: cartId, 12 | lineId: itemId, 13 | }), 14 | } 15 | ) 16 | .then((response) => response.json()) 17 | .then((response) => { 18 | console.log('--- Item deleted ---'); 19 | 20 | removeItem(response.lines.edges); 21 | return response; 22 | }); 23 | }; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {cartItems.map((item, index) => { 38 | item = item.node; 39 | 40 | let merchandiseTitle = 41 | item.merchandise.title === 'Default Title' 42 | ? '' 43 | : `(${item.merchandise.title})`; 44 | return ( 45 | 46 | 49 | 55 | 56 | 59 | 68 | 69 | ); 70 | })} 71 | 72 |
ItemPriceQuantityTotalActions
47 | {item.merchandise.product.title} {merchandiseTitle} 48 | 50 | {formatPrice( 51 | item.merchandise.priceV2.amount, 52 | item.merchandise.priceV2.currencyCode 53 | )} 54 | {item.quantity} 57 | {itemTotal(item.merchandise.priceV2, item.quantity)} 58 | 60 | 67 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/CartTotal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { formatPriceWithDefault } from '../utilityFunctions'; 3 | 4 | export default function CartTotal({ cost }) { 5 | let subtotal, tax, total; 6 | 7 | subtotal = formatPriceWithDefault( 8 | cost?.subtotalAmount?.amount, 9 | cost?.subtotalAmount?.currency 10 | ); 11 | tax = formatPriceWithDefault( 12 | cost?.totalTaxAmount?.amount, 13 | cost?.totalTaxAmount?.currency 14 | ); 15 | total = formatPriceWithDefault( 16 | cost?.totalAmount?.amount, 17 | cost?.totalAmount?.currency 18 | ); 19 | 20 | return ( 21 |
22 |
23 |
24 |

25 | Subtotal: 26 |

27 |

Shipping:

28 |

Tax:

29 |

Total:

30 |
31 |
32 |

33 | {subtotal} 34 |

35 |

Free Shipping

36 |

{tax}

37 |

{total}

38 |
39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Footer.astro: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Header() { 4 | return ( 5 |
6 |

7 | Shoperoni 8 |

9 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ProductListing.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ProductListing({ product }) { 4 | let image = product.images.edges[0].node; 5 | return ( 6 |
  • 7 |
    8 | {image.altText} 9 |
    10 |
    11 |

    {product.title}

    12 |

    13 | {product.description.substring(0, 60)}... 14 |

    15 |
    16 | 17 | 18 | 19 |
  • 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ProductPageContent.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { formatPrice } from '../utilityFunctions'; 3 | 4 | function getCurrentVariantObject(vars, id) { 5 | return vars.filter((v) => { 6 | return v.node.id === id; 7 | })[0]; 8 | } 9 | 10 | function VariantForm({ vars, current, pick, setQ }) { 11 | return ( 12 |
    13 | {vars.length > 1 && 14 | vars.map((v, index) => { 15 | return ( 16 |
    17 | 29 |
    30 |
    31 | ); 32 | })} 33 | { 40 | setQ(parseInt(e.target.value)); 41 | }} 42 | /> 43 |
    44 | ); 45 | } 46 | 47 | export default function ProductPageContent({ product }) { 48 | let vars = product.variants.edges; 49 | 50 | // Chosen variant ID 51 | const [chosenVariant, setChosenVariant] = useState(vars[0].node.id); 52 | // Quantity of the chosen variant 53 | const [quantity, setQuantity] = useState(1); 54 | const [cost, setCost] = useState(''); 55 | 56 | useEffect(() => { 57 | let variantPrice = getCurrentVariantObject(vars, chosenVariant).node.priceV2 58 | .amount; 59 | 60 | setCost(formatPrice(variantPrice * quantity)); 61 | }, [chosenVariant, quantity, cost]); 62 | 63 | let image = product.images.edges[0].node; 64 | 65 | let handleAddToCart = async () => { 66 | console.log('--- Adding to cart ---'); 67 | 68 | const localCart = window.localStorage.getItem('astroCartId'); 69 | 70 | const body = { 71 | cartId: localCart || '', 72 | itemId: chosenVariant, 73 | quantity: quantity, 74 | }; 75 | 76 | const cartResponse = await fetch( 77 | `${import.meta.env.NETLIFY_URL}/.netlify/functions/add-to-cart`, 78 | { 79 | method: 'post', 80 | body: JSON.stringify(body), 81 | headers: { 'Content-Type': 'application/json' }, 82 | } 83 | ); 84 | 85 | const data = await cartResponse.json(); 86 | window.localStorage.setItem('astroCartId', data.id); 87 | 88 | return data; 89 | }; 90 | 91 | return ( 92 |
    93 |
    94 | {image.altText} 99 |
    100 |
    101 |

    {product.title}

    102 |

    {cost}

    103 |

    {product.description}

    104 | 105 | 111 | 112 | {product.totalInventory > 0 ? ( 113 | 114 | ) : ( 115 | 118 | )} 119 |
    120 |
    121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/pages/cart.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Header from '../components/Header.jsx' 3 | import Footer from '../components/Footer.astro' 4 | import Cart from '../components/Cart.jsx'; 5 | --- 6 | 7 | 8 | 9 | 10 | 11 | 12 | Shoperoni | Cart 13 | 14 | 15 | 16 | 17 |
    18 |
    19 |
    20 |

    Your cart

    21 | 22 |
    23 |
    24 |