├── .prettierrc ├── static ├── favicon.ico ├── images │ └── testimonial-bg.jpg └── README.md ├── netlify.toml ├── layouts └── default.vue ├── utils └── currency.js ├── jsconfig.json ├── .editorconfig ├── assets └── README.md ├── plugins └── README.md ├── .eslintrc.js ├── middleware └── README.md ├── store ├── README.md └── cart.js ├── styles ├── _settings.scss ├── main.scss └── shoperoni.css ├── netlify └── functions │ ├── utils │ ├── postToShopify.js │ ├── addItemToCart.js │ ├── removeItemFromCart.js │ └── createCartWithItem.js │ ├── remove-from-cart.js │ ├── get-product.js │ ├── add-to-cart.js │ ├── get-product-list.js │ └── get-cart.js ├── components ├── ProductGrid.vue ├── CartTotal.vue ├── AppFooterLinks.vue ├── AppFooter.vue ├── ProductCard.vue ├── CartTable.vue └── AppHeader.vue ├── package.json ├── pages ├── index.vue ├── cart.vue └── products │ └── _handle.vue ├── README.md ├── .gitignore └── nuxt.config.js /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodezen/shopify-nuxt-kit/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/api/*" 3 | to = "/.netlify/functions/:splat" 4 | status = 200 5 | -------------------------------------------------------------------------------- /static/images/testimonial-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodezen/shopify-nuxt-kit/HEAD/static/images/testimonial-bg.jpg -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /utils/currency.js: -------------------------------------------------------------------------------- 1 | export const formatCurrency = (amount, currency) => { 2 | const amountFloat = Number(amount).toFixed(2) 3 | 4 | return '$' + amountFloat + ` ${currency}` 5 | } 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "~/*": ["./*"], 6 | "@/*": ["./*"], 7 | "~~/*": ["./*"], 8 | "@@/*": ["./*"] 9 | } 10 | }, 11 | "exclude": ["node_modules", ".nuxt", "dist"] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). 8 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | }, 7 | parserOptions: { 8 | parser: 'babel-eslint', 9 | }, 10 | extends: [ 11 | '@nuxtjs', 12 | 'plugin:prettier/recommended', 13 | 'plugin:nuxt/recommended', 14 | ], 15 | plugins: [], 16 | // add your custom rules here 17 | rules: {}, 18 | } 19 | -------------------------------------------------------------------------------- /middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /styles/_settings.scss: -------------------------------------------------------------------------------- 1 | /*------------ Variables -----------*/ 2 | 3 | // Colors 4 | $brandprimary: #d96528; 5 | $brandsecondary: #03c1c1; 6 | 7 | // Typography 8 | $fontSerif: 'Domine', 'PT Serif', -apple-system, BlinkMacSystemFont, 'Segoe UI', 9 | Roboto, 'Helvetica Neue', Arial, sans-serif; 10 | $fontSansSerif: 'Nanum Gothic', 'Montserrat', -apple-system, BlinkMacSystemFont, 11 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 12 | 13 | // Screen Widths 14 | $deviceXs: 414px; 15 | $deviceSm: 640px; 16 | $deviceMd: 768px; 17 | $deviceLg: 1024px; 18 | $deviceXl: 1280px; 19 | 20 | /*------------ Mixins -----------*/ 21 | 22 | @mixin breakpoint($size) { 23 | @media screen and (min-width: $size) { 24 | @content; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /netlify/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 | -------------------------------------------------------------------------------- /components/ProductGrid.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-nuxt", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxt", 7 | "build": "nuxt build", 8 | "start": "nuxt start", 9 | "generate": "nuxt generate", 10 | "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", 11 | "lint": "yarn lint:js" 12 | }, 13 | "dependencies": { 14 | "@nuxt/http": "^0.6.4", 15 | "core-js": "^3.9.1", 16 | "node-fetch": "^2.6.1", 17 | "nuxt": "^2.15.8" 18 | }, 19 | "devDependencies": { 20 | "@nuxtjs/eslint-config": "^6.0.0", 21 | "@nuxtjs/eslint-module": "^3.0.2", 22 | "@nuxtjs/style-resources": "^1.2.0", 23 | "babel-eslint": "^10.1.0", 24 | "eslint": "^7.22.0", 25 | "eslint-config-prettier": "^8.1.0", 26 | "eslint-plugin-nuxt": "^2.0.0", 27 | "eslint-plugin-prettier": "^3.3.1", 28 | "eslint-plugin-vue": "^7.7.0", 29 | "prettier": "^2.2.1", 30 | "sass": "^1.35.1", 31 | "sass-loader": "10" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /netlify/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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify Nuxt Kit 2 | 3 | A starter template kit for those looking to use [Shopify](https://www.shopify.com)'s new Cart API with Nuxt 2 on [Netlify](https://www.netlify.com). 4 | 5 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/bencodezen/shopify-nuxt-kit) 6 | 7 | ## Configuration 8 | 9 | You need to set the following environment variables in your Netlify dashboard: 10 | 11 | - `SHOPIFY_API_ENDPOINT`: Example `https://example.myshopify.com/api/unstable/graphql.json` 12 | - `SHOPIFY_STOREFRONT_API_TOKEN`: Example `asdfj8fjhd83js83dhdhs8s` 13 | 14 | ## Build Setup 15 | 16 | _**Prerequisite**: [Netlify CLI](https://docs.netlify.com/cli/get-started/) and [Node 16](https://nodejs.org/en/)_ 17 | 18 | ```bash 19 | # install dependencies 20 | $ npm install 21 | 22 | # serve with hot reload at localhost:8888 23 | $ ntl dev 24 | 25 | # build for production and launch server 26 | $ npm run build 27 | $ npm run start 28 | 29 | # generate static project 30 | $ npm run generate 31 | ``` 32 | 33 | For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). 34 | 35 | ## Credit 36 | 37 | Big hat tip to [Sarah's Netlify E-commerce Site](https://github.com/sdras/ecommerce-netlify/) which served as a big inspiration for the current design. 38 | -------------------------------------------------------------------------------- /components/CartTotal.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 37 | 38 | 65 | -------------------------------------------------------------------------------- /components/AppFooterLinks.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 30 | 31 | 71 | -------------------------------------------------------------------------------- /components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 29 | 30 | 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | 92 | # Local Netlify folder 93 | .netlify -------------------------------------------------------------------------------- /pages/cart.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 50 | 51 | 73 | -------------------------------------------------------------------------------- /styles/main.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Inspired by Sarah's E-Commerce Site 3 | * https://github.com/sdras/ecommerce-netlify/blob/main/assets/main.scss 4 | */ 5 | 6 | /*------------ Global -----------*/ 7 | *, 8 | *::before, 9 | *::after { 10 | box-sizing: border-box; 11 | margin: 0; 12 | } 13 | 14 | html { 15 | font-family: $fontSansSerif; 16 | font-size: 16px; 17 | word-spacing: 1px; 18 | -ms-text-size-adjust: 100%; 19 | -webkit-text-size-adjust: 100%; 20 | -moz-osx-font-smoothing: grayscale; 21 | -webkit-font-smoothing: antialiased; 22 | box-sizing: border-box; 23 | } 24 | 25 | body { 26 | border: 10px solid #ccc; 27 | min-height: 100vh; 28 | font-family: $fontSansSerif; 29 | font-size: 16px; 30 | line-height: 1.4; 31 | word-spacing: 1px; 32 | -ms-text-size-adjust: 100%; 33 | -webkit-text-size-adjust: 100%; 34 | -moz-osx-font-smoothing: grayscale; 35 | -webkit-font-smoothing: antialiased; 36 | box-sizing: border-box; 37 | } 38 | 39 | h1, 40 | h2, 41 | h3 { 42 | font-family: $fontSerif; 43 | font-weight: normal; 44 | } 45 | 46 | h1 { 47 | font-size: 2.5rem; 48 | } 49 | 50 | p { 51 | margin: 20px 0; 52 | } 53 | 54 | a, 55 | a:active, 56 | a:visited { 57 | color: $brandprimary; 58 | text-decoration: none; 59 | transition: 0.3s all ease; 60 | } 61 | 62 | button { 63 | border: 1px solid #ccc; 64 | background: white; 65 | padding: 10px 14px; 66 | cursor: pointer; 67 | color: black; 68 | font-weight: 700; 69 | font-family: $fontSansSerif; 70 | transition: 0.3s all ease; 71 | 72 | &:hover { 73 | background: black; 74 | border: 1px solid black; 75 | color: white; 76 | } 77 | } 78 | 79 | hr { 80 | border-top: 1px solid #eee; 81 | margin: 30px 0; 82 | } 83 | 84 | input { 85 | font-family: $fontSansSerif; 86 | font-size: 16px; 87 | padding: 5px 10px; 88 | } 89 | -------------------------------------------------------------------------------- /components/ProductCard.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 40 | 41 | 85 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Target: https://go.nuxtjs.dev/config-target 3 | target: 'static', 4 | 5 | // Global page headers: https://go.nuxtjs.dev/config-head 6 | head: { 7 | title: 'Shoperoni', 8 | htmlAttrs: { 9 | lang: 'en', 10 | }, 11 | meta: [ 12 | { charset: 'utf-8' }, 13 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 14 | { hid: 'description', name: 'description', content: '' }, 15 | ], 16 | link: [ 17 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, 18 | { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, 19 | { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, 20 | { 21 | href: 'https://fonts.googleapis.com/css2?family=Domine:wght@400;500;600;700&family=Nanum+Gothic:wght@400;700&display=swap', 22 | rel: 'stylesheet', 23 | }, 24 | ], 25 | }, 26 | 27 | // Global CSS: https://go.nuxtjs.dev/config-css 28 | css: ['~/styles/main.scss'], 29 | 30 | styleResources: { 31 | // your settings here 32 | scss: ['~/styles/_settings.scss'], 33 | }, 34 | 35 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 36 | plugins: [], 37 | 38 | // Auto import components: https://go.nuxtjs.dev/config-components 39 | components: true, 40 | 41 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 42 | buildModules: [ 43 | // https://go.nuxtjs.dev/eslint 44 | '@nuxtjs/eslint-module', 45 | '@nuxtjs/style-resources', 46 | ], 47 | 48 | // Modules: https://go.nuxtjs.dev/config-modules 49 | modules: ['@nuxt/http'], 50 | 51 | // Build Configuration: https://go.nuxtjs.dev/config-build 52 | build: {}, 53 | 54 | // $http Configuration: https://go.nuxtjs.dev/config-build 55 | http: { 56 | baseUrl: 57 | process.env.NODE_ENV !== 'production' 58 | ? 'http://localhost:8888' 59 | : 'https://shopify-nuxt-kit.netlify.app', 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /netlify/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 | -------------------------------------------------------------------------------- /netlify/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/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 | -------------------------------------------------------------------------------- /netlify/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 | -------------------------------------------------------------------------------- /netlify/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 | -------------------------------------------------------------------------------- /netlify/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 | productType 36 | totalInventory 37 | variants(first: 5) { 38 | edges { 39 | node { 40 | id 41 | title 42 | quantityAvailable 43 | priceV2 { 44 | amount 45 | currencyCode 46 | } 47 | } 48 | } 49 | } 50 | priceRange { 51 | maxVariantPrice { 52 | amount 53 | currencyCode 54 | } 55 | minVariantPrice { 56 | amount 57 | currencyCode 58 | } 59 | } 60 | images(first: 1) { 61 | edges { 62 | node { 63 | src 64 | altText 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | `, 73 | }) 74 | 75 | return { 76 | statusCode: 200, 77 | body: JSON.stringify(shopifyResponse), 78 | } 79 | } catch (error) { 80 | console.log(error) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /store/cart.js: -------------------------------------------------------------------------------- 1 | import { formatCurrency } from '../utils/currency' 2 | 3 | export const state = () => ({ 4 | base: { 5 | id: '', 6 | lines: { 7 | edges: [], 8 | }, 9 | estimatedCost: { 10 | subtotalAmount: {}, 11 | totalAmount: {}, 12 | }, 13 | }, 14 | }) 15 | 16 | export const getters = { 17 | id: (state) => { 18 | return state.base?.id ? state.base.id : '' 19 | }, 20 | items: (state) => { 21 | if (state.base && state.base.lines) { 22 | return state.base.lines.edges 23 | } else { 24 | return [] 25 | } 26 | }, 27 | size: (_, getters) => { 28 | if (getters.items.length > 0) { 29 | return getters.items.reduce((acc, cv) => { 30 | return acc + cv.node.quantity 31 | }, 0) 32 | } else { 33 | return 0 34 | } 35 | }, 36 | subtotal: (state) => { 37 | if (state.base && state.base.estimatedCost) { 38 | const subtotal = state.base.estimatedCost.subtotalAmount 39 | 40 | return formatCurrency(subtotal.amount, subtotal.currencyCode) 41 | } 42 | }, 43 | tax: (state) => { 44 | if (state.base && state.base.estimatedCost) { 45 | const tax = state.base.estimatedCost.totalTaxAmount 46 | ? state.base.estimatedCost.totalTaxAmount 47 | : { 48 | amount: 0, 49 | currencyCode: 'USD', 50 | } 51 | 52 | return formatCurrency(tax.amount, tax.currencyCode) 53 | } 54 | }, 55 | total: (state) => { 56 | if (state.base && state.base.estimatedCost) { 57 | const total = state.base.estimatedCost.totalAmount 58 | 59 | return formatCurrency(total.amount, total.currencyCode) 60 | } 61 | }, 62 | } 63 | 64 | export const mutations = { 65 | setBase(state, response) { 66 | state.base = response 67 | }, 68 | setId(state, id) { 69 | state.base.id = id 70 | }, 71 | } 72 | 73 | export const actions = { 74 | updateBase({ commit }, response) { 75 | window.localStorage.removeItem('shopifyNuxtCart') 76 | window.localStorage.setItem('shopifyNuxtCart', JSON.stringify(response)) 77 | commit('setBase', response) 78 | }, 79 | updateId({ commit }, id) { 80 | window.localStorage.removeItem('shopifyNuxtCartId') 81 | window.localStorage.setItem('shopifyNuxtCartId', id) 82 | commit('setId', id) 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /components/CartTable.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 70 | 71 | 92 | -------------------------------------------------------------------------------- /components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | 39 | 109 | -------------------------------------------------------------------------------- /netlify/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 | -------------------------------------------------------------------------------- /pages/products/_handle.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 145 | 146 | 199 | -------------------------------------------------------------------------------- /styles/shoperoni.css: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the result of the compiled CSS from the various components. 3 | * It's meant to serve as a single file that can be imported into any site 4 | * and as long as the markup is identical, the design should be applied correctly. 5 | */ 6 | *, 7 | :after, 8 | :before { 9 | box-sizing: border-box; 10 | margin: 0; 11 | } 12 | body, 13 | html { 14 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 15 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 16 | font-size: 16px; 17 | word-spacing: 1px; 18 | -ms-text-size-adjust: 100%; 19 | -webkit-text-size-adjust: 100%; 20 | -moz-osx-font-smoothing: grayscale; 21 | -webkit-font-smoothing: antialiased; 22 | box-sizing: border-box; 23 | } 24 | body { 25 | border: 10px solid #ccc; 26 | min-height: 100vh; 27 | line-height: 1.4; 28 | } 29 | h1, 30 | h2, 31 | h3 { 32 | font-family: Domine, 'PT Serif', -apple-system, BlinkMacSystemFont, Segoe UI, 33 | Roboto, Helvetica Neue, Arial, sans-serif; 34 | font-weight: 400; 35 | } 36 | h1 { 37 | font-size: 2.5rem; 38 | } 39 | p { 40 | margin: 20px 0; 41 | } 42 | a, 43 | a:active, 44 | a:visited { 45 | color: #d96528; 46 | text-decoration: none; 47 | transition: all 0.3s ease; 48 | } 49 | button { 50 | border: 1px solid #ccc; 51 | background: #fff; 52 | padding: 10px 14px; 53 | cursor: pointer; 54 | color: #000; 55 | font-weight: 700; 56 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 57 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 58 | transition: all 0.3s ease; 59 | } 60 | button:hover { 61 | background: #000; 62 | border: 1px solid #000; 63 | color: #fff; 64 | } 65 | hr { 66 | border-top: 1px solid #eee; 67 | margin: 30px 0; 68 | } 69 | input { 70 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 71 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 72 | font-size: 16px; 73 | padding: 5px 10px; 74 | } 75 | .app-header { 76 | flex-direction: column; 77 | padding: 40px 40px 0; 78 | } 79 | .app-header, 80 | .main-nav { 81 | display: flex; 82 | justify-content: center; 83 | align-items: center; 84 | } 85 | .main-nav { 86 | width: 80vw; 87 | margin-top: 30px; 88 | border-top: 1px solid #ccc; 89 | border-bottom: 1px solid #ccc; 90 | padding: 8px 0; 91 | } 92 | .main-nav ul { 93 | padding-left: 0; 94 | } 95 | .main-nav-item { 96 | position: relative; 97 | display: inline; 98 | padding: 0 3px; 99 | font-size: 0.6rem; 100 | letter-spacing: 0.1em; 101 | text-transform: uppercase; 102 | } 103 | @media screen and (min-width: 414px) { 104 | .main-nav-item { 105 | padding: 0 8px; 106 | border-left: 1px solid #ddd; 107 | border-right: 1px solid #ddd; 108 | font-size: 0.7rem; 109 | } 110 | } 111 | @media screen and (min-width: 640px) { 112 | .main-nav-item { 113 | padding: 0 10px; 114 | font-size: 0.8rem; 115 | } 116 | } 117 | .main-nav-item a { 118 | color: #000; 119 | } 120 | .main-nav-item a:hover { 121 | color: #d96528; 122 | } 123 | .cart-size { 124 | position: absolute; 125 | top: -18px; 126 | right: -20px; 127 | width: 25px; 128 | height: 25px; 129 | padding: 6px 10px; 130 | border-radius: 1000px; 131 | background: #000; 132 | text-align: center; 133 | color: #fff; 134 | font-size: 10px; 135 | font-weight: 700; 136 | } 137 | @media screen and (min-width: 768px) { 138 | .cart-size { 139 | right: -18px; 140 | } 141 | } 142 | .testimonial { 143 | width: 100%; 144 | height: 280px; 145 | background: url(/images/testimonial-bg.jpg) 50% no-repeat; 146 | background-size: cover; 147 | display: flex; 148 | justify-content: center; 149 | align-items: center; 150 | flex-direction: column; 151 | color: #fff; 152 | } 153 | .testimonial h2 { 154 | padding: 0 30px; 155 | text-align: center; 156 | } 157 | .project-credit { 158 | width: 100%; 159 | padding: 10px 30px; 160 | background: #000; 161 | color: #fff; 162 | text-align: center; 163 | } 164 | .project-credit a, 165 | .project-credit a:active, 166 | .project-credit a:visited { 167 | color: #2af; 168 | font-weight: 700; 169 | } 170 | .app-footer-links { 171 | width: 80%; 172 | padding: 40px 0; 173 | margin-left: 10%; 174 | display: grid; 175 | grid-template-columns: 1fr 1fr; 176 | grid-template-rows: 1fr 1fr; 177 | grid-row-gap: 30px; 178 | } 179 | @media screen and (min-width: 1024px) { 180 | .app-footer-links { 181 | grid-template-columns: 1fr 1fr 2fr; 182 | grid-template-rows: 1fr; 183 | grid-row-gap: 0; 184 | } 185 | } 186 | .app-footer-links ul { 187 | list-style: none; 188 | padding-left: 0; 189 | } 190 | .newsletter { 191 | width: 100%; 192 | grid-column: 1 / span 2; 193 | } 194 | @media screen and (min-width: 1024px) { 195 | .newsletter { 196 | grid-column: 3; 197 | } 198 | } 199 | .newsletter-title { 200 | margin-bottom: 1rem; 201 | } 202 | .newsletter-input { 203 | width: 100%; 204 | padding: 10px; 205 | } 206 | .cart-page { 207 | width: 80vw; 208 | margin: 0 auto; 209 | } 210 | .cart-page-button.is-dark { 211 | background: #222; 212 | color: #f8f8f8; 213 | padding: 10px 14px; 214 | display: inline-block; 215 | } 216 | .cart-page-content { 217 | margin: 2rem 0 3rem; 218 | text-align: center; 219 | } 220 | .cart-page-message { 221 | margin-bottom: 1.5rem; 222 | } 223 | .cart-table { 224 | width: 100%; 225 | margin-top: 20px; 226 | margin-bottom: 30px; 227 | } 228 | .cart-table-cell { 229 | padding: 8px 0; 230 | border-bottom: 1px solid #ccc; 231 | } 232 | .cart-table-heading { 233 | padding: 10px 0; 234 | border-bottom: 1px solid #ccc; 235 | } 236 | .cart-table-row { 237 | text-align: center; 238 | } 239 | .cart-total { 240 | display: grid; 241 | grid-template-columns: repeat(5, 1fr); 242 | } 243 | .cart-total-content { 244 | grid-column: 1 / span 5; 245 | display: grid; 246 | grid-template-columns: repeat(2, 1fr); 247 | } 248 | @media screen and (min-width: 1024px) { 249 | .cart-total-content { 250 | grid-column: 4 / span 2; 251 | } 252 | } 253 | .cart-total-column p { 254 | padding: 10px; 255 | margin: 0; 256 | text-align: right; 257 | } 258 | .cart-total-column p:last-child { 259 | font-weight: 700; 260 | background: #f2eee2; 261 | } 262 | .product-page { 263 | margin: 60px 0; 264 | } 265 | .product-page-content { 266 | width: 80%; 267 | margin: 30px auto 0; 268 | } 269 | @media screen and (min-width: 1024px) { 270 | .product-page-content { 271 | display: grid; 272 | justify-content: space-between; 273 | justify-items: center; 274 | align-items: center; 275 | grid-template-columns: 1fr 1fr; 276 | grid-column-gap: 30px; 277 | } 278 | } 279 | .product-page-image { 280 | width: 100%; 281 | margin-bottom: 30px; 282 | } 283 | @media screen and (min-width: 1024px) { 284 | .product-page-image { 285 | width: 100%; 286 | margin-bottom: 0; 287 | } 288 | } 289 | .product-page-price { 290 | color: #d96528; 291 | font-size: 1.2rem; 292 | margin: 5px 0; 293 | font-weight: 400; 294 | font-family: Domine, 'PT Serif', -apple-system, BlinkMacSystemFont, Segoe UI, 295 | Roboto, Helvetica Neue, Arial, sans-serif; 296 | } 297 | .product-page-price-list, 298 | .product-page-price.is-solo { 299 | margin-bottom: 30px; 300 | } 301 | .product-page-quantity-input { 302 | width: 70px; 303 | } 304 | .product-page-quantity-row { 305 | display: flex; 306 | } 307 | .home-page { 308 | margin: 30px 0 45px; 309 | } 310 | .product-grid { 311 | max-width: 60vw; 312 | margin: 0 auto; 313 | display: grid; 314 | grid-template-columns: 1fr; 315 | grid-template-rows: 1fr; 316 | grid-column-gap: 30px; 317 | grid-row-gap: 30px; 318 | } 319 | @media screen and (min-width: 640px) { 320 | .product-grid { 321 | grid-template-columns: repeat(2, 1fr); 322 | } 323 | } 324 | @media screen and (min-width: 1024px) { 325 | .product-grid { 326 | grid-template-columns: repeat(3, 1fr); 327 | } 328 | } 329 | @media screen and (min-width: 1280px) { 330 | .product-grid { 331 | grid-template-columns: repeat(4, 1fr); 332 | } 333 | } 334 | .product-card { 335 | display: flex; 336 | justify-content: space-between; 337 | align-items: center; 338 | flex-direction: column; 339 | } 340 | .product-card-description { 341 | margin-top: 0; 342 | margin-bottom: 1rem; 343 | overflow: hidden; 344 | width: 100%; 345 | display: -webkit-box; 346 | -webkit-box-orient: vertical; 347 | -webkit-line-clamp: 2; 348 | } 349 | .product-card-frame { 350 | height: 120px; 351 | margin-bottom: 0.5rem; 352 | display: flex; 353 | align-content: center; 354 | align-items: center; 355 | border-radius: 10px; 356 | overflow: hidden; 357 | } 358 | .product-card-frame img { 359 | width: 100%; 360 | border-radius: 10px; 361 | -o-object-fit: cover; 362 | object-fit: cover; 363 | height: 100%; 364 | } 365 | .product-card-text { 366 | margin: 0.5rem 0; 367 | } 368 | .product-card-title { 369 | margin: 0.5rem 0; 370 | text-align: center; 371 | font-weight: 700; 372 | } 373 | --------------------------------------------------------------------------------