├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── RULES.md ├── TODO.md ├── bun.lockb ├── nextjs ├── .env.example ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vercelignore ├── .vscode │ └── snippets.code-snippets ├── LICENSE ├── README.md ├── _developer │ ├── tomlWriter.js │ ├── types │ │ ├── 2024-07 │ │ │ └── webhooks.js │ │ ├── toml.js │ │ └── webhookTopics.js │ ├── updateDashboard.js │ └── webhookWriter.js ├── components │ ├── .gitkeep │ ├── home │ │ ├── SetupGuide.module.css │ │ └── SetupGuideComponent.jsx │ ├── hooks │ │ └── .gitkeep │ └── providers │ │ └── AppBridgeProvider.jsx ├── docs │ ├── EXTENSIONS.md │ ├── NOTES.md │ ├── SETUP.md │ ├── SNIPPETS.md │ └── migration │ │ ├── app-bridge-cdn.md │ │ ├── clientProvider.md │ │ ├── managed-webhooks.md │ │ └── oauth-to-managed-installation.md ├── jsconfig.json ├── middleware.js ├── next.config.js ├── package.json ├── pages │ ├── _app.js │ ├── _document.js │ ├── api │ │ ├── apps │ │ │ ├── debug │ │ │ │ ├── activeWebhooks.js │ │ │ │ ├── createNewSubscription.js │ │ │ │ ├── getActiveSubscriptions.js │ │ │ │ ├── gql.js │ │ │ │ └── index.js │ │ │ └── index.js │ │ ├── gdpr │ │ │ ├── customers_data_request.js │ │ │ ├── customers_redact.js │ │ │ └── shop_redact.js │ │ ├── graphql.js │ │ ├── index.js │ │ ├── proxy_route │ │ │ ├── json.js │ │ │ └── wishlist │ │ │ │ └── add_product.js │ │ └── webhooks │ │ │ └── [...webhookTopic].js │ ├── debug │ │ ├── billing.jsx │ │ ├── data.jsx │ │ ├── index.jsx │ │ ├── resourcePicker.jsx │ │ └── webhooks.jsx │ ├── index.jsx │ └── info.jsx ├── prisma │ └── schema.prisma ├── public │ └── .gitkeep ├── utils │ ├── clientProvider.js │ ├── cryption.js │ ├── freshInstall.js │ ├── middleware │ │ ├── isInitialLoad.js │ │ ├── verifyHmac.js │ │ ├── verifyProxy.js │ │ ├── verifyRequest.js │ │ └── withMiddleware.js │ ├── prisma.js │ ├── sessionHandler.js │ ├── setupCheck.js │ ├── shopify.js │ ├── validateJWT.js │ └── webhooks │ │ ├── app_uninstalled.js │ │ └── orders.js ├── zz.html └── zz.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | package-lock.json 4 | .DS_Store 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | dist 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | .cache 111 | 112 | # Docusaurus cache and generated files 113 | .docusaurus 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # Stores VSCode versions used for testing VSCode extensions 128 | .vscode-test 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* 136 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ## Prettier will ignore following files and directories 2 | .next/ 3 | mysql/ 4 | mongo/ 5 | database/ 6 | postgres/ 7 | node_modules/ 8 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": false, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Harshdeep Singh Hura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wishlist App 2 | 3 | A Wishlist app built thrice with Express, Remix and Next.js, and Shopify Extensions. 4 | 5 | ## Goals 6 | 7 | - Build with 3 frameworks 8 | - [ ] Next.js (Prisma ORM + MySQL) 9 | - [ ] Express.js (Mongoose + MongoDB) 10 | - [ ] Remix (Prisma ORM + PostgreSQL) 11 | - Extension Points 12 | - [ ] Theme App Extension 13 | - [ ] Customer Account Extensions 14 | - [ ] Checkout UI Extensions 15 | - [ ] Post Purchase Extensions 16 | - [ ] Shopify Functions 17 | - [ ] Point of Sale Extensions 18 | - [ ] Shopify Flow 19 | 20 | ## Notes 21 | 22 | - Read more on [TODO](./TODO.md) 23 | -------------------------------------------------------------------------------- /RULES.md: -------------------------------------------------------------------------------- 1 | # Rules 2 | 3 | ## Deadlines 4 | 5 | - [ ] Nextjs: September 30th (Estimated) 6 | - [ ] Extensions: September 30th (Estimated) 7 | - [ ] Express: TBD 8 | - [ ] Remix: TBD 9 | 10 | ## Dev 11 | 12 | - Most of the development should be on a live stream 13 | - No contributions / PRs until the base app and extensions are done 14 | - MIT license 15 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # To Do 2 | 3 | ## Base Feature Set 4 | 5 | - [ ] Allow customers to create one or multiple wishlists 6 | - [ ] Merchant to see what a certain customer has on their wishlist 7 | - Wishlist endpoints 8 | - [ ] Product Page 9 | - [ ] Collection Page 10 | - [ ] Cart (save for later) 11 | - [ ] Give customization for the button like an icon, icon with text or text only 12 | - [ ] Analytics + Dash 13 | - [ ] No guest wishlists, customers need to be logged in 14 | - [ ] Email reminders (Shopify Flow) 15 | - [ ] Restock alert (Shopify Flow) 16 | - [ ] Sharing wishlists 17 | - Shareable wishlists 18 | - [ ] Share wishlists with friends and family 19 | - [ ] Allow people to buy stuff for each other via the shared wishlist 20 | - Timed discounts for customers 21 | - [ ] Discount via Shopify Functions for items that are cleared for wishlist discounting 22 | 23 | ## Build with 3 frameworks 24 | 25 | - [ ] Next.js (Prisma ORM + MySQL) 26 | - [ ] Express.js (Mongoose + MongoDB) 27 | - [ ] Remix (Prisma ORM + PostgreSQL) 28 | 29 | ## Extension Points 30 | 31 | - [ ] Theme App Extension 32 | - [ ] Customer Account Extensions 33 | - [ ] Checkout UI Extensions 34 | - [ ] Post Purchase Extensions 35 | - [ ] Shopify Functions 36 | - [ ] Point of Sale Extensions 37 | - [ ] Admin Block Extensions 38 | - [ ] Shopify Flow 39 | 40 | ## Maybes 41 | 42 | ### Integrations 43 | 44 | - [ ] Shopify Search and Discovery - filter collections with wishlist items only 45 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinngh/shopify-wishlist-live-app/8f0ec8ac4fd884e614a876625992d0626f58d4af/bun.lockb -------------------------------------------------------------------------------- /nextjs/.env.example: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_KEY= 2 | SHOPIFY_API_SECRET= 3 | SHOPIFY_API_SCOPES=write_products,write_customers,write_orders 4 | SHOPIFY_APP_URL=https://ngrok-url.io 5 | SHOPIFY_API_VERSION="2024-07" 6 | DATABASE_URL="postgres://username:password@127.0.0.1:5432/shopify-app?schema=public" 7 | ENCRYPTION_STRING= 8 | 9 | ## App Details 10 | # App's name as in your Partner dashboard. Ex: "My App" 11 | APP_NAME= 12 | # App's URL that you want in the store. No spaces, use `-` instead. Ex: "my-app" 13 | APP_HANDLE= 14 | 15 | ## App Proxy 16 | APP_PROXY_PREFIX="a" 17 | APP_PROXY_SUBPATH="wishlist" 18 | # Prefix can be `apps`, `a`, `community` or `tools`. Any other value will yield in errors. 19 | # Proxy URL is autofilled 20 | # If `APP_PROXY_PREFIX` or `APP_PROXY_SUBPATH` is left blank, no app proxy entry is created 21 | 22 | ## Point of Sale 23 | POS_EMBEDDED=false 24 | 25 | ## Access 26 | DIRECT_API_MODE= 27 | # Direct API Mode can be either `online` or `offline` 28 | EMBEDDED_APP_DIRECT_API_ACCESS= 29 | # Embedded app direct api access mode is either true or false. 30 | # Read more about direct api access here: 31 | # https://shopify.dev/docs/api/admin-extensions#direct-api-access 32 | # No entries are created if left blank 33 | 34 | # To quickly install the app on your store, use this URL: 35 | # https://storename.myshopify.com/admin/oauth/install?client_id=SHOPIFY_API_KEY 36 | 37 | ## Notes: 38 | # Ensure SHOPIFY_APP_URL starts with `https://`. 39 | # When deploying to Vercel, don't wrap any values in quotes. 40 | # Despite what anyone tells you, updating anything in the `env` requires a restart of the dev server. -------------------------------------------------------------------------------- /nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | ## Dev only files and folders 4 | # _developer/ 5 | database/ 6 | shopify.*.toml 7 | 8 | # dependencies 9 | package-lock.json 10 | /node_modules 11 | bun.lockb 12 | /.pnp 13 | .pnp.js 14 | 15 | # testing 16 | /coverage 17 | 18 | # next.js 19 | /.next/ 20 | /out/ 21 | 22 | # production 23 | /build 24 | 25 | # misc 26 | .DS_Store 27 | *.pem 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | .pnpm-debug.log* 34 | 35 | # local env files 36 | .env*.local 37 | .env 38 | 39 | # vercel 40 | .vercel 41 | -------------------------------------------------------------------------------- /nextjs/.prettierignore: -------------------------------------------------------------------------------- 1 | ## Prettier will ignore following files and directories 2 | .next/ 3 | mysql/ 4 | mongo/ 5 | database/ 6 | postgres/ 7 | node_modules/ 8 | package-lock.json -------------------------------------------------------------------------------- /nextjs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": false, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /nextjs/.vercelignore: -------------------------------------------------------------------------------- 1 | _developer/ -------------------------------------------------------------------------------- /nextjs/.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Arrow Function Component": { 3 | "prefix": "sfc", 4 | "body": [ 5 | "const $1 = ($2) => {", 6 | " return ( $0 );", 7 | "}", 8 | " ", 9 | "export default $1;", 10 | ], 11 | "description": "Arrow Function Component", 12 | }, 13 | 14 | "Polaris Page": { 15 | "prefix": "createNewPage", 16 | "body": [ 17 | "import { BlockStack, InlineStack, Button, Card, Layout, Page, Text } from \"@shopify/polaris\";", 18 | "import { useRouter } from \"next/router\";", 19 | "", 20 | "const $1 = () => {", 21 | " const router = useRouter();", 22 | " return (", 23 | " <>", 24 | " {", 28 | " router.push('/');", 29 | " },", 30 | " }}", 31 | " >", 32 | " ", 33 | " ", 34 | " ", 35 | " ", 36 | " Heading", 37 | " Regular Text Content", 38 | " ", 39 | " {", 42 | " alert('Button pressed');", 43 | " }}", 44 | " >", 45 | " Button", 46 | " ", 47 | " ", 48 | " ", 49 | " ", 50 | " ", 51 | " ", 52 | " ", 53 | " ", 54 | " );", 55 | "};", 56 | "", 57 | "export default $1;", 58 | ], 59 | "description": "Create a new page with navigation and layout components from Polaris.", 60 | }, 61 | 62 | "api/ route with middleware": { 63 | "prefix": "createapi", 64 | "body": [ 65 | "import withMiddleware from \"@/utils/middleware/withMiddleware.js\";", 66 | "", 67 | "/**", 68 | "* @param {import(\"next\").NextApiRequest} req - The HTTP request object.", 69 | "* @param {import(\"next\").NextApiResponse} res - The HTTP response object.", 70 | "*/", 71 | "const $1 = async (req, res) => {", 72 | " if (req.method !== \"GET\") {", 73 | " //GET, POST, PUT, DELETE", 74 | " console.log(\"Serve this only if the request method is GET\");", 75 | " return res.status(405).send({ error: true });", 76 | " }", 77 | "", 78 | " try {", 79 | " $3", 80 | " return res.status(200).send({ text: \"Success!\" });", 81 | " } catch (e) {", 82 | " console.error(`---> An error occured at /api/apps/${2:ROUTE} : ${e.message}`,e);", 83 | " return res.status(403).send({ error: true });", 84 | " }", 85 | "};", 86 | "", 87 | "export default withMiddleware(\"verifyRequest\")($1);", 88 | ], 89 | "description": "api/ route with middleware", 90 | }, 91 | 92 | "app_proxy/ route with middleware": { 93 | "prefix": "createproxy", 94 | "body": [ 95 | "import withMiddleware from \"@/utils/middleware/withMiddleware.js\";", 96 | "", 97 | "/**", 98 | "* @param {import(\"next\").NextApiRequest} req - The HTTP request object.", 99 | "* @param {import(\"next\").NextApiResponse} res - The HTTP response object.", 100 | "*/", 101 | "const $1 = async (req, res) => {", 102 | " if (req.method !== \"GET\") {", 103 | " //GET, POST, PUT, DELETE", 104 | " console.log(\"Serve this request only if method type is GET\");", 105 | " return res.status(405).send({ error: true });", 106 | " }", 107 | " try {", 108 | " $3", 109 | " res.status(200).send({ content: \"Proxy Be Working\" });", 110 | " } catch (e) {", 111 | " console.error(`---> An error occured in /api/proxy_route/${2:ROUTE} :${e.message}`,e);", 112 | " return res.status(403).send({ error: true });", 113 | " }", 114 | "};", 115 | "", 116 | "export default withMiddleware(\"verifyProxy\")($1);", 117 | ], 118 | "description": "app_proxy/ route with middleware", 119 | }, 120 | 121 | "Webhook function": { 122 | "prefix": "createwebhook", 123 | "body": [ 124 | "/**", 125 | "* Replace TOPIC_NAME with a Webhook Topic to enable autocomplete", 126 | "* @typedef { import(\"@/_developer/types/2024-07/webhooks.js\").${2:TOPIC_NAME} } webhookTopic", 127 | "*/", 128 | "", 129 | "const $1 = async (topic, shop, webhookRequestBody, webhookId, apiVersion) => {", 130 | " try {", 131 | " /** @type {webhookTopic} */", 132 | " const webhookBody = JSON.parse(webhookRequestBody);", 133 | " $3", 134 | " } catch (e) {", 135 | " console.error(e);", 136 | " }", 137 | "};", 138 | "", 139 | "export default $1;", 140 | ], 141 | "description": "Webhook function", 142 | }, 143 | 144 | "GraphQL Client Provider": { 145 | "prefix": "createOnlineClientGql", 146 | "body": [ 147 | "// import clientProvider from \"@/utils/clientProvider\";", 148 | "", 149 | " const { client } = await clientProvider.online.graphqlClient({", 150 | " req,", 151 | " res,", 152 | " });", 153 | "", 154 | " const response = await client.request(", 155 | " `{}`, //Paste your GraphQL query/mutation here", 156 | " );", 157 | ], 158 | "description": "GraphQL Client Provider", 159 | }, 160 | "Offline GraphQL Client Provider": { 161 | "prefix": "createOfflineClientGql", 162 | "body": [ 163 | "// import clientProvider from \"@/utils/clientProvider\";", 164 | "", 165 | " const { client } = await clientProvider.offline.graphqlClient({", 166 | " shop: req.user_shop,", 167 | " });", 168 | "", 169 | " const response = await client.request(", 170 | " `{}`, //Paste your GraphQL query/mutation here", 171 | " );", 172 | ], 173 | "description": "Offline GraphQL Client Provider", 174 | }, 175 | "POST request": { 176 | "prefix": "createpost", 177 | "body": [ 178 | "const $1 = await(", 179 | "await fetch(\"/api/apps/$2\", {", 180 | "headers: {", 181 | " Accept: \"application/json\",", 182 | " \"Content-Type\": \"application/json\",", 183 | " },", 184 | " method: \"POST\",", 185 | " body: JSON.stringify(${3:body}),", 186 | "})", 187 | ").json();", 188 | ], 189 | "description": "Creates a new POST fetch request", 190 | }, 191 | "GET request": { 192 | "prefix": "createget", 193 | "body": [ 194 | "const $1 = await(", 195 | "await fetch(\"/api/apps/$2\", {", 196 | " method: \"GET\",", 197 | "})", 198 | ").json();", 199 | ], 200 | "description": "Creates a new GET fetch request", 201 | }, 202 | } 203 | -------------------------------------------------------------------------------- /nextjs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Harshdeep Singh Hura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /nextjs/README.md: -------------------------------------------------------------------------------- 1 | # Shopify Next.js x Prisma Boilerplate 2 | 3 | - Base repo is available at [@kinngh/shopify-nextjs-prisma-app](https://github.com/kinngh/shopify-nextjs-prisma-app) 4 | 5 | ## Notes 6 | 7 | - Refer to [SETUP](/docs/SETUP.md) 8 | - The project comes with snippets to speed up development. Refer to [Snippets](/docs/SNIPPETS.md). 9 | - App Bridge CDN migration guide is available [here](/docs/migration/app-bridge-cdn.md) 10 | - Shopify Managed Installation migration guide is available [here](/docs/migration/oauth-to-managed-installation.md) 11 | - Client Provider abstraction update guide is available [here](/docs/migration/clientProvider.md) 12 | - GraphQL to Managed Webhooks migration guide is available [here](/docs/migration/managed-webhooks.md) 13 | -------------------------------------------------------------------------------- /nextjs/_developer/tomlWriter.js: -------------------------------------------------------------------------------- 1 | import toml from "@iarna/toml"; 2 | import "dotenv/config"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import setupCheck from "../utils/setupCheck.js"; 6 | import webhookWriter from "./webhookWriter.js"; 7 | 8 | /** @typedef {import("@/_developer/types/toml.js").AppConfig} Config */ 9 | 10 | /** @type {Config} */ 11 | let config = {}; 12 | 13 | try { 14 | setupCheck(); //Run setup check to ensure all env variables are accessible 15 | 16 | let appUrl = process.env.SHOPIFY_APP_URL; 17 | if (appUrl.endsWith("/")) { 18 | appUrl = appUrl.slice(0, -1); 19 | } 20 | // Globals 21 | config.name = process.env.APP_NAME; 22 | config.handle = process.env.APP_HANDLE; 23 | config.client_id = process.env.SHOPIFY_API_KEY; 24 | config.application_url = appUrl; 25 | config.embedded = true; 26 | config.extension_directories = ["../extension/extensions/*"]; 27 | 28 | // Auth 29 | config.auth = {}; 30 | config.auth.redirect_urls = [`${appUrl}/api/`]; 31 | // Scopes 32 | config.access_scopes = {}; 33 | config.access_scopes.scopes = process.env.SHOPIFY_API_SCOPES; 34 | config.access_scopes.use_legacy_install_flow = false; 35 | 36 | if ( 37 | process.env.DIRECT_API_MODE && 38 | process.env.EMBEDDED_APP_DIRECT_API_ACCESS 39 | ) { 40 | // Access 41 | config.access = {}; 42 | config.access.admin = {}; 43 | process.env.DIRECT_API_MODE 44 | ? (config.access.admin.direct_api_mode = process.env.DIRECT_API_MODE) 45 | : null; 46 | process.env.EMBEDDED_APP_DIRECT_API_ACCESS 47 | ? (config.access.admin.embedded_app_direct_api_access = 48 | process.env.EMBEDDED_APP_DIRECT_API_ACCESS === "true") 49 | : null; 50 | } 51 | 52 | // Webhook event version to always match the app API version 53 | config.webhooks = {}; 54 | config.webhooks.api_version = process.env.SHOPIFY_API_VERSION; 55 | 56 | // Webhooks 57 | webhookWriter(config); 58 | 59 | // GDPR URLs 60 | config.webhooks.privacy_compliance = {}; 61 | config.webhooks.privacy_compliance.customer_data_request_url = `${appUrl}/api/gdpr/customers_data_request`; 62 | config.webhooks.privacy_compliance.customer_deletion_url = `${appUrl}/api/gdpr/customers_redact`; 63 | config.webhooks.privacy_compliance.shop_deletion_url = `${appUrl}/api/gdpr/shop_redact`; 64 | 65 | // App Proxy 66 | if ( 67 | process.env.APP_PROXY_PREFIX?.length > 0 && 68 | process.env.APP_PROXY_SUBPATH?.length > 0 69 | ) { 70 | config.app_proxy = {}; 71 | config.app_proxy.url = `${appUrl}/api/proxy_route`; 72 | config.app_proxy.prefix = process.env.APP_PROXY_PREFIX; 73 | config.app_proxy.subpath = process.env.APP_PROXY_SUBPATH; 74 | } 75 | 76 | // PoS 77 | if (process.env.POS_EMBEDDED?.length > 1) { 78 | config.pos = {}; 79 | config.pos.embedded = process.env.POS_EMBEDDED === "true"; 80 | } 81 | 82 | //Build 83 | config.build = {}; 84 | config.build.include_config_on_deploy = true; 85 | 86 | //Write to toml 87 | let str = toml.stringify(config); 88 | str = 89 | "# Avoid writing to toml directly. Use your .env file instead\n\n" + str; 90 | 91 | fs.writeFileSync(path.join(process.cwd(), "shopify.app.toml"), str, (err) => { 92 | if (err) { 93 | console.log("An error occured while writing to file", e); 94 | return; 95 | } 96 | 97 | console.log("Written TOML to root"); 98 | return; 99 | }); 100 | 101 | const extensionsDir = path.join("..", "extension"); 102 | 103 | config.extension_directories = ["./extensions/*"]; 104 | let extensionStr = toml.stringify(config); 105 | extensionStr = 106 | "# Avoid writing to toml directly. Use your .env file instead\n\n" + 107 | extensionStr; 108 | 109 | config.extension_directories = ["extension/extensions/*"]; 110 | let globalStr = toml.stringify(config); 111 | globalStr = 112 | "# Avoid writing to toml directly. Use your .env file instead\n\n" + 113 | globalStr; 114 | 115 | if (fs.existsSync(extensionsDir)) { 116 | fs.writeFileSync( 117 | path.join(process.cwd(), "..", "shopify.app.toml"), 118 | globalStr, 119 | (err) => { 120 | if (err) { 121 | console.log("An error occured while writing to file", e); 122 | return; 123 | } 124 | 125 | console.log("Written TOML to root"); 126 | return; 127 | } 128 | ); 129 | 130 | fs.writeFileSync( 131 | path.join(extensionsDir, "shopify.app.toml"), 132 | extensionStr, 133 | (err) => { 134 | if (err) { 135 | console.log("An error occured while writing to file", e); 136 | return; 137 | } 138 | 139 | console.log("Written TOML to extension"); 140 | return; 141 | } 142 | ); 143 | } 144 | } catch (e) { 145 | console.error("---> An error occured while writing toml files"); 146 | console.log(e.message); 147 | } 148 | -------------------------------------------------------------------------------- /nextjs/_developer/types/toml.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Most of the `Build` config is ommited on purpose since we're not using CLI to run dev 4 | * Read about config more on: 5 | * https://shopify.dev/docs/apps/tools/cli/configuration 6 | * 7 | */ 8 | 9 | /** 10 | * Configuration for the Shopify app 11 | * @typedef {Object} AppConfig 12 | * @property {string} name - The name of the application. 13 | * @property {string} handle - A unique handle for the application. 14 | * @property {string} client_id - The client ID for OAuth authentication. 15 | * @property {string[]} application_url - The base URL for the application. 16 | * @property {string[]} extension_directories - The location of extension directory. 17 | * @property {boolean} embedded - Indicates if the app is to be embedded within a platform. 18 | * 19 | * @property {AccessScopes} access_scopes - The access scopes required by the application. 20 | * @property {AccessConfig} access - The access scopes required by the application. 21 | * @property {AuthConfig} auth - Authentication configuration details. 22 | * @property {WebhooksConfig} webhooks - Configuration for webhooks. 23 | * @property {AppProxyConfig} app_proxy - Configuration for application proxy. 24 | * @property {POSConfig} pos - Point of Sale configuration. 25 | * @property {PreferencesConfig} preferences - App preferences configuration. 26 | * @property {BuildConfig} build - Build configuration. 27 | */ 28 | 29 | /** 30 | * Access scopes 31 | * @typedef {Object} AccessScopes 32 | * @property {string} scopes - The scopes required for accessing resources. 33 | * @property {boolean} use_legacy_install_flow - Indicates if the legacy install flow should be used. 34 | */ 35 | 36 | /** 37 | * Access config for Shopify APIs 38 | * @typedef {Object} AccessConfig 39 | * @property {('online'|'offline')} direct_api_mode - Access mode that direct api access wil use. 40 | * @property {boolean} embedded_app_direct_api_access - Whether your embedded app has access to direct api access for calling admin Graphql APIs 41 | */ 42 | 43 | /** 44 | * Authentication configuration 45 | * @typedef {Object} AuthConfig 46 | * @property {string[]} redirect_urls - URLs to which the user can be redirected after authentication. 47 | */ 48 | 49 | /** 50 | * Webhook configuration 51 | * @typedef {Object} WebhooksConfig 52 | * @property {('2024-07' | '2024-04' | '2024-01' | '2023-10')} api_version - The API version to be used for webhooks. 53 | * @property {WebhookSubscription[]} subscriptions - Array of webhook subscriptions. 54 | * @property {PrivacyComplianceConfig} privacy_compliance - Configuration for privacy compliance. 55 | */ 56 | 57 | /** 58 | * Webhook subscription 59 | * @typedef {Object} WebhookSubscription 60 | * @property {string[]} topics - Array of webhook topics to subscribe to. 61 | * @property {string} url - The URL to receive webhook events. 62 | * @property {string} [filter] - Optional filter for webhook events. 63 | * @property {string[]} [include_fields] - Optional array of fields to include in the webhook payload. 64 | */ 65 | 66 | /** 67 | * GDPR Strings 68 | * @typedef {Object} PrivacyComplianceConfig 69 | * @property {string} customer_deletion_url - GPDR route to customer deletion url 70 | * @property {string} customer_data_request_url - GPDR route to customer data request url 71 | * @property {string} shop_deletion_url - GPDR route to shop deletion url 72 | 73 | */ 74 | 75 | /** 76 | * App proxy 77 | * @typedef {Object} AppProxyConfig 78 | * @property {string} url - The base URL for the app proxy. 79 | * @property {string} subpath - The subpath at which the app proxy is accessible. 80 | * @property {('apps' | 'a' | 'community' | 'tools' )} prefix - The prefix used for the app proxy routes. 81 | */ z; 82 | 83 | /** 84 | * Point of Sale (POS) configuration 85 | * @typedef {Object} POSConfig 86 | * @property {boolean} embedded - Indicates if the POS app is to be embedded within a platform. 87 | */ 88 | 89 | /** 90 | * Preferences configuration 91 | * @typedef {Object} PreferencesConfig 92 | * @property {boolean} url - URL for your app's preferences page 93 | */ 94 | 95 | /** 96 | * Preferences configuration 97 | * @typedef {Object} BuildConfig 98 | * @property {boolean} include_config_on_deploy - Includes the toml file when deploying to Shopify 99 | */ 100 | 101 | export {}; 102 | -------------------------------------------------------------------------------- /nextjs/_developer/types/webhookTopics.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} WebhookTopics 3 | * @property {Array<( 4 | * 'app/uninstalled' | 5 | * 'app_purchases_one_time/update' | 6 | * 'app_subscriptions/approaching_capped_amount' | 7 | * 'app_subscriptions/update' | 8 | * 'bulk_operations/finish' | 9 | * 'carts/create' | 10 | * 'carts/update' | 11 | * 'channels/delete' | 12 | * 'checkouts/create' | 13 | * 'checkouts/delete' | 14 | * 'checkouts/update' | 15 | * 'collection_listings/add' | 16 | * 'collection_listings/remove' | 17 | * 'collection_listings/update' | 18 | * 'collection_publications/create' | 19 | * 'collection_publications/delete' | 20 | * 'collection_publications/update' | 21 | * 'collections/create' | 22 | * 'collections/delete' | 23 | * 'collections/update' | 24 | * 'companies/create' | 25 | * 'companies/delete' | 26 | * 'companies/update' | 27 | * 'company_contact_roles/assign' | 28 | * 'company_contact_roles/revoke' | 29 | * 'company_contacts/create' | 30 | * 'company_contacts/delete' | 31 | * 'company_contacts/update' | 32 | * 'company_locations/create' | 33 | * 'company_locations/delete' | 34 | * 'company_locations/update' | 35 | * 'customer/tags_added' | 36 | * 'customer/tags_removed' | 37 | * 'customer_account_settings/update' | 38 | * 'customer_groups/create' | 39 | * 'customer_groups/delete' | 40 | * 'customer_groups/update' | 41 | * 'customer_payment_methods/create' | 42 | * 'customer_payment_methods/revoke' | 43 | * 'customer_payment_methods/update' | 44 | * 'customers/create' | 45 | * 'customers/data_request' | 46 | * 'customers/delete' | 47 | * 'customers/disable' | 48 | * 'customers/enable' | 49 | * 'customers/merge' | 50 | * 'customers/redact' | 51 | * 'customers/update' | 52 | * 'customers_email_marketing_consent/update' | 53 | * 'customers_marketing_consent/update' | 54 | * 'discounts/create' | 55 | * 'discounts/delete' | 56 | * 'discounts/redeemcode_added' | 57 | * 'discounts/redeemcode_removed' | 58 | * 'discounts/update' | 59 | * 'disputes/create' | 60 | * 'disputes/update' | 61 | * 'domains/create' | 62 | * 'domains/destroy' | 63 | * 'domains/update' | 64 | * 'draft_orders/create' | 65 | * 'draft_orders/delete' | 66 | * 'draft_orders/update' | 67 | * 'fulfillment_events/create' | 68 | * 'fulfillment_events/delete' | 69 | * 'fulfillment_orders/cancellation_request_accepted' | 70 | * 'fulfillment_orders/cancellation_request_rejected' | 71 | * 'fulfillment_orders/cancellation_request_submitted' | 72 | * 'fulfillment_orders/cancelled' | 73 | * 'fulfillment_orders/fulfillment_request_accepted' | 74 | * 'fulfillment_orders/fulfillment_request_rejected' | 75 | * 'fulfillment_orders/fulfillment_request_submitted' | 76 | * 'fulfillment_orders/fulfillment_service_failed_to_complete' | 77 | * 'fulfillment_orders/hold_released' | 78 | * 'fulfillment_orders/line_items_prepared_for_local_delivery' | 79 | * 'fulfillment_orders/line_items_prepared_for_pickup' | 80 | * 'fulfillment_orders/merged' | 81 | * 'fulfillment_orders/moved' | 82 | * 'fulfillment_orders/order_routing_complete' | 83 | * 'fulfillment_orders/placed_on_hold' | 84 | * 'fulfillment_orders/rescheduled' | 85 | * 'fulfillment_orders/scheduled_fulfillment_order_ready' | 86 | * 'fulfillment_orders/split' | 87 | * 'fulfillments/create' | 88 | * 'fulfillments/update' | 89 | * 'inventory_items/create' | 90 | * 'inventory_items/delete' | 91 | * 'inventory_items/update' | 92 | * 'inventory_levels/connect' | 93 | * 'inventory_levels/disconnect' | 94 | * 'inventory_levels/update' | 95 | * 'locales/create' | 96 | * 'locales/update' | 97 | * 'locations/activate' | 98 | * 'locations/create' | 99 | * 'locations/deactivate' | 100 | * 'locations/delete' | 101 | * 'locations/update' | 102 | * 'markets/create' | 103 | * 'markets/delete' | 104 | * 'markets/update' | 105 | * 'metaobjects/create' | 106 | * 'metaobjects/delete' | 107 | * 'metaobjects/update' | 108 | * 'order_transactions/create' | 109 | * 'orders/cancelled' | 110 | * 'orders/create' | 111 | * 'orders/delete' | 112 | * 'orders/edited' | 113 | * 'orders/fulfilled' | 114 | * 'orders/paid' | 115 | * 'orders/partially_fulfilled' | 116 | * 'orders/risk_assessment_changed' | 117 | * 'orders/shopify_protect_eligibility_changed' | 118 | * 'orders/updated' | 119 | * 'payment_schedules/due' | 120 | * 'payment_terms/create' | 121 | * 'payment_terms/delete' | 122 | * 'payment_terms/update' | 123 | * 'product_feeds/create' | 124 | * 'product_feeds/update' | 125 | * 'product_listings/add' | 126 | * 'product_listings/remove' | 127 | * 'product_listings/update' | 128 | * 'product_publications/create' | 129 | * 'product_publications/delete' | 130 | * 'product_publications/update' | 131 | * 'products/create' | 132 | * 'products/delete' | 133 | * 'products/update' | 134 | * 'profiles/create' | 135 | * 'profiles/delete' | 136 | * 'profiles/update' | 137 | * 'refunds/create' | 138 | * 'returns/approve' | 139 | * 'returns/cancel' | 140 | * 'returns/close' | 141 | * 'returns/decline' | 142 | * 'returns/reopen' | 143 | * 'returns/request' | 144 | * 'returns/update' | 145 | * 'reverse_deliveries/attach_deliverable' | 146 | * 'reverse_fulfillment_orders/dispose' | 147 | * 'scheduled_product_listings/add' | 148 | * 'scheduled_product_listings/remove' | 149 | * 'scheduled_product_listings/update' | 150 | * 'segments/create' | 151 | * 'segments/delete' | 152 | * 'segments/update' | 153 | * 'selling_plan_groups/create' | 154 | * 'selling_plan_groups/delete' | 155 | * 'selling_plan_groups/update' | 156 | * 'shop/redact' | 157 | * 'shop/update' | 158 | * 'subscription_billing_attempts/challenged' | 159 | * 'subscription_billing_attempts/failure' | 160 | * 'subscription_billing_attempts/success' | 161 | * 'subscription_billing_cycle_edits/create' | 162 | * 'subscription_billing_cycle_edits/delete' | 163 | * 'subscription_billing_cycle_edits/update' | 164 | * 'subscription_billing_cycles/skip' | 165 | * 'subscription_billing_cycles/unskip' | 166 | * 'subscription_contracts/activate' | 167 | * 'subscription_contracts/cancel' | 168 | * 'subscription_contracts/create' | 169 | * 'subscription_contracts/expire' | 170 | * 'subscription_contracts/fail' | 171 | * 'subscription_contracts/pause' | 172 | * 'subscription_contracts/update' | 173 | * 'tender_transactions/create' | 174 | * 'themes/create' | 175 | * 'themes/delete' | 176 | * 'themes/publish' | 177 | * 'themes/update' | 178 | * 'variants/in_stock' | 179 | * 'variants/out_of_stock' 180 | * )>} topic - Topic of the webhook 181 | */ 182 | 183 | export {}; 184 | -------------------------------------------------------------------------------- /nextjs/_developer/updateDashboard.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | DEV ONLY --> `npm run update:url` 4 | 5 | LIMITATION: 6 | - [OEM] Cannot update GDPR URLs. 7 | - [OEM] Cannot update App Proxy URL. 8 | - May break with a future update to `@shopify/cli-kit`. 9 | */ 10 | 11 | /* 12 | 13 | import { partnersRequest } from "@shopify/cli-kit/node/api/partners"; 14 | import { ensureAuthenticatedPartners } from "@shopify/cli-kit/node/session"; 15 | import { renderSelectPrompt } from "@shopify/cli-kit/node/ui"; 16 | import "dotenv/config"; 17 | 18 | const UpdateAppURLQuery = ` 19 | mutation appUpdate($apiKey: String!, $applicationUrl: Url!, $redirectUrlWhitelist: [Url]!) { 20 | appUpdate(input: {apiKey: $apiKey, applicationUrl: $applicationUrl, redirectUrlWhitelist: $redirectUrlWhitelist}) { 21 | userErrors { 22 | message 23 | field 24 | } 25 | } 26 | }`; 27 | 28 | const FindAppQuery = ` 29 | query FindApp($apiKey: String!) { 30 | app(apiKey: $apiKey) { 31 | id 32 | title 33 | apiKey 34 | organizationId 35 | apiSecretKeys { 36 | secret 37 | } 38 | appType 39 | grantedScopes 40 | } 41 | }`; 42 | 43 | const AllOrganizationsQuery = ` 44 | { 45 | organizations(first: 200) { 46 | nodes { 47 | id 48 | businessName 49 | website 50 | } 51 | } 52 | }`; 53 | 54 | const selectOrg = async (accessToken) => { 55 | const orgs = await getOrgs(accessToken); 56 | const org = await selectOrgCLI(orgs); 57 | return org.id; 58 | }; 59 | 60 | const getOrgs = async (accessToken) => { 61 | const response = await partnersRequest(AllOrganizationsQuery, accessToken); 62 | const orgs = response.organizations.nodes; 63 | if (orgs.length === 0) { 64 | throw new Error( 65 | `---> There was a problem connecting to the org. Please check that the org exists and/or you have access. You can logout using\n npm run shopify auth logout` 66 | ); 67 | } 68 | return orgs; 69 | }; 70 | 71 | const selectOrgCLI = async (orgs) => { 72 | if (orgs.length === 1) { 73 | return orgs[0]; 74 | } 75 | const orgList = orgs.map((org) => ({ 76 | label: org.businessName, 77 | value: org.id, 78 | id: org.id, 79 | })); 80 | 81 | const choice = await renderSelectPrompt({ 82 | message: "Select a Shopify Partner org for this app", 83 | choices: orgList, 84 | }); 85 | 86 | return orgs.find((org) => org.id === choice); 87 | }; 88 | 89 | const getApp = async (apiKey, accessToken) => { 90 | const response = await partnersRequest(FindAppQuery, accessToken, { 91 | apiKey, 92 | }); 93 | return response.app; 94 | }; 95 | const updateDashboardURLs = async (apiKey, appUrl) => { 96 | const accessToken = await ensureAuthenticatedPartners(); 97 | 98 | const redirectURLs = appUrl.endsWith("/") 99 | ? [`${appUrl}api/auth/tokens`, `${appUrl}api/auth/callback`] 100 | : [`${appUrl}/api/auth/tokens`, `${appUrl}/api/auth/callback`]; 101 | 102 | const urls = { 103 | applicationUrl: appUrl, 104 | redirectUrlWhitelist: redirectURLs, 105 | }; 106 | 107 | const result = await partnersRequest(UpdateAppURLQuery, accessToken, { 108 | apiKey, 109 | ...urls, 110 | }); 111 | if (result.appUpdate.userErrors.length > 0) { 112 | const errors = result.appUpdate.userErrors 113 | .map((error) => error.message) 114 | .join(", "); 115 | 116 | throw new Error(errors); 117 | } 118 | }; 119 | 120 | console.warn("--> This is for use in DEV mode only"); 121 | console.log("--> Fetching Access Tokens"); 122 | const accessToken = await ensureAuthenticatedPartners(); 123 | console.log("--> Fetching Orgs"); 124 | await selectOrg(accessToken); 125 | console.log("--> Fetching App Data"); 126 | const app = await getApp(process.env.SHOPIFY_API_KEY, accessToken); 127 | console.log("--> Updating URLs"); 128 | await updateDashboardURLs(app.apiKey, process.env.SHOPIFY_APP_URL); 129 | console.log("--> URLs updated. Please update GDPR and Proxy routes manually"); 130 | console.log("--> Done"); 131 | 132 | */ 133 | 134 | throw new Error( 135 | "\n\n\n\n\n---> npm run update:url is now `npm run update:config`. Please refer to setup for more information. If you're not using managed auth, head into `_developer/updateDashboard.js` and uncomment the file\n\n\n\n\n" 136 | ); 137 | -------------------------------------------------------------------------------- /nextjs/_developer/webhookWriter.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import shopify from "../utils/shopify.js"; 4 | 5 | /** 6 | * @typedef {Object} ApiEndpoint 7 | * @property {string} topic - The API endpoint topic. 8 | * @property {string} graphql_topic - The topic's GraphQL topic name. 9 | * @property {string[]} scopes - The required scopes for accessing the endpoint. 10 | * @property {boolean} approval - Indicates if the endpoint requires approval from Shopify. 11 | * @property {boolean} pii - Indicates if the endpoint requires customer data access. 12 | */ 13 | 14 | //Ref: https://shopify.dev/docs/api/webhooks/2024-10?reference=toml 15 | /** 16 | * @type {ApiEndpoint[]} 17 | */ 18 | const topicsAndScopes = [ 19 | { topic: "app/uninstalled", graphql_topic: "APP_UNINSTALLED" }, 20 | { 21 | topic: "app_purchases_one_time/update", 22 | graphql_topic: "APP_PURCHASES_ONE_TIME_UPDATE", 23 | }, 24 | { 25 | topic: "app_subscriptions/approaching_capped_amount", 26 | graphql_topic: "APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT", 27 | }, 28 | { 29 | topic: "app_subscriptions/update", 30 | graphql_topic: "APP_SUBSCRIPTIONS_UPDATE", 31 | }, 32 | { topic: "bulk_operations/finish", graphql_topic: "BULK_OPERATIONS_FINISH" }, 33 | { 34 | topic: "carts/create", 35 | scopes: ["read_orders", "write_orders"], 36 | graphql_topic: "CARTS_CREATE", 37 | }, 38 | { 39 | topic: "carts/update", 40 | scopes: ["read_orders", "write_orders"], 41 | graphql_topic: "CARTS_UPDATE", 42 | }, 43 | { 44 | topic: "channels/delete", 45 | scopes: ["read_publications", "write_publications"], 46 | approval: true, 47 | graphql_topic: "CHANNELS_DELETE", 48 | }, 49 | { 50 | topic: "checkouts/create", 51 | scopes: ["read_orders", "write_orders"], 52 | graphql_topic: "CHECKOUTS_CREATE", 53 | }, 54 | { 55 | topic: "checkouts/delete", 56 | scopes: ["read_orders", "write_orders"], 57 | graphql_topic: "CHECKOUTS_DELETE", 58 | }, 59 | { 60 | topic: "checkouts/update", 61 | scopes: ["read_orders", "write_orders"], 62 | graphql_topic: "CHECKOUTS_UPDATE", 63 | }, 64 | { 65 | topic: "collection_listings/add", 66 | scopes: ["read_product_listings"], 67 | graphql_topic: "COLLECTION_LISTINGS_ADD", 68 | }, 69 | { 70 | topic: "collection_listings/remove", 71 | scopes: ["read_product_listings"], 72 | graphql_topic: "COLLECTION_LISTINGS_REMOVE", 73 | }, 74 | { 75 | topic: "collection_listings/update", 76 | scopes: ["read_product_listings"], 77 | graphql_topic: "COLLECTION_LISTINGS_UPDATE", 78 | }, 79 | { 80 | topic: "collection_publications/create", 81 | scopes: ["read_publications", "write_publications"], 82 | graphql_topic: "COLLECTION_PUBLICATIONS_CREATE", 83 | }, 84 | { 85 | topic: "collection_publications/delete", 86 | scopes: ["read_publications", "write_publications"], 87 | graphql_topic: "COLLECTION_PUBLICATIONS_DELETE", 88 | }, 89 | { 90 | topic: "collection_publications/update", 91 | scopes: ["read_publications", "write_publications"], 92 | graphql_topic: "COLLECTION_PUBLICATIONS_UPDATE", 93 | }, 94 | { 95 | topic: "collections/create", 96 | scopes: [""], 97 | graphql_topic: "COLLECTIONS_CREATE", 98 | }, 99 | { 100 | topic: "collections/delete", 101 | scopes: ["read_products", "write_products"], 102 | graphql_topic: "COLLECTIONS_DELETE", 103 | }, 104 | { 105 | topic: "collections/update", 106 | scopes: ["read_products", "write_products"], 107 | graphql_topic: "COLLECTIONS_UPDATE", 108 | }, 109 | { 110 | topic: "companies/create", 111 | scopes: ["read_products", "write_products"], 112 | pii: true, 113 | graphql_topic: "COMPANIES_CREATE", 114 | }, 115 | { 116 | topic: "companies/delete", 117 | scopes: ["read_customers", "write_customers"], 118 | pii: true, 119 | graphql_topic: "COMPANIES_DELETE", 120 | }, 121 | { 122 | topic: "companies/update", 123 | scopes: ["read_customers", "write_customers"], 124 | pii: true, 125 | graphql_topic: "COMPANIES_UPDATE", 126 | }, 127 | { 128 | topic: "company_contact_roles/assign", 129 | scopes: ["read_customers", "write_customers"], 130 | pii: true, 131 | graphql_topic: "COMPANY_CONTACT_ROLES_ASSIGN", 132 | }, 133 | { 134 | topic: "company_contact_roles/revoke", 135 | scopes: ["read_customers", "write_customers"], 136 | pii: true, 137 | graphql_topic: "COMPANY_CONTACT_ROLES_REVOKE", 138 | }, 139 | { 140 | topic: "company_contacts/create", 141 | scopes: ["read_customers", "write_customers"], 142 | pii: true, 143 | graphql_topic: "COMPANY_CONTACTS_CREATE", 144 | }, 145 | { 146 | topic: "company_contacts/delete", 147 | scopes: ["read_customers", "write_customers"], 148 | pii: true, 149 | graphql_topic: "COMPANY_CONTACTS_DELETE", 150 | }, 151 | { 152 | topic: "company_contacts/update", 153 | scopes: ["read_customers", "write_customers"], 154 | pii: true, 155 | graphql_topic: "COMPANY_CONTACTS_UPDATE", 156 | }, 157 | { 158 | topic: "company_locations/create", 159 | scopes: ["read_customers", "write_customers"], 160 | pii: true, 161 | graphql_topic: "COMPANY_LOCATIONS_CREATE", 162 | }, 163 | { 164 | topic: "company_locations/delete", 165 | scopes: ["read_customers", "write_customers"], 166 | pii: true, 167 | graphql_topic: "COMPANY_LOCATIONS_DELETE", 168 | }, 169 | { 170 | topic: "company_locations/update", 171 | scopes: ["read_customers", "write_customers"], 172 | pii: true, 173 | graphql_topic: "COMPANY_LOCATIONS_UPDATE", 174 | }, 175 | { 176 | topic: "customer.tags_added", 177 | scopes: ["read_customers", "write_customers"], 178 | pii: true, 179 | graphql_topic: "CUSTOMER_TAGS_ADDED", 180 | }, 181 | { 182 | topic: "customer.tags_removed", 183 | scopes: ["read_customers", "write_customers"], 184 | pii: true, 185 | graphql_topic: "CUSTOMER_TAGS_REMOVED", 186 | }, 187 | { 188 | topic: "customer_account_settings/update", 189 | 190 | graphql_topic: "CUSTOMER_ACCOUNT_SETTINGS_UPDATE", 191 | }, 192 | { 193 | topic: "customer_groups/create", 194 | scopes: ["read_customers", "write_customers"], 195 | pii: true, 196 | graphql_topic: "CUSTOMER_GROUPS_CREATE", 197 | }, 198 | { 199 | topic: "customer_groups/delete", 200 | scopes: ["read_customers", "write_customers"], 201 | pii: true, 202 | graphql_topic: "CUSTOMER_GROUPS_DELETE", 203 | }, 204 | { 205 | topic: "customer_groups/update", 206 | scopes: ["read_customers", "write_customers"], 207 | pii: true, 208 | graphql_topic: "CUSTOMER_GROUPS_UPDATE", 209 | }, 210 | { 211 | topic: "customer_payment_methods/create", 212 | scopes: ["read_customer_payment_methods"], 213 | approval: true, 214 | pii: true, 215 | graphql_topic: "CUSTOMER_PAYMENT_METHODS_CREATE", 216 | }, 217 | { 218 | topic: "customer_payment_methods/revoke", 219 | scopes: ["read_customer_payment_methods"], 220 | approval: true, 221 | pii: true, 222 | graphql_topic: "CUSTOMER_PAYMENT_METHODS_REVOKE", 223 | }, 224 | { 225 | topic: "customer_payment_methods/update", 226 | scopes: ["read_customer_payment_methods"], 227 | approval: true, 228 | pii: true, 229 | graphql_topic: "CUSTOMER_PAYMENT_METHODS_UPDATE", 230 | }, 231 | { 232 | topic: "customers/create", 233 | scopes: ["read_customers", "write_customers"], 234 | pii: true, 235 | graphql_topic: "CUSTOMERS_CREATE", 236 | }, 237 | { 238 | topic: "customers/data_request", 239 | 240 | graphql_topic: "CUSTOMERS_DATA_REQUEST", 241 | }, 242 | { 243 | topic: "customers/delete", 244 | scopes: ["read_customers", "write_customers"], 245 | pii: true, 246 | graphql_topic: "CUSTOMERS_DELETE", 247 | }, 248 | { 249 | topic: "customers/disable", 250 | scopes: ["read_customers", "write_customers"], 251 | pii: true, 252 | graphql_topic: "CUSTOMERS_DISABLE", 253 | }, 254 | { 255 | topic: "customers/enable", 256 | scopes: ["read_customers", "write_customers"], 257 | pii: true, 258 | graphql_topic: "CUSTOMERS_ENABLE", 259 | }, 260 | { 261 | topic: "customers/merge", 262 | scopes: ["read_customer_merge", "write_customer_merge"], 263 | graphql_topic: "CUSTOMERS_MERGE", 264 | }, 265 | { topic: "customers/redact", graphql_topic: "CUSTOMERS_REDACT" }, 266 | { 267 | topic: "customers/update", 268 | scopes: ["read_customers", "write_customers"], 269 | pii: true, 270 | graphql_topic: "CUSTOMERS_UPDATE", 271 | }, 272 | { 273 | topic: "customers_email_marketing_consent/update", 274 | scopes: ["read_customers", "write_customers"], 275 | pii: true, 276 | graphql_topic: "CUSTOMERS_EMAIL_MARKETING_CONSENT_UPDATE", 277 | }, 278 | { 279 | topic: "customers_marketing_consent/update", 280 | scopes: ["read_customers", "write_customers"], 281 | pii: true, 282 | graphql_topic: "CUSTOMERS_MARKETING_CONSENT_UPDATE", 283 | }, 284 | { 285 | topic: "discounts/create", 286 | scopes: ["read_discounts", "write_discounts"], 287 | graphql_topic: "DISCOUNTS_CREATE", 288 | }, 289 | { 290 | topic: "discounts/delete", 291 | scopes: ["read_discounts", "write_discounts"], 292 | graphql_topic: "DISCOUNTS_DELETE", 293 | }, 294 | { 295 | topic: "discounts/redeemcode_added", 296 | scopes: ["read_discounts", "write_discounts"], 297 | graphql_topic: "DISCOUNTS_REDEEMCODE_ADDED", 298 | }, 299 | { 300 | topic: "discounts/redeemcode_removed", 301 | scopes: ["read_discounts", "write_discounts"], 302 | graphql_topic: "DISCOUNTS_REDEEMCODE_REMOVED", 303 | }, 304 | { 305 | topic: "discounts/update", 306 | scopes: ["read_discounts", "write_discounts"], 307 | graphql_topic: "DISCOUNTS_UPDATE", 308 | }, 309 | { 310 | topic: "disputes/create", 311 | scopes: ["read_shopify_payments_disputes"], 312 | pii: true, 313 | graphql_topic: "DISPUTES_CREATE", 314 | }, 315 | { 316 | topic: "disputes/update", 317 | scopes: ["read_shopify_payments_disputes"], 318 | pii: true, 319 | graphql_topic: "DISPUTES_UPDATE", 320 | }, 321 | { topic: "domains/create", graphql_topic: "DOMAINS_CREATE" }, 322 | { topic: "domains/destroy", graphql_topic: "DOMAINS_DESTROY" }, 323 | { topic: "domains/update", graphql_topic: "DOMAINS_UPDATE" }, 324 | { 325 | topic: "draft_orders/create", 326 | scopes: ["read_draft_orders", "write_draft_orders"], 327 | pii: true, 328 | graphql_topic: "DRAFT_ORDERS_CREATE", 329 | }, 330 | { 331 | topic: "draft_orders/delete", 332 | scopes: ["read_draft_orders", "write_draft_orders"], 333 | graphql_topic: "DRAFT_ORDERS_DELETE", 334 | }, 335 | { 336 | topic: "draft_orders/update", 337 | scopes: ["read_draft_orders", "write_draft_orders"], 338 | pii: true, 339 | graphql_topic: "DRAFT_ORDERS_UPDATE", 340 | }, 341 | { 342 | topic: "fulfillment_events/create", 343 | scopes: ["read_fulfillments", "write_fulfillments"], 344 | pii: true, 345 | graphql_topic: "FULFILLMENT_EVENTS_CREATE", 346 | }, 347 | { 348 | topic: "fulfillment_events/delete", 349 | scopes: ["read_fulfillments", "write_fulfillments"], 350 | graphql_topic: "FULFILLMENT_EVENTS_DELETE", 351 | }, 352 | { 353 | topic: "fulfillment_orders/cancellation_request_accepted", 354 | scopes: [ 355 | "read_merchant_managed_fulfillment_orders", 356 | "write_merchant_managed_fulfillment_orders", 357 | "read_assigned_fulfillment_orders", 358 | "write_assigned_fulfillment_orders", 359 | "read_third_party_fulfillment_orders", 360 | "write_third_party_fulfillment_orders", 361 | ], 362 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLATION_REQUEST_ACCEPTED", 363 | }, 364 | { 365 | topic: "fulfillment_orders/cancellation_request_rejected", 366 | scopes: [ 367 | "read_merchant_managed_fulfillment_orders", 368 | "write_merchant_managed_fulfillment_orders", 369 | "read_assigned_fulfillment_orders", 370 | "write_assigned_fulfillment_orders", 371 | "read_third_party_fulfillment_orders", 372 | "write_third_party_fulfillment_orders", 373 | ], 374 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLATION_REQUEST_REJECTED", 375 | }, 376 | { 377 | topic: "fulfillment_orders/cancellation_request_submitted", 378 | scopes: [ 379 | "read_merchant_managed_fulfillment_orders", 380 | "write_merchant_managed_fulfillment_orders", 381 | "read_assigned_fulfillment_orders", 382 | "write_assigned_fulfillment_orders", 383 | "read_third_party_fulfillment_orders", 384 | "write_third_party_fulfillment_orders", 385 | ], 386 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLATION_REQUEST_SUBMITTED", 387 | }, 388 | { 389 | topic: "fulfillment_orders/cancelled", 390 | scopes: [ 391 | "read_merchant_managed_fulfillment_orders", 392 | "write_merchant_managed_fulfillment_orders", 393 | "read_assigned_fulfillment_orders", 394 | "write_assigned_fulfillment_orders", 395 | "read_third_party_fulfillment_orders", 396 | "write_third_party_fulfillment_orders", 397 | ], 398 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLED", 399 | }, 400 | { 401 | topic: "fulfillment_orders/fulfillment_request_accepted", 402 | scopes: [ 403 | "read_merchant_managed_fulfillment_orders", 404 | "write_merchant_managed_fulfillment_orders", 405 | "read_assigned_fulfillment_orders", 406 | "write_assigned_fulfillment_orders", 407 | "read_third_party_fulfillment_orders", 408 | "write_third_party_fulfillment_orders", 409 | ], 410 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_REQUEST_ACCEPTED", 411 | }, 412 | { 413 | topic: "fulfillment_orders/fulfillment_request_rejected", 414 | scopes: [ 415 | "read_merchant_managed_fulfillment_orders", 416 | "write_merchant_managed_fulfillment_orders", 417 | "read_assigned_fulfillment_orders", 418 | "write_assigned_fulfillment_orders", 419 | "read_third_party_fulfillment_orders", 420 | "write_third_party_fulfillment_orders", 421 | ], 422 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_REQUEST_REJECTED", 423 | }, 424 | { 425 | topic: "fulfillment_orders/fulfillment_request_submitted", 426 | scopes: [ 427 | "read_merchant_managed_fulfillment_orders", 428 | "write_merchant_managed_fulfillment_orders", 429 | "read_assigned_fulfillment_orders", 430 | "write_assigned_fulfillment_orders", 431 | "read_third_party_fulfillment_orders", 432 | "write_third_party_fulfillment_orders", 433 | ], 434 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_REQUEST_SUBMITTED", 435 | }, 436 | { 437 | topic: "fulfillment_orders/fulfillment_service_failed_to_complete", 438 | scopes: [ 439 | "read_merchant_managed_fulfillment_orders", 440 | "write_merchant_managed_fulfillment_orders", 441 | "read_assigned_fulfillment_orders", 442 | "write_assigned_fulfillment_orders", 443 | "read_third_party_fulfillment_orders", 444 | "write_third_party_fulfillment_orders", 445 | ], 446 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_SERVICE_FAILED_TO_COMPLETE", 447 | }, 448 | { 449 | topic: "fulfillment_orders/hold_released", 450 | scopes: [ 451 | "read_merchant_managed_fulfillment_orders", 452 | "write_merchant_managed_fulfillment_orders", 453 | "read_assigned_fulfillment_orders", 454 | "write_assigned_fulfillment_orders", 455 | "read_third_party_fulfillment_orders", 456 | "write_third_party_fulfillment_orders", 457 | ], 458 | graphql_topic: "FULFILLMENT_ORDERS_HOLD_RELEASED", 459 | }, 460 | { 461 | topic: "fulfillment_orders/line_items_prepared_for_local_delivery", 462 | scopes: [ 463 | "read_merchant_managed_fulfillment_orders", 464 | "write_merchant_managed_fulfillment_orders", 465 | "read_assigned_fulfillment_orders", 466 | "write_assigned_fulfillment_orders", 467 | "read_third_party_fulfillment_orders", 468 | "write_third_party_fulfillment_orders", 469 | ], 470 | graphql_topic: "FULFILLMENT_ORDERS_LINE_ITEMS_PREPARED_FOR_LOCAL_DELIVERY", 471 | }, 472 | { 473 | topic: "fulfillment_orders/line_items_prepared_for_pickup", 474 | scopes: [ 475 | "read_merchant_managed_fulfillment_orders", 476 | "write_merchant_managed_fulfillment_orders", 477 | "read_assigned_fulfillment_orders", 478 | "write_assigned_fulfillment_orders", 479 | "read_third_party_fulfillment_orders", 480 | "write_third_party_fulfillment_orders", 481 | ], 482 | graphql_topic: "FULFILLMENT_ORDERS_LINE_ITEMS_PREPARED_FOR_PICKUP", 483 | }, 484 | { 485 | topic: "fulfillment_orders/merged", 486 | scopes: [ 487 | "read_merchant_managed_fulfillment_orders", 488 | "write_merchant_managed_fulfillment_orders", 489 | "read_assigned_fulfillment_orders", 490 | "write_assigned_fulfillment_orders", 491 | "read_third_party_fulfillment_orders", 492 | "write_third_party_fulfillment_orders", 493 | ], 494 | graphql_topic: "FULFILLMENT_ORDERS_MERGED", 495 | }, 496 | { 497 | topic: "fulfillment_orders/moved", 498 | scopes: [ 499 | "read_merchant_managed_fulfillment_orders", 500 | "write_merchant_managed_fulfillment_orders", 501 | "read_assigned_fulfillment_orders", 502 | "write_assigned_fulfillment_orders", 503 | "read_third_party_fulfillment_orders", 504 | "write_third_party_fulfillment_orders", 505 | ], 506 | graphql_topic: "FULFILLMENT_ORDERS_MOVED", 507 | }, 508 | { 509 | topic: "fulfillment_orders/order_routing_complete", 510 | scopes: [ 511 | "read_merchant_managed_fulfillment_orders", 512 | "write_merchant_managed_fulfillment_orders", 513 | "read_assigned_fulfillment_orders", 514 | "write_assigned_fulfillment_orders", 515 | "read_third_party_fulfillment_orders", 516 | "write_third_party_fulfillment_orders", 517 | ], 518 | graphql_topic: "FULFILLMENT_ORDERS_ORDER_ROUTING_COMPLETE", 519 | }, 520 | { 521 | topic: "fulfillment_orders/placed_on_hold", 522 | scopes: [ 523 | "read_merchant_managed_fulfillment_orders", 524 | "write_merchant_managed_fulfillment_orders", 525 | "read_assigned_fulfillment_orders", 526 | "write_assigned_fulfillment_orders", 527 | "read_third_party_fulfillment_orders", 528 | "write_third_party_fulfillment_orders", 529 | ], 530 | graphql_topic: "FULFILLMENT_ORDERS_PLACED_ON_HOLD", 531 | }, 532 | { 533 | topic: "fulfillment_orders/rescheduled", 534 | scopes: [ 535 | "read_merchant_managed_fulfillment_orders", 536 | "write_merchant_managed_fulfillment_orders", 537 | "read_assigned_fulfillment_orders", 538 | "write_assigned_fulfillment_orders", 539 | "read_third_party_fulfillment_orders", 540 | "write_third_party_fulfillment_orders", 541 | ], 542 | graphql_topic: "FULFILLMENT_ORDERS_RESCHEDULED", 543 | }, 544 | { 545 | topic: "fulfillment_orders/scheduled_fulfillment_order_ready", 546 | scopes: [ 547 | "read_merchant_managed_fulfillment_orders", 548 | "write_merchant_managed_fulfillment_orders", 549 | "read_assigned_fulfillment_orders", 550 | "write_assigned_fulfillment_orders", 551 | "read_third_party_fulfillment_orders", 552 | "write_third_party_fulfillment_orders", 553 | ], 554 | graphql_topic: "FULFILLMENT_ORDERS_SCHEDULED_FULFILLMENT_ORDER_READY", 555 | }, 556 | { 557 | topic: "fulfillment_orders/split", 558 | scopes: [ 559 | "read_merchant_managed_fulfillment_orders", 560 | "write_merchant_managed_fulfillment_orders", 561 | "read_assigned_fulfillment_orders", 562 | "write_assigned_fulfillment_orders", 563 | "read_third_party_fulfillment_orders", 564 | "write_third_party_fulfillment_orders", 565 | ], 566 | graphql_topic: "FULFILLMENT_ORDERS_SPLIT", 567 | }, 568 | { 569 | topic: "fulfillments/create", 570 | scopes: ["read_fulfillments", "write_fulfillments"], 571 | pii: true, 572 | graphql_topic: "FULFILLMENTS_CREATE", 573 | }, 574 | { 575 | topic: "fulfillments/update", 576 | scopes: ["read_fulfillments", "write_fulfillments"], 577 | pii: true, 578 | graphql_topic: "FULFILLMENTS_UPDATE", 579 | }, 580 | { 581 | topic: "inventory_items/create", 582 | scopes: ["read_inventory", "write_inventory"], 583 | graphql_topic: "INVENTORY_ITEMS_CREATE", 584 | }, 585 | { 586 | topic: "inventory_items/delete", 587 | scopes: ["read_inventory", "write_inventory"], 588 | graphql_topic: "INVENTORY_ITEMS_DELETE", 589 | }, 590 | { 591 | topic: "inventory_items/update", 592 | scopes: ["read_inventory", "write_inventory"], 593 | graphql_topic: "INVENTORY_ITEMS_UPDATE", 594 | }, 595 | { 596 | topic: "inventory_levels/connect", 597 | scopes: ["read_inventory", "write_inventory"], 598 | graphql_topic: "INVENTORY_LEVELS_CONNECT", 599 | }, 600 | { 601 | topic: "inventory_levels/disconnect", 602 | scopes: ["read_inventory", "write_inventory"], 603 | graphql_topic: "INVENTORY_LEVELS_DISCONNECT", 604 | }, 605 | { 606 | topic: "inventory_levels/update", 607 | scopes: ["read_inventory", "write_inventory"], 608 | graphql_topic: "INVENTORY_LEVELS_UPDATE", 609 | }, 610 | { 611 | topic: "locales/create", 612 | scopes: ["read_locales", "write_locales"], 613 | graphql_topic: "LOCALES_CREATE", 614 | }, 615 | { 616 | topic: "locales/update", 617 | scopes: ["read_locales", "write_locales"], 618 | graphql_topic: "LOCALES_UPDATE", 619 | }, 620 | { 621 | topic: "locations/activate", 622 | scopes: ["read_locations", "write_locations"], 623 | graphql_topic: "LOCATIONS_ACTIVATE", 624 | }, 625 | { 626 | topic: "locations/create", 627 | scopes: ["read_locations", "write_locations"], 628 | graphql_topic: "LOCATIONS_CREATE", 629 | }, 630 | { 631 | topic: "locations/deactivate", 632 | scopes: ["read_locations", "write_locations"], 633 | graphql_topic: "LOCATIONS_DEACTIVATE", 634 | }, 635 | { 636 | topic: "locations/delete", 637 | scopes: ["read_locations", "write_locations"], 638 | graphql_topic: "LOCATIONS_DELETE", 639 | }, 640 | { 641 | topic: "locations/update", 642 | scopes: ["read_locations", "write_locations"], 643 | graphql_topic: "LOCATIONS_UPDATE", 644 | }, 645 | { 646 | topic: "markets/create", 647 | scopes: ["read_markets", "write_markets"], 648 | graphql_topic: "MARKETS_CREATE", 649 | }, 650 | { 651 | topic: "markets/delete", 652 | scopes: ["read_markets", "write_markets"], 653 | graphql_topic: "MARKETS_DELETE", 654 | }, 655 | { 656 | topic: "markets/update", 657 | scopes: ["read_markets", "write_markets"], 658 | graphql_topic: "MARKETS_UPDATE", 659 | }, 660 | { 661 | topic: "metaobjects/create", 662 | scopes: ["read_metaobjects", "write_metaobjects"], 663 | graphql_topic: "METAOBJECTS_CREATE", 664 | }, 665 | { 666 | topic: "metaobjects/delete", 667 | scopes: ["read_metaobjects", "write_metaobjects"], 668 | graphql_topic: "METAOBJECTS_DELETE", 669 | }, 670 | { 671 | topic: "metaobjects/update", 672 | scopes: ["read_metaobjects", "write_metaobjects"], 673 | graphql_topic: "METAOBJECTS_UPDATE", 674 | }, 675 | { 676 | topic: "order_transactions/create", 677 | scopes: ["read_orders", "write_orders"], 678 | graphql_topic: "ORDER_TRANSACTIONS_CREATE", 679 | }, 680 | { 681 | topic: "orders/cancelled", 682 | scopes: ["read_orders", "write_orders"], 683 | pii: true, 684 | graphql_topic: "ORDERS_CANCELLED", 685 | }, 686 | { 687 | topic: "orders/create", 688 | scopes: ["read_orders", "write_orders"], 689 | pii: true, 690 | graphql_topic: "ORDERS_CREATE", 691 | }, 692 | { 693 | topic: "orders/delete", 694 | scopes: ["read_orders", "write_orders"], 695 | pii: true, 696 | graphql_topic: "ORDERS_DELETE", 697 | }, 698 | { 699 | topic: "orders/edited", 700 | scopes: ["read_orders", "write_orders"], 701 | pii: true, 702 | graphql_topic: "ORDERS_EDITED", 703 | }, 704 | { 705 | topic: "orders/fulfilled", 706 | scopes: ["read_orders", "write_orders"], 707 | pii: true, 708 | graphql_topic: "ORDERS_FULFILLED", 709 | }, 710 | { 711 | topic: "orders/paid", 712 | scopes: ["read_orders", "write_orders"], 713 | pii: true, 714 | graphql_topic: "ORDERS_PAID", 715 | }, 716 | { 717 | topic: "orders/partially_fulfilled", 718 | scopes: ["read_orders", "write_orders"], 719 | pii: true, 720 | graphql_topic: "ORDERS_PARTIALLY_FULFILLED", 721 | }, 722 | { 723 | topic: "orders/risk_assessment_changed", 724 | scopes: ["read_orders", "write_orders"], 725 | pii: true, 726 | graphql_topic: "ORDERS_RISK_ASSESSMENT_CHANGED", 727 | }, 728 | { 729 | topic: "orders/shopify_protect_eligibility_changed", 730 | scopes: ["read_orders", "write_orders"], 731 | pii: true, 732 | graphql_topic: "ORDERS_SHOPIFY_PROTECT_ELIGIBILITY_CHANGED", 733 | }, 734 | { 735 | topic: "orders/updated", 736 | scopes: ["read_orders", "write_orders"], 737 | pii: true, 738 | graphql_topic: "ORDERS_UPDATED", 739 | }, 740 | { 741 | topic: "payment_schedules/due", 742 | scopes: ["read_payment_terms", "write_payment_terms"], 743 | graphql_topic: "PAYMENT_SCHEDULES_DUE", 744 | }, 745 | { 746 | topic: "payment_terms/create", 747 | scopes: ["read_payment_terms", "write_payment_terms"], 748 | graphql_topic: "PAYMENT_TERMS_CREATE", 749 | }, 750 | { 751 | topic: "payment_terms/delete", 752 | scopes: ["read_payment_terms", "write_payment_terms"], 753 | graphql_topic: "PAYMENT_TERMS_DELETE", 754 | }, 755 | { 756 | topic: "payment_terms/update", 757 | scopes: ["read_payment_terms", "write_payment_terms"], 758 | graphql_topic: "PAYMENT_TERMS_UPDATE", 759 | }, 760 | { 761 | topic: "product_feeds/create", 762 | scopes: ["read_product_listings"], 763 | graphql_topic: "PRODUCT_FEEDS_CREATE", 764 | }, 765 | { 766 | topic: "product_feeds/update", 767 | scopes: ["read_product_listings"], 768 | graphql_topic: "PRODUCT_FEEDS_UPDATE", 769 | }, 770 | { 771 | topic: "product_listings/add", 772 | scopes: ["read_product_listings"], 773 | graphql_topic: "PRODUCT_LISTINGS_ADD", 774 | }, 775 | { 776 | topic: "product_listings/remove", 777 | scopes: ["read_product_listings"], 778 | graphql_topic: "PRODUCT_LISTINGS_REMOVE", 779 | }, 780 | { 781 | topic: "product_listings/update", 782 | scopes: ["read_product_listings"], 783 | graphql_topic: "PRODUCT_LISTINGS_UPDATE", 784 | }, 785 | { 786 | topic: "product_publications/create", 787 | scopes: ["read_publications", "write_publications"], 788 | approval: true, 789 | graphql_topic: "PRODUCT_PUBLICATIONS_CREATE", 790 | }, 791 | { 792 | topic: "product_publications/delete", 793 | scopes: ["read_publications", "write_publications"], 794 | approval: true, 795 | graphql_topic: "PRODUCT_PUBLICATIONS_DELETE", 796 | }, 797 | { 798 | topic: "product_publications/update", 799 | scopes: ["read_publications", "write_publications"], 800 | approval: true, 801 | graphql_topic: "PRODUCT_PUBLICATIONS_UPDATE", 802 | }, 803 | { 804 | topic: "products/create", 805 | scopes: ["read_products,write_products"], 806 | graphql_topic: "PRODUCTS_CREATE", 807 | }, 808 | { 809 | topic: "products/delete", 810 | scopes: ["read_products,write_products"], 811 | graphql_topic: "PRODUCTS_DELETE", 812 | }, 813 | { 814 | topic: "products/update", 815 | scopes: ["read_products,write_products"], 816 | graphql_topic: "PRODUCTS_UPDATE", 817 | }, 818 | { 819 | topic: "profiles/create", 820 | scopes: ["read_shipping", "write_shipping"], 821 | pii: true, 822 | graphql_topic: "PROFILES_CREATE", 823 | }, 824 | { 825 | topic: "profiles/delete", 826 | scopes: ["read_shipping", "write_shipping"], 827 | pii: true, 828 | graphql_topic: "PROFILES_DELETE", 829 | }, 830 | { 831 | topic: "profiles/update", 832 | scopes: ["read_shipping", "write_shipping"], 833 | pii: true, 834 | graphql_topic: "PROFILES_UPDATE", 835 | }, 836 | { 837 | topic: "refunds/create", 838 | scopes: ["read_orders", "write_orders"], 839 | graphql_topic: "REFUNDS_CREATE", 840 | }, 841 | { 842 | topic: "returns/approve", 843 | scopes: ["read_returns", "write_returns"], 844 | graphql_topic: "RETURNS_APPROVE", 845 | }, 846 | { 847 | topic: "returns/cancel", 848 | scopes: ["read_returns", "write_returns"], 849 | graphql_topic: "RETURNS_CANCEL", 850 | }, 851 | { 852 | topic: "returns/close", 853 | scopes: ["read_returns", "write_returns"], 854 | graphql_topic: "RETURNS_CLOSE", 855 | }, 856 | { 857 | topic: "returns/decline", 858 | scopes: ["read_returns", "write_returns"], 859 | graphql_topic: "RETURNS_DECLINE", 860 | }, 861 | { 862 | topic: "returns/reopen", 863 | scopes: ["read_returns", "write_returns"], 864 | graphql_topic: "RETURNS_REOPEN", 865 | }, 866 | { 867 | topic: "returns/request", 868 | scopes: ["read_returns", "write_returns"], 869 | graphql_topic: "RETURNS_REQUEST", 870 | }, 871 | { 872 | topic: "returns/update", 873 | scopes: ["read_returns", "write_returns"], 874 | graphql_topic: "RETURNS_UPDATE", 875 | }, 876 | { 877 | topic: "reverse_deliveries/attach_deliverable", 878 | scopes: ["read_returns", "write_returns"], 879 | pii: true, 880 | graphql_topic: "REVERSE_DELIVERIES_ATTACH_DELIVERABLE", 881 | }, 882 | { 883 | topic: "reverse_fulfillment_orders/dispose", 884 | scopes: ["read_returns", "write_returns"], 885 | graphql_topic: "REVERSE_FULFILLMENT_ORDERS_DISPOSE", 886 | }, 887 | { 888 | topic: "scheduled_product_listings/add", 889 | scopes: ["read_product_listings"], 890 | graphql_topic: "SCHEDULED_PRODUCT_LISTINGS_ADD", 891 | }, 892 | { 893 | topic: "scheduled_product_listings/remove", 894 | scopes: ["read_product_listings"], 895 | graphql_topic: "SCHEDULED_PRODUCT_LISTINGS_REMOVE", 896 | }, 897 | { 898 | topic: "scheduled_product_listings/update", 899 | scopes: ["read_product_listings"], 900 | graphql_topic: "SCHEDULED_PRODUCT_LISTINGS_UPDATE", 901 | }, 902 | { 903 | topic: "segments/create", 904 | scopes: ["read_customers", "write_customers"], 905 | graphql_topic: "SEGMENTS_CREATE", 906 | }, 907 | { 908 | topic: "segments/delete", 909 | scopes: ["read_customers", "write_customers"], 910 | graphql_topic: "SEGMENTS_DELETE", 911 | }, 912 | { 913 | topic: "segments/update", 914 | scopes: ["read_customers", "write_customers"], 915 | graphql_topic: "SEGMENTS_UPDATE", 916 | }, 917 | { 918 | topic: "selling_plan_groups/create", 919 | scopes: ["read_products", "write_products"], 920 | graphql_topic: "SELLING_PLAN_GROUPS_CREATE", 921 | }, 922 | { 923 | topic: "selling_plan_groups/delete", 924 | scopes: ["read_products", "write_products"], 925 | graphql_topic: "SELLING_PLAN_GROUPS_DELETE", 926 | }, 927 | { 928 | topic: "selling_plan_groups/update", 929 | scopes: ["read_products", "write_products"], 930 | graphql_topic: "SELLING_PLAN_GROUPS_UPDATE", 931 | }, 932 | { topic: "shop/redact", graphql_topic: "SHOP_REDACT" }, 933 | { topic: "shop/update", graphql_topic: "SHOP_UPDATE" }, 934 | { 935 | topic: "subscription_billing_attempts/challenged", 936 | scopes: [ 937 | "read_own_subscription_contracts", 938 | "write_own_subscription_contracts", 939 | ], 940 | pii: true, 941 | graphql_topic: "SUBSCRIPTION_BILLING_ATTEMPTS_CHALLENGED", 942 | }, 943 | { 944 | topic: "subscription_billing_attempts/failure", 945 | scopes: [ 946 | "read_own_subscription_contracts", 947 | "write_own_subscription_contracts", 948 | ], 949 | pii: true, 950 | graphql_topic: "SUBSCRIPTION_BILLING_ATTEMPTS_FAILURE", 951 | }, 952 | { 953 | topic: "subscription_billing_attempts/success", 954 | scopes: [ 955 | "read_own_subscription_contracts", 956 | "write_own_subscription_contracts", 957 | ], 958 | pii: true, 959 | graphql_topic: "SUBSCRIPTION_BILLING_ATTEMPTS_SUCCESS", 960 | }, 961 | { 962 | topic: "subscription_billing_cycle_edits/create", 963 | scopes: [ 964 | "read_own_subscription_contracts", 965 | "write_own_subscription_contracts", 966 | ], 967 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLE_EDITS_CREATE", 968 | }, 969 | { 970 | topic: "subscription_billing_cycle_edits/delete", 971 | scopes: [ 972 | "read_own_subscription_contracts", 973 | "write_own_subscription_contracts", 974 | ], 975 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLE_EDITS_DELETE", 976 | }, 977 | { 978 | topic: "subscription_billing_cycle_edits/update", 979 | scopes: [ 980 | "read_own_subscription_contracts", 981 | "write_own_subscription_contracts", 982 | ], 983 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLE_EDITS_UPDATE", 984 | }, 985 | { 986 | topic: "subscription_billing_cycles/skip", 987 | scopes: [ 988 | "read_own_subscription_contracts", 989 | "write_own_subscription_contracts", 990 | ], 991 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLES_SKIP", 992 | }, 993 | { 994 | topic: "subscription_billing_cycles/unskip", 995 | scopes: [ 996 | "read_own_subscription_contracts", 997 | "write_own_subscription_contracts", 998 | ], 999 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLES_UNSKIP", 1000 | }, 1001 | { 1002 | topic: "subscription_contracts/activate", 1003 | scopes: [ 1004 | "read_own_subscription_contracts", 1005 | "write_own_subscription_contracts", 1006 | ], 1007 | graphql_topic: "SUBSCRIPTION_CONTRACTS_ACTIVATE", 1008 | }, 1009 | { 1010 | topic: "subscription_contracts/cancel", 1011 | scopes: [ 1012 | "read_own_subscription_contracts", 1013 | "write_own_subscription_contracts", 1014 | ], 1015 | graphql_topic: "SUBSCRIPTION_CONTRACTS_CANCEL", 1016 | }, 1017 | { 1018 | topic: "subscription_contracts/create", 1019 | scopes: [ 1020 | "read_own_subscription_contracts", 1021 | "write_own_subscription_contracts", 1022 | ], 1023 | graphql_topic: "SUBSCRIPTION_CONTRACTS_CREATE", 1024 | }, 1025 | { 1026 | topic: "subscription_contracts/expire", 1027 | scopes: [ 1028 | "read_own_subscription_contracts", 1029 | "write_own_subscription_contracts", 1030 | ], 1031 | graphql_topic: "SUBSCRIPTION_CONTRACTS_EXPIRE", 1032 | }, 1033 | { 1034 | topic: "subscription_contracts/fail", 1035 | scopes: [ 1036 | "read_own_subscription_contracts", 1037 | "write_own_subscription_contracts", 1038 | ], 1039 | graphql_topic: "SUBSCRIPTION_CONTRACTS_FAIL", 1040 | }, 1041 | { 1042 | topic: "subscription_contracts/pause", 1043 | scopes: [ 1044 | "read_own_subscription_contracts", 1045 | "write_own_subscription_contracts", 1046 | ], 1047 | graphql_topic: "SUBSCRIPTION_CONTRACTS_PAUSE", 1048 | }, 1049 | { 1050 | topic: "subscription_contracts/update", 1051 | scopes: [ 1052 | "read_own_subscription_contracts", 1053 | "write_own_subscription_contracts", 1054 | ], 1055 | graphql_topic: "SUBSCRIPTION_CONTRACTS_UPDATE", 1056 | }, 1057 | { 1058 | topic: "tender_transactions/create", 1059 | scopes: ["read_orders", "write_orders"], 1060 | graphql_topic: "TENDER_TRANSACTIONS_CREATE", 1061 | }, 1062 | { 1063 | topic: "themes/create", 1064 | scopes: ["read_themes", "write_themes"], 1065 | graphql_topic: "THEMES_CREATE", 1066 | }, 1067 | { 1068 | topic: "themes/delete", 1069 | scopes: ["read_themes", "write_themes"], 1070 | graphql_topic: "THEMES_DELETE", 1071 | }, 1072 | { 1073 | topic: "themes/publish", 1074 | scopes: ["read_themes", "write_themes"], 1075 | graphql_topic: "THEMES_PUBLISH", 1076 | }, 1077 | { 1078 | topic: "themes/update", 1079 | scopes: ["read_themes", "write_themes"], 1080 | graphql_topic: "THEMES_UPDATE", 1081 | }, 1082 | { 1083 | topic: "variants/in_stock", 1084 | scopes: ["read_products", "write_products"], 1085 | approval: true, 1086 | graphql_topic: "VARIANTS_IN_STOCK", 1087 | }, 1088 | { 1089 | topic: "variants/out_of_stock", 1090 | scopes: ["read_products", "write_products"], 1091 | approval: true, 1092 | graphql_topic: "VARIANTS_OUT_OF_STOCK", 1093 | }, 1094 | ]; 1095 | 1096 | const webhookWriter = (config) => { 1097 | let subscriptionsArray = []; 1098 | for (const entry in shopify.user.webhooks) { 1099 | const subscription = { 1100 | topics: shopify.user.webhooks[entry].topics, 1101 | uri: shopify.user.webhooks[entry].url.startsWith("/api/webhooks/") 1102 | ? `${process.env.SHOPIFY_APP_URL}${shopify.user.webhooks[entry].url}` 1103 | : shopify.user.webhooks[entry].url, 1104 | }; 1105 | 1106 | if (shopify.user.webhooks[entry].include_fields) { 1107 | subscription.include_fields = shopify.user.webhooks[entry].include_fields; 1108 | } 1109 | 1110 | if (shopify.user.webhooks[entry].filter) { 1111 | subscription.filter = shopify.user.webhooks[entry].filter; 1112 | } 1113 | 1114 | subscriptionsArray.push(subscription); 1115 | } 1116 | 1117 | config.webhooks.subscriptions = [...subscriptionsArray]; 1118 | 1119 | writeToApi(); 1120 | }; 1121 | 1122 | const shopifyFilePath = path.join(process.cwd(), "utils", "shopify.js"); 1123 | const webhookTopicFilePath = path.join( 1124 | process.cwd(), 1125 | "pages", 1126 | "api", 1127 | "webhooks", 1128 | "[...webhookTopic].js" 1129 | ); 1130 | 1131 | async function writeToApi() { 1132 | try { 1133 | const shopifyFileContent = fs.readFileSync(shopifyFilePath, "utf8"); 1134 | const webhookImports = shopifyFileContent.match( 1135 | /import .* from "\.\/webhooks\/.*";/g 1136 | ); 1137 | 1138 | let webhookTopicFileContent = fs.readFileSync(webhookTopicFilePath, "utf8"); 1139 | 1140 | const topComment = `/** 1141 | * DO NOT EDIT THIS FILE DIRECTLY 1142 | * Head over to utils/shopify.js to create your webhooks 1143 | * and write your webhook functions in utils/webhooks. 1144 | * If you don't know the format, use the \`createwebhook\` snippet when using VSCode 1145 | * to get a boilerplate function for webhooks. 1146 | * To update this file, run \`npm run update:config\` or \`bun run update:config\` 1147 | */\n\n`; 1148 | 1149 | // Remove the existing comment if it's already there 1150 | webhookTopicFileContent = webhookTopicFileContent.replace( 1151 | /\/\*\*[\s\S]*?\*\/\s*/, 1152 | "" 1153 | ); 1154 | 1155 | // Add the comment at the top of the file 1156 | webhookTopicFileContent = topComment + webhookTopicFileContent; 1157 | 1158 | // Remove all existing webhook imports 1159 | webhookTopicFileContent = webhookTopicFileContent.replace( 1160 | /import .* from "@\/utils\/webhooks\/.*";\n/g, 1161 | "" 1162 | ); 1163 | 1164 | // Add new imports 1165 | if (webhookImports) { 1166 | webhookImports.forEach((importStatement) => { 1167 | const formattedImportStatement = importStatement.replace( 1168 | "./webhooks", 1169 | "@/utils/webhooks" 1170 | ); 1171 | webhookTopicFileContent = 1172 | topComment + 1173 | formattedImportStatement + 1174 | "\n" + 1175 | webhookTopicFileContent.replace(topComment, ""); 1176 | }); 1177 | } 1178 | 1179 | // Check for duplicate topics 1180 | const topicCounts = {}; 1181 | shopify.user.webhooks.forEach((webhook) => { 1182 | webhook.topics.forEach((topic) => { 1183 | topicCounts[topic] = (topicCounts[topic] || 0) + 1; 1184 | }); 1185 | }); 1186 | 1187 | const hasDuplicateTopics = Object.values(topicCounts).some( 1188 | (count) => count > 1 1189 | ); 1190 | 1191 | // Generate the switch/case statement 1192 | let switchCaseStatement = hasDuplicateTopics 1193 | ? "switch (req.url) {\n" 1194 | : "switch (validateWebhook.topic) {\n"; 1195 | 1196 | for (const entry of shopify.user.webhooks) { 1197 | if (entry.url.startsWith("/api/webhooks")) { 1198 | const handlerName = entry.callback.name; 1199 | if (hasDuplicateTopics) { 1200 | switchCaseStatement += ` case "${entry.url}":\n`; 1201 | switchCaseStatement += ` ${handlerName}(validateWebhook.topic, shop, rawBody, webhookId, apiVersion);\n`; 1202 | switchCaseStatement += ` break;\n`; 1203 | } else { 1204 | entry.topics.forEach((topic, index) => { 1205 | const topicCase = 1206 | topicsAndScopes.find((t) => t.topic === topic)?.graphql_topic || 1207 | topic.toUpperCase().replace("/", "_"); 1208 | switchCaseStatement += ` case "${topicCase}":\n`; 1209 | if (index === entry.topics.length - 1) { 1210 | switchCaseStatement += ` ${handlerName}(validateWebhook.topic, shop, rawBody, webhookId, apiVersion);\n`; 1211 | switchCaseStatement += ` break;\n`; 1212 | } 1213 | }); 1214 | } 1215 | } 1216 | } 1217 | switchCaseStatement += ` default:\n`; 1218 | switchCaseStatement += ` throw new Error(\`Can't find a handler for \${${ 1219 | hasDuplicateTopics ? "req.url" : "topic" 1220 | }}\`);\n`; 1221 | switchCaseStatement += "}\n"; 1222 | 1223 | // Replace the existing switch/case statement 1224 | const switchCaseRegex = /\/\/SWITCHCASE\n[\s\S]*?\/\/SWITCHCASE END/; 1225 | webhookTopicFileContent = webhookTopicFileContent.replace( 1226 | switchCaseRegex, 1227 | `//SWITCHCASE\n${switchCaseStatement}//SWITCHCASE END` 1228 | ); 1229 | 1230 | fs.writeFileSync(webhookTopicFilePath, webhookTopicFileContent, "utf8"); 1231 | } catch (error) { 1232 | console.error("Error writing to webhookTopic file:", error); 1233 | } 1234 | } 1235 | 1236 | export default webhookWriter; 1237 | -------------------------------------------------------------------------------- /nextjs/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinngh/shopify-wishlist-live-app/8f0ec8ac4fd884e614a876625992d0626f58d4af/nextjs/components/.gitkeep -------------------------------------------------------------------------------- /nextjs/components/home/SetupGuide.module.css: -------------------------------------------------------------------------------- 1 | /* If using CSS modules in Remix, make sure you have properly configured your project (https://remix.run/docs/en/main/styling/css-modules#css-modules) */ 2 | 3 | .setupItem { 4 | padding: 0.25rem 0.5rem; 5 | border-radius: 0.5rem; 6 | } 7 | 8 | .setupItem:hover { 9 | background-color: #f7f7f7; 10 | } 11 | 12 | .setupItemExpanded:hover { 13 | background-color: inherit; 14 | } 15 | 16 | .completeButton { 17 | width: 1.5rem; 18 | height: 1.5rem; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | color: #303030; 23 | } 24 | 25 | .itemContent { 26 | width: 100%; 27 | display: flex; 28 | gap: 8rem; 29 | justify-content: space-between; 30 | } 31 | 32 | /* These styles take into account the Shopify sidebar visibility & hides image based on window width */ 33 | @media (min-width: 48em) and (max-width: 61.871875em) { 34 | .itemImage { 35 | display: none; 36 | } 37 | } 38 | 39 | @media (max-width: 45.625em) { 40 | .itemImage { 41 | display: none; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /nextjs/components/home/SetupGuideComponent.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionList, 3 | BlockStack, 4 | Box, 5 | Button, 6 | ButtonGroup, 7 | Card, 8 | Collapsible, 9 | Icon, 10 | Image, 11 | InlineStack, 12 | Popover, 13 | ProgressBar, 14 | Spinner, 15 | Text, 16 | Tooltip, 17 | } from "@shopify/polaris"; 18 | import { 19 | CheckIcon, 20 | ChevronDownIcon, 21 | ChevronUpIcon, 22 | MenuHorizontalIcon, 23 | XIcon, 24 | } from "@shopify/polaris-icons"; 25 | import { useId, useState } from "react"; 26 | import styles from "./SetupGuide.module.css"; 27 | 28 | const SetupGuideComponent = ({ onDismiss, onStepComplete, items }) => { 29 | const [expanded, setExpanded] = useState( 30 | items.findIndex((item) => !item.complete) 31 | ); 32 | const [isGuideOpen, setIsGuideOpen] = useState(true); 33 | const [popoverActive, setPopoverActive] = useState(false); 34 | const accessId = useId(); 35 | const completedItemsLength = items.filter((item) => item.complete).length; 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | Setup Guide 44 | 45 | 46 | setPopoverActive((prev) => !prev)} 49 | activator={ 50 | 161 | 162 | 163 | ) : null} 164 | 165 | ); 166 | }; 167 | 168 | const SetupItem = ({ 169 | complete, 170 | onComplete, 171 | expanded, 172 | setExpanded, 173 | title, 174 | description, 175 | image, 176 | primaryButton, 177 | secondaryButton, 178 | id, 179 | }) => { 180 | const [loading, setLoading] = useState(false); 181 | 182 | const completeItem = async () => { 183 | setLoading(true); 184 | await onComplete(id); 185 | setLoading(false); 186 | }; 187 | 188 | return ( 189 | 190 |
193 | 194 | 198 | 220 | 221 |
null : setExpanded} 224 | style={{ 225 | cursor: expanded ? "default" : "pointer", 226 | paddingTop: ".15rem", 227 | width: "100%", 228 | }} 229 | > 230 | 231 | 232 | {title} 233 | 234 | 235 | 236 | 237 | 238 | {description} 239 | 240 | {primaryButton || secondaryButton ? ( 241 | 242 | {primaryButton ? ( 243 | 246 | ) : null} 247 | {secondaryButton ? ( 248 | 251 | ) : null} 252 | 253 | ) : null} 254 | 255 | 256 | 257 | 258 | {image && expanded ? ( // hide image at 700px down 259 | {image.alt} 265 | ) : null} 266 |
267 |
268 |
269 |
270 | ); 271 | }; 272 | 273 | const outlineSvg = ( 274 | 281 | 288 | 294 | 301 | 310 | 311 | ); 312 | 313 | export default SetupGuideComponent; 314 | -------------------------------------------------------------------------------- /nextjs/components/hooks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinngh/shopify-wishlist-live-app/8f0ec8ac4fd884e614a876625992d0626f58d4af/nextjs/components/hooks/.gitkeep -------------------------------------------------------------------------------- /nextjs/components/providers/AppBridgeProvider.jsx: -------------------------------------------------------------------------------- 1 | const AppBridgeProvider = ({ children }) => { 2 | if (typeof window !== "undefined") { 3 | const shop = window?.shopify?.config?.shop; 4 | 5 | if (!shop) { 6 | return

No Shop Provided

; 7 | } 8 | } 9 | 10 | return <>{children}; 11 | }; 12 | 13 | export default AppBridgeProvider; 14 | -------------------------------------------------------------------------------- /nextjs/docs/EXTENSIONS.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | Setting up extensions requires a shake up of the folder structure. 4 | 5 | - Create a new folder called `app/` that includes everything except for `docs/` and `.git/` folders. 6 | 7 | - You might have to enable viewing hidden files in your system. 8 | - macOS: Command + Shift + . 9 | - Linux: Control + H 10 | - Windows: https://kinsta.com/blog/show-hidden-files 11 | 12 | - Create a new folder called `extension/` in your root. 13 | 14 | - Ensure `app/` and `extension/` are in the same level. 15 | 16 | - Create a new `package.json` file by using `npm init --y` or manually creating a new file named `package.json` in your root and `extension/`. 17 | 18 | - Head into `app/package.json` and update `update:config` script to `node _developer/tomlWriter.js && cd .. && shopify app deploy` 19 | 20 | - I've tested it to work with `bun` and `npm` or other package managers may cause issue. If CLI doesn't detect your extensions, you can either switch to `bun` or change the `update:config` script to only `node _developer/tomlWriter.js` and run `shopify app deploy` from the root. 21 | - More information on this is [available here](https://github.com/kinngh/shopify-nextjs-prisma-app/discussions/53) 22 | 23 | - Now in your `extension/package.json`, it's great to have this script so it's easier to create new extensions, which will put your extension in `extension/extensions/extension-name`: 24 | 25 | ```javascript 26 | "generate": "shopify app generate extension" 27 | ``` 28 | 29 | ## Notes 30 | 31 | For visual reference, this is what we're expecting the folder structure to look like: 32 | 33 | Simplified: 34 | 35 | ![Screenshot 2024-03-09 at 12 23 17 PM](https://github.com/kinngh/shopify-nextjs-prisma-app/assets/773555/462479bd-360f-49cb-aed7-b8b1c85ab5a1) 36 | 37 | Detailed: 38 | 39 | ![Screenshot 2024-03-09 at 12 23 11 PM](https://github.com/kinngh/shopify-nextjs-prisma-app/assets/773555/2af3463f-fe9f-4c88-841c-9f15bbf72474) 40 | 41 | The `npm` vs `bun` difference once you update the script: 42 | ![npm v bun](https://github.com/kinngh/shopify-nextjs-prisma-app/assets/773555/8781d757-92b3-4f26-9aff-79b200920365) 43 | -------------------------------------------------------------------------------- /nextjs/docs/NOTES.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | ## APIs 4 | 5 | - All your APIs need to use middlewares. For App Proxy, it's `export default withMiddleware("verifyProxy")(handler)` and for Apps route (the regular backend) it's `export default withMiddleware("verifyRequest")(handler);`. 6 | - Example implementations are available in `pages/api/proxy_route/json.js` and `pages/api/apps/index.js` 7 | - If you don't use the middlewares your app will be rejected and it's insecure to run APIs without context. 8 | 9 | ## Webhooks 10 | 11 | - Processing webhooks has changed a bit, if you're coming from the Mongo repo available [here](https://github.com/kinngh/shopify-node-express-mongodb-app). 12 | - To add your webhooks, head over to `utils/shopify.js` and at the bottom add all your webhooks and handlers. 13 | - I recommend offloading webhooks handling (except `APP_UNINSTALLED`) to a different service like Google PubSub, AWS EventBridge or Cloudflare Workers so if you're building at scale, you can handle the insane amounts of webhooks that you could be processing, during times like Black Friday when your app is put to test. 14 | 15 | ## Next.js 16 | 17 | - If you're unsure about what gets shipped to browser, check out [Next.js Code Elimination Tool](https://next-code-elimination.vercel.app) 18 | -------------------------------------------------------------------------------- /nextjs/docs/SETUP.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | This is an in-depth guide on using this repo. This goes over getting the base repo up and running, to understand how to add your own customizations server side like registering webhooks, routes, etc, refer to [Notes](/docs/NOTES.md). 4 | 5 | - [ ] Run `npm run g:install` to install global dependencies. 6 | 7 | - This isn't required to be run every single time, but is necessary for local development - Installing the Shopify/app and Shopify/cli packages. Please see `package.json` for more info. 8 | 9 | - [ ] Run `npm i --force` to install dependencies. 10 | 11 | - Substantial efforts have gone into ensuring we're using the latest package versions, and some incompatibility issues always pop up while installing. There are no negative effects on the functionality just yet, but if you find anything please open an issue. 12 | 13 | - [ ] Create a new app (Public or Custom) from your [Shopify Partner Dashboard](https://partners.shopify.com). 14 | 15 | - [ ] Build your `.env` file based on `.env.example`. 16 | 17 | - `SHOPIFY_API_KEY`: App API key. 18 | - `SHOPIFY_API_SECRET`: App secret. 19 | - `SHOPIFY_API_SCOPES`: Scopes required by your Shopify app. A list of access scopes can be found [here](https://shopify.dev/api/usage/access-scopes) 20 | - `SHOPIFY_APP_URL`: URL generated from Ngrok. 21 | - `SHOPIFY_API_VERSION`: Pre-filled to the latest version. All the calls in the repo are based off this API version so if you're downgrading please refer to the official docs instead. The repo is always kept up to date with the newest practices so you can rely on the basic repo to almost always work without depriciation errors popping up. 22 | - `DATABASE_URL`: Database connection URL. Since we're using Prisma ORM with this repo, it supports SQL and noSQL databases. Read more about it [here](https://www.prisma.io/stack) 23 | - `ENCRYPTION_STRING`: String to use for Cryption for encrypting sessions token. Add a random salt (or a random string of letters and numbers) and save it. If you loose the string you cannot decrypt your sessions and must be kept safely. 24 | - `APP_NAME`: Name of your app, as you've entered in Partner Dashboard. 25 | - `APP_HANDLE`: The URL handle of your app. 26 | - `APP_PROXY_PREFIX`: The prefix for your App Proxy's path, can be one of these: 27 | - apps 28 | - a 29 | - community 30 | - tools 31 | - `APP_PROXY_SUBPATH`: Subpath for your app proxy. 32 | - Leave `APP_PROXY_PREFIX` or `APP_PROXY_SUBPATH` blank and no App Proxy entries are created. 33 | - `POS_EMBEDDED`: Boolean. If your app is embedded in Shopify Point of Sale. 34 | 35 | - [ ] NPM Scripts 36 | 37 | - `dev`: Run in dev mode 38 | - `build`: Build for production 39 | - `start`: Start in production mode. Requires `npm run build` before starting. 40 | - `pretty`: Run `prettier` on the entire project. 41 | - `update`: Force updates all packages to latest version and requires you to manually run `npm i --force` after. Not recommended if you don't know what you're doing. 42 | - 43 | - `ngrok:auth`: Replace `` with your ngrok token and run it to activate ngrok. 44 | - `ngrok`: Starts ngrok on port 3000. 45 | - `cloudflare`: Starts cloudflare tunnel on port 3000 (make sure you have `cloudflared` installed). 46 | - 47 | - `g:install`: Required global installs for buildling Shopify apps. 48 | - `shopify`: Run `shopify` commands 49 | - `update:config`: [Managed Installation] Use the Shopify CLI to update your configuration. Auto writes your `toml` file to root and `extension/` for syncing. 50 | - `update:url`: [OAuth Installation] Use `@shopify/cli-kit` to update URLs to your Shopify partner dashboard. Requires a proper setup of `.env` file. 51 | - 52 | - `pg:create`: Create a new folder `database` and init a PostgreSQL instance. Requires you to have postgres installed. 53 | - Run `brew install postgresql` 54 | - `pg:start`: Start a PostgreSQL instance on `database`. Requires you to run `npm run pg:create` before you can do this. 55 | - `pg:stop`: Stop PostgreSQL server. 56 | - 57 | - `prisma`: Generic command to access `prisma` commands. 58 | - `prisma:push`: Push `schema.prisma` to your `DATABASE_URL` database. 59 | - `prisma:pull`: Pull database schema from `DATABASE_URL` database and generates a `schema.prisma` file. 60 | - 61 | - `prepare`: Reserved script to generate `@prisma/client`. 62 | 63 | - [ ] Setup Partner Dashboard 64 | 65 | - Run `npm run ngrok` or `npm run cloudflare` to generate your subdomain. Copy the `https://` domain and add it in `SHOPIFY_APP_URL` in your `.env` file. 66 | - Run `npm run update:config` to create and update your `shopify.app.toml` file and sync with Shopify. 67 | - GPDR handlers are available at `page/api/gdpr/` and the URLs that are auto registered via your toml are are: 68 | - Customers Data Request: `https:///api/gdpr/customers_data_request` 69 | - Customers Redact: `https:///api/gdpr/customers_redact` 70 | - Shop Redact: `https:///api/gdpr/shop_redact` 71 | - App Proxy routes are setup to allow accessing data from your app directly from the store. An example proxy route has been setup and is available at `/pages/api/proxy_route`. First you need to setup your base urls. Here's how to get it working: 72 | 73 | - Subpath Prefix: `apps` [fill in env] 74 | - Subpath: `next-proxy` [fill in env] 75 | - Proxy URL: `https:///api/proxy_route` [auto filled by `_developer/tomlWriter.js`] 76 | 77 | - So when a merchant visits `https://shop-url.com/apps/next-proxy/`, the response to that request will come from `https:///api/proxy_route`. A middleware has already been setup to check signatures so you don't have to worry about authenticating proxy calls, and is available at `utils/middleware/verifyProxy.js`. 78 | - Subsequently, any child requests will be mapped the same way. A call to `https://shop-url.com/apps/next-proxy/json` will be routed to `https:///api/proxy_route/json`. 79 | - To confirm if you've setup app proxy properly, head over to `https://shop-url.myshopify.com/apps/next-proxy/json` to confirm if you get a JSON being returned with the configuration set above^ 80 | - A common _gotcha_ is if you're creating multiple apps that all use the same subpath (`next-proxy` in this case), all susbequent installs will throw a `404` error because Shopify serializes routes based on installation. To avoid this, please change the subpath to something that's unique to your app. I prefer using the format `<>-proxy` 81 | 82 | - [ ] Running App 83 | 84 | - If it's your first time connecting to said database, run `npx prisma db push` to get your database working. 85 | - Run `npm run dev`, your database and ngrok/cloudflare. 86 | - Install the app by heading over to `https://storename.myshopify.com/admin/oauth/install?client_id=SHOPIFY_API_KEY`. 87 | 88 | - [ ] Setting up extensions 89 | - See [Extensions](./EXTENSIONS.md) 90 | -------------------------------------------------------------------------------- /nextjs/docs/SNIPPETS.md: -------------------------------------------------------------------------------- 1 | # Snippets 2 | 3 | A collection of snippets to quickly create further boilerplate code with relevant functions and checks. 4 | 5 | I selected these snippets from my regular use and code that I was writing over and over again. Instead of again including a `__templates/` directory that was causing a lot of confusion, it made more sense to add this as a workspace snippet library instead. This also avoided the issue of creating and maintaining extensions. 6 | 7 | ## Snippets 8 | 9 | | Snippet | Description | 10 | | ------------------------ | ----------------------------------------------- | 11 | | `sfc` | Create an arrow function component | 12 | | `createNewPage` | Create a new Polaris page with a Card | 13 | | `createapi` | Create a new endpoint for `/api` | 14 | | `createproxy` | Create a new endpoint for `/api/proxy_route` | 15 | | `createwebhook` | Create a new webhook function | 16 | | `createOnlineClientGql` | Create a new GraphQL Client with online tokens | 17 | | `createOfflineClientGql` | Create a new GraphQL Client with offline tokens | 18 | -------------------------------------------------------------------------------- /nextjs/docs/migration/app-bridge-cdn.md: -------------------------------------------------------------------------------- 1 | # App Bridge CDN Migration Guide 2 | 3 | Moving from App Bridge React to CDN is pretty straight forward and not as dauting of a task. 4 | 5 | 1. Uninstall `@shopify/app-bridge` and `@shopify/app-bridge-react` packages. This is being replaced by import AppBridge from Shopify's CDN in `pages/_document.js` file. 6 | 2. `` is now `...` that takes a series of `...` or for Next.js specifically, `...` tags. 7 | 3. `AppBridgeProvider` is now just a check to see if the `shop` exists. We do this to stop the base URL of our app be accessible without a shop. 8 | 4. There's no need to use `useNavigate()` or `Redirect.app` anymore. Using `open()` works as expected. You can see examples of this sprinkled throughout the debug cards and exitframe. 9 | 5. Using the new Resource Picker can come as a bit of a challenge. A great way to use it is to encapsulate the resource picker in an async funtion and instead of returning values, update your state variables to account in for the change. I'll be adding in an example for this in the future. 10 | 11 | ## Misc upgrades 12 | 13 | 1. `useFetch` hook got an update to be more reliable. We're now passing different headers that don't rely on Shopify/AppBridge to handle, making it more reliable. Your use of `useFetch` hook doesn't change, just drop in the new `useFetch` hook and you're good to go. 14 | 2. `/exitframe/[...shop].js` has reliability changes. You might want to update the components in there for better clarity on reauthorization process. 15 | 3. Legacy Polaris elements are replaced with newer ones. 16 | 4. `verifyRequest` now checks for scope changes too. In dev mode, you might want to kill your dev server and restart for env to properly take effect, or you'll be stuck in an endless auth loop. 17 | 5. `pages/api/auth` have new changes. It's meant to be a drop in replacement so just replace your existing files with the new ones, and add in your changes. 18 | 6. Debug cards have been renamed and refreshed for better clarity and examples. 19 | -------------------------------------------------------------------------------- /nextjs/docs/migration/clientProvider.md: -------------------------------------------------------------------------------- 1 | # Client Provider 2 | 3 | The `clientProvider` abstraction has gotten a makeover to simplify the API. Instead of exposting `graphqlClient` and `restClient` functions, it now has a namespace that contains both `online` and `offline` objects. Each object contains a `graphqlClient` and `restClient` function that can be used to create a client for the respective access mode. 4 | 5 | ## Usage 6 | 7 | ### Online Client 8 | 9 | ```javascript 10 | import clientProvider from "@/utils/clientProvider"; 11 | 12 | const { client, shop, session } = await clientProvider.online.graphqlClient({ 13 | req, 14 | res, 15 | }); 16 | 17 | const { client, shop, session } = await clientProvider.online.restClient({ 18 | req, 19 | res, 20 | }); 21 | ``` 22 | 23 | ### Offline Client 24 | 25 | ```javascript 26 | import clientProvider from "@/utils/clientProvider"; 27 | 28 | const { client, shop, session } = await clientProvider.offline.graphqlClient({ 29 | shop: req.user_shop, 30 | }); 31 | 32 | const { client, shop, session } = await clientProvider.offline.restClient({ 33 | shop: req.user_shop, 34 | }); 35 | ``` 36 | 37 | ## Steps 38 | 39 | 1. Head into `verifyRequest` and add in `req.user_shop = session.shop` after `req.user_session = session;`. 40 | 2. Update your `graphqlClient` and `restClient` calls to `clientProvider.online.graphqlClient` / `clientProvider.offline.graphqlClient` and `clientProvider.online.restClient` / `clientProvider.offline.restClient` 41 | -------------------------------------------------------------------------------- /nextjs/docs/migration/managed-webhooks.md: -------------------------------------------------------------------------------- 1 | # Managed Webhooks 2 | 3 | We (finally) have the ability to decide what webhooks we want to recieve and what to exclude. This has some great implications, the major one being less compute resources being used so your servers cost less. 4 | 5 | ## Migration 6 | 7 | ### Files 8 | 9 | While all of this is going in a single commit, here's the entire log on what's changed and the reasoning behind it: 10 | 11 | - `isInitialLoad.js` 12 | We no longer need the `webhookRegistrar` function to register webhooks. So now that we're not making that GraphQL call to register webhooks, the initial load times of the app is much faster, helping with overall LCP. 13 | 14 | - `[...webhookTopic].js` 15 | This is an autogenrated file now. It creates a `switch/case` statement to handle all your webhook landings. This won't contain a case if you're using an external HTTP server, AWS EventBridge or Google PubSub, since the `switch/case` only handles requests that come to this server. 16 | 17 | - `shopify.js` 18 | Instead of using the `addHandlers()` function, we're now passing it as `shopify.user.webhooks`, which doesn't affect how the baseline works and this isn't that big of a change, but how you declare webhook topics have changed. 19 | 20 | - `tomlWriter.js` and `webhookWriter.js` 21 | This is the secret sauce of parsing files and creating TOMLs and other configuration. I highly suggest to not mess with this. 22 | 23 | ### GraphQL to TOML 24 | 25 | - Managed webhooks don't show up when you make the GQL call to get active webhooks, so the webhooks Debug card is now useless. 26 | - If you're migrating your live project from `webhookRegistrar()` / GraphQL based webhooks to managed webhooks, you need to manually remove the older webhooks, or both the webhooks are going to fire twice. 27 | 28 | ## Gotchas 29 | 30 | - Filter `:` is actually `=` and not a fuzzy search, unlike the rest of theShopify Search API it's following 31 | - Scope errors requires multiple pushes sometimes. Comment the webhooks in `shopify.js`, run `bun run update:config` to push your access scopes, uncomment the webhooks and run `bun run update:config` again and it'll work as expected. 32 | -------------------------------------------------------------------------------- /nextjs/docs/migration/oauth-to-managed-installation.md: -------------------------------------------------------------------------------- 1 | # OAuth to Managed Installation 2 | 3 | Shopify introduced `Shopify Managed Installation` to get rid of screen flickering during auth, unnecessary auth redirects while fetching online sessions and other misc issues. To oversimplify, you pass the access tokens to Shopify and get Session tokens in return like a regular fetch and save it in your database. Here's a rundown of what's changed: 4 | 5 | 1. Changes in Auth 6 | 7 | The older way of doing auth is still supported from Shopify but for embedded apps in this repo, it's gone and so are the files. I've completely removed the older strait to run auth. 8 | 9 | - `pages/api/auth/index.js`: Removed. 10 | - `pages/api/auth/token.js`: Removed. 11 | - `pages/api/auth/callback.js`: Removed. 12 | 13 | 2. Updates to `isShopAvailable` function 14 | 15 | `pages/index.jsx` now calls a new function, `isInitialLoad` instead of `isShopAvailable`. The new strait means on the first load we get `id_token` as a query param that is exchanged for online and offline session tokens. `isInitialLoad` checks if these params exist, exchanges them for online and offline tokens and saves them in the db. Note we do not await the `sessionHandler.storeSession()` function here because that's eating up time on initial load and we don't want the first time to take longer than ~3 seconds. 16 | 17 | A new check also happens here, `isFreshInstall`. Since the database structure is kept the same to ensure smooth transition to the new auth, we can now check if the install was a fresh one. If the store doesn't exist in the `store` model, it's a new install, but if it does have a `Bool` value, that means it's either already installed or is a reinstall. While I've merged these in an if condition, you can break them apart and run your own checks if required. 18 | 19 | ```javascript 20 | if (!isFreshInstall || isFreshInstall?.isActive === false) { 21 | // !isFreshInstall -> New Install 22 | // isFreshInstall?.isActive === false -> Reinstall 23 | await freshInstall({ shop: onlineSession.shop }); 24 | } 25 | ``` 26 | 27 | This is now followed up with a `props` return since `getServerSideProps` has to return it. 28 | 29 | 3. Changes to `verifyRequest` and `ExitFrame` 30 | 31 | The `verifyRequest()` middleware now works completely differently. First we check for `authorization` headers in each `fetch()` since App Bridge CDN automatically adds headers to each `fetch`. Then a JWT validation is run to ensure the headers are valid, followed by getting the session id and rading from the database, check for expiry and fetch new tokens if the online tokens have expired. Then pass the session to use in subsequent routes as `req.user_session` and the middleware is done. 32 | 33 | A great thing about this is `ExitFrame` doesn't exist anymore. If the tokens are invalid, we throw a `401` and if the tokens are expired, we fetch them and move on to the next set. 34 | 35 | 4. Quick auth URL 36 | 37 | The quick auth URL has gotten an update. We've moved from `https://appurl.com/api/auth?shop=storename.myshopify.com` to `https://storename.myshopify.com/admin/oauth/install?client_id=SHOPIFY_API_KEY`, which now takes the merchant to the install screen. 38 | 39 | 5. Depricating `useFetch()` hook 40 | 41 | The idea of `useFetch()` was to redirect towards `ExitFrame` if the tokens had expired or not found - this is not required anymore. All vanilla `fetch` requests work since AppBridge CDN adds in authorization headers in the background. 42 | 43 | 6. Thoughts 44 | 45 | Managed installation is great. No flickering, no running through ExitFrame, it's 10/10 all around. The only problem is now you don't get a hit when someone comes over to the permissions screen and are only made aware of the store when the permissions are approved. The new `tomlWriter` was built so that you are still only relying on your `env` and that's writing your `shopify.app.toml` file to root (and `extension/` folder). It took a second to wrap my head around but once you get the hang of it, it's great. 46 | -------------------------------------------------------------------------------- /nextjs/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["./*"], 6 | "@/utils": ["./utils/*"], 7 | "@/components": ["./components/*"], 8 | "@/hooks": ["./components/hooks/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /nextjs/middleware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware to add Content Security Policy headers to matched requests. 3 | */ 4 | 5 | import { NextResponse } from "next/server"; 6 | 7 | export const config = { 8 | matcher: [ 9 | /* 10 | * Exceptions: 11 | * /api/auth, /api/webhooks, /api/proxy_route, /api/gdpr, /_next, 12 | * /_proxy, /_auth, /_static, /_vercel, /public (/favicon.ico, etc) 13 | */ 14 | "/((?!api/auth|api/webhooks|api/proxy_route|api/gdpr|_next|_proxy|_auth|_static|_vercel|[\\w-]+\\.\\w+).*)", 15 | ], 16 | }; 17 | 18 | /** 19 | * @param {NextRequest} request - The incoming request object. 20 | * @returns {NextResponse} The response object with modified headers. 21 | */ 22 | export function middleware(request) { 23 | const { 24 | nextUrl: { search }, 25 | } = request; 26 | 27 | /** 28 | * Convert the query string into an object. 29 | * @type {URLSearchParams} 30 | */ 31 | const urlSearchParams = new URLSearchParams(search); 32 | const params = Object.fromEntries(urlSearchParams.entries()); 33 | 34 | const shop = params.shop || "*.myshopify.com"; 35 | 36 | /** 37 | * Construct the Next.js response and set the Content-Security-Policy header. 38 | * @type {NextResponse} 39 | */ 40 | const res = NextResponse.next(); 41 | res.headers.set( 42 | "Content-Security-Policy", 43 | `frame-ancestors https://${shop} https://admin.shopify.com;` 44 | ); 45 | 46 | return res; 47 | } 48 | -------------------------------------------------------------------------------- /nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | import "@shopify/shopify-api/adapters/node"; 4 | import setupCheck from "./utils/setupCheck.js"; 5 | 6 | setupCheck(); 7 | 8 | console.log(`--> Running in ${process.env.NODE_ENV} mode`); 9 | 10 | const nextConfig = { 11 | reactStrictMode: true, 12 | env: { 13 | CONFIG_SHOPIFY_API_KEY: process.env.SHOPIFY_API_KEY, 14 | CONFIG_SHOPIFY_APP_URL: process.env.SHOPIFY_APP_URL, 15 | }, 16 | }; 17 | 18 | export default nextConfig; 19 | -------------------------------------------------------------------------------- /nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-nextjs-prisma-app", 3 | "version": "2024.08.31", 4 | "type": "module", 5 | "author": { 6 | "name": "Harshdeep Singh Hura", 7 | "url": "https://harshdeephura.com" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/kinngh/shopify-nextjs-prisma-app.git" 12 | }, 13 | "scripts": { 14 | "dev": "next dev", 15 | "build": "next build", 16 | "start": "next start", 17 | "pretty": "prettier --write ./", 18 | "update": "ncu -u", 19 | "-----> Tunnel <-----": "", 20 | "ngrok:auth": "ngrok authtoken ", 21 | "ngrok": "ngrok http 3000", 22 | "cloudflare": "cloudflared tunnel --url localhost:3000", 23 | "-----> Shopify <-----": "", 24 | "g:install": "npm i -g @shopify/app@latest @shopify/cli@latest", 25 | "shopify": "shopify", 26 | "update:config": "node _developer/tomlWriter.js && shopify app deploy; npm run pretty", 27 | "update:url": "node _developer/updateDashboard.js", 28 | "-----> Database <-----": "", 29 | "pg:create": "mkdir database; pg_ctl -D database init", 30 | "pg:start": "pg_ctl -D database start", 31 | "pg:stop": "pg_ctl -D database stop", 32 | "-----> Prisma <-----": "", 33 | "prisma": "npx prisma", 34 | "prisma:push": "npx prisma db push", 35 | "prisma:pull": "npx prisma db pull", 36 | "-----> Reserved Scripts <-----": "", 37 | "prepare": "npx prisma generate" 38 | }, 39 | "dependencies": { 40 | "@prisma/client": "^5.20.0", 41 | "@shopify/polaris": "^13.9.0", 42 | "@shopify/shopify-api": "^11.4.1", 43 | "cryptr": "^6.3.0", 44 | "next": "14.2.13", 45 | "next-api-middleware": "^3.0.0", 46 | "react": "18.3.1", 47 | "react-dom": "18.3.1" 48 | }, 49 | "devDependencies": { 50 | "@iarna/toml": "^2.2.5", 51 | "@shopify/cli-kit": "^3.67.2", 52 | "dotenv": "^16.4.5", 53 | "ngrok": "^5.0.0-beta.2", 54 | "npm-check-updates": "^17.1.3", 55 | "prettier": "^3.3.3", 56 | "prisma": "^5.20.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /nextjs/pages/_app.js: -------------------------------------------------------------------------------- 1 | import AppBridgeProvider from "@/components/providers/AppBridgeProvider"; 2 | import { AppProvider as PolarisProvider } from "@shopify/polaris"; 3 | import "@shopify/polaris/build/esm/styles.css"; 4 | import translations from "@shopify/polaris/locales/en.json"; 5 | import Link from "next/link"; 6 | 7 | const App = ({ Component, pageProps }) => { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | Debug Cards 14 | Info 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default App; 24 | -------------------------------------------------------------------------------- /nextjs/pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /nextjs/pages/api/apps/debug/activeWebhooks.js: -------------------------------------------------------------------------------- 1 | import clientProvider from "@/utils/clientProvider"; 2 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 3 | 4 | /** 5 | * @param {import("next").NextApiRequest} req - The HTTP request object. 6 | * @param {import("next").NextApiResponse} res - The HTTP response object. 7 | */ 8 | const handler = async (req, res) => { 9 | if (req.method === "GET") { 10 | try { 11 | const { client } = await clientProvider.online.graphqlClient({ 12 | req, 13 | res, 14 | }); 15 | const activeWebhooks = await client.request( 16 | `{ 17 | webhookSubscriptions(first: 25) { 18 | edges { 19 | node { 20 | topic 21 | endpoint { 22 | __typename 23 | ... on WebhookHttpEndpoint { 24 | callbackUrl 25 | } 26 | } 27 | } 28 | } 29 | } 30 | }` 31 | ); 32 | return res.status(200).send(activeWebhooks); 33 | } catch (e) { 34 | console.error(`---> An error occured`, e); 35 | return res.status(400).send({ text: "Bad request" }); 36 | } 37 | } else { 38 | res.status(400).send({ text: "Bad request" }); 39 | } 40 | }; 41 | 42 | export default withMiddleware("verifyRequest")(handler); 43 | -------------------------------------------------------------------------------- /nextjs/pages/api/apps/debug/createNewSubscription.js: -------------------------------------------------------------------------------- 1 | import clientProvider from "@/utils/clientProvider"; 2 | import withMiddleware from "@/utils/middleware/withMiddleware"; 3 | 4 | /** 5 | * @param {import("next").NextApiRequest} req - The HTTP request object. 6 | * @param {import("next").NextApiResponse} res - The HTTP response object. 7 | */ 8 | const handler = async (req, res) => { 9 | //false for offline session, true for online session 10 | const { client } = await clientProvider.online.graphqlClient({ 11 | req, 12 | res, 13 | }); 14 | const returnUrl = `${process.env.SHOPIFY_APP_URL}/?shop=${req.user_shop}`; 15 | 16 | const planName = "$10.25 plan"; 17 | const planPrice = 10.25; //Always a decimal 18 | 19 | const response = await client.request( 20 | `mutation CreateSubscription{ 21 | appSubscriptionCreate( 22 | name: "${planName}" 23 | returnUrl: "${returnUrl}" 24 | test: true 25 | lineItems: [ 26 | { 27 | plan: { 28 | appRecurringPricingDetails: { 29 | price: { amount: ${planPrice}, currencyCode: USD } 30 | } 31 | } 32 | } 33 | ] 34 | ) { 35 | userErrors { 36 | field 37 | message 38 | } 39 | confirmationUrl 40 | appSubscription { 41 | id 42 | status 43 | } 44 | } 45 | } 46 | ` 47 | ); 48 | 49 | if (response.data.appSubscriptionCreate.userErrors.length > 0) { 50 | console.log( 51 | `--> Error subscribing ${req.user_shop} to plan:`, 52 | response.data.appSubscriptionCreate.userErrors 53 | ); 54 | res.status(400).send({ error: "An error occured." }); 55 | return; 56 | } 57 | 58 | res.status(200).send({ 59 | confirmationUrl: `${response.data.appSubscriptionCreate.confirmationUrl}`, 60 | }); 61 | return; 62 | }; 63 | 64 | export default withMiddleware("verifyRequest")(handler); 65 | -------------------------------------------------------------------------------- /nextjs/pages/api/apps/debug/getActiveSubscriptions.js: -------------------------------------------------------------------------------- 1 | import clientProvider from "@/utils/clientProvider"; 2 | import withMiddleware from "@/utils/middleware/withMiddleware"; 3 | 4 | /** 5 | * @param {import("next").NextApiRequest} req - The HTTP request object. 6 | * @param {import("next").NextApiResponse} res - The HTTP response object. 7 | */ 8 | const handler = async (req, res) => { 9 | //false for offline session, true for online session 10 | const { client } = await clientProvider.online.graphqlClient({ 11 | req, 12 | res, 13 | }); 14 | 15 | const response = await client.request( 16 | `{ 17 | appInstallation { 18 | activeSubscriptions { 19 | name 20 | status 21 | lineItems { 22 | plan { 23 | pricingDetails { 24 | ... on AppRecurringPricing { 25 | __typename 26 | price { 27 | amount 28 | currencyCode 29 | } 30 | interval 31 | } 32 | } 33 | } 34 | } 35 | test 36 | } 37 | } 38 | }` 39 | ); 40 | 41 | res.status(200).send(response); 42 | }; 43 | 44 | export default withMiddleware("verifyRequest")(handler); 45 | -------------------------------------------------------------------------------- /nextjs/pages/api/apps/debug/gql.js: -------------------------------------------------------------------------------- 1 | import clientProvider from "@/utils/clientProvider"; 2 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 3 | 4 | /** 5 | * @param {import("next").NextApiRequest} req - The HTTP request object. 6 | * @param {import("next").NextApiResponse} res - The HTTP response object. 7 | */ 8 | const handler = async (req, res) => { 9 | if (req.method === "GET") { 10 | try { 11 | const { client } = await clientProvider.online.graphqlClient({ 12 | req, 13 | res, 14 | }); 15 | const shop = await client.request(`{shop{name}}`); 16 | return res.status(200).send({ text: shop.data.shop.name }); 17 | } catch (e) { 18 | console.error(`---> An error occured`, e); 19 | return res.status(400).send({ text: "Bad request" }); 20 | } 21 | } else { 22 | res.status(400).send({ text: "Bad request" }); 23 | } 24 | }; 25 | 26 | export default withMiddleware("verifyRequest")(handler); 27 | -------------------------------------------------------------------------------- /nextjs/pages/api/apps/debug/index.js: -------------------------------------------------------------------------------- 1 | //This is the same as `pages/api/index.js`. 2 | 3 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 4 | 5 | /** 6 | * @param {import("next").NextApiRequest} req - The HTTP request object. 7 | * @param {import("next").NextApiResponse} res - The HTTP response object. 8 | */ 9 | const handler = async (req, res) => { 10 | if (req.method === "GET") { 11 | return res 12 | .status(200) 13 | .send({ text: "This text is coming from `/api/apps route`" }); 14 | } 15 | 16 | if (req.method === "POST") { 17 | return res.status(200).send(req.body); 18 | } 19 | 20 | return res.status(400).send({ text: "Bad request" }); 21 | }; 22 | 23 | export default withMiddleware("verifyRequest")(handler); 24 | -------------------------------------------------------------------------------- /nextjs/pages/api/apps/index.js: -------------------------------------------------------------------------------- 1 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 2 | 3 | /** 4 | * @param {import("next").NextApiRequest} req - The HTTP request object. 5 | * @param {import("next").NextApiResponse} res - The HTTP response object. 6 | */ 7 | const handler = async (req, res) => { 8 | if (req.method === "GET") { 9 | return res 10 | .status(200) 11 | .send({ text: "This text is coming from `/api/apps route`" }); 12 | } 13 | 14 | if (req.method === "POST") { 15 | return res.status(200).send({ text: req.body.content }); 16 | } 17 | 18 | return res.status(400).send({ text: "Bad request" }); 19 | }; 20 | 21 | export default withMiddleware("verifyRequest")(handler); 22 | -------------------------------------------------------------------------------- /nextjs/pages/api/gdpr/customers_data_request.js: -------------------------------------------------------------------------------- 1 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 2 | 3 | /** 4 | * @param {import("next").NextApiRequest} req - The HTTP request object. 5 | * @param {import("next").NextApiResponse} res - The HTTP response object. 6 | */ 7 | const handler = async (req, res) => { 8 | if (req.method !== "POST") { 9 | return res.status(401).send("Must be POST"); 10 | } 11 | try { 12 | const { body } = req; 13 | const shop = req.body.shop_domain; 14 | console.log("gdpr/customers_data_request", body, shop); 15 | return res.status(200).send({ message: "ok" }); 16 | } catch (e) { 17 | console.error( 18 | `---> An error occured at /api/gdpr/customers_data_request: ${e.message}`, 19 | e 20 | ); 21 | return res.status(500).send({ error: true }); 22 | } 23 | }; 24 | 25 | export default withMiddleware("verifyHmac")(handler); 26 | -------------------------------------------------------------------------------- /nextjs/pages/api/gdpr/customers_redact.js: -------------------------------------------------------------------------------- 1 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 2 | 3 | /** 4 | * @param {import("next").NextApiRequest} req - The HTTP request object. 5 | * @param {import("next").NextApiResponse} res - The HTTP response object. 6 | */ 7 | const handler = async (req, res) => { 8 | if (req.method !== "POST") { 9 | return res.status(401).send("Must be POST"); 10 | } 11 | try { 12 | const { body } = req; 13 | const shop = req.body.shop_domain; 14 | console.log("gdpr/customers_redact", body, shop); 15 | return res.status(200).send({ message: "ok" }); 16 | } catch (e) { 17 | console.error( 18 | `---> An error occured at /api/gdpr/customers_redact: ${e.message}`, 19 | e 20 | ); 21 | return res.status(500).send({ error: true }); 22 | } 23 | }; 24 | 25 | export default withMiddleware("verifyHmac")(handler); 26 | -------------------------------------------------------------------------------- /nextjs/pages/api/gdpr/shop_redact.js: -------------------------------------------------------------------------------- 1 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 2 | 3 | /** 4 | * @param {import("next").NextApiRequest} req - The HTTP request object. 5 | * @param {import("next").NextApiResponse} res - The HTTP response object. 6 | */ 7 | const handler = async (req, res) => { 8 | if (req.method !== "POST") { 9 | return res.status(401).send("Must be POST"); 10 | } 11 | try { 12 | const { body } = req; 13 | const shop = req.body.shop_domain; 14 | console.log("gdpr/shop_redact", body, shop); 15 | return res.status(200).send({ message: "ok" }); 16 | } catch (e) { 17 | console.error( 18 | `---> An error occured at /api/gdpr/shop_redact: ${e.message}`, 19 | e 20 | ); 21 | return res.status(500).send({ error: true }); 22 | } 23 | }; 24 | 25 | export default withMiddleware("verifyHmac")(handler); 26 | -------------------------------------------------------------------------------- /nextjs/pages/api/graphql.js: -------------------------------------------------------------------------------- 1 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 2 | import shopify from "@/utils/shopify.js"; 3 | import sessionHandler from "@/utils/sessionHandler.js"; 4 | 5 | /** 6 | * @param {import("next").NextApiRequest} req - The HTTP request object. 7 | * @param {import("next").NextApiResponse} res - The HTTP response object. 8 | */ 9 | const handler = async (req, res) => { 10 | //Reject anything that's not a POST 11 | if (req.method !== "POST") { 12 | return res.status(400).send({ text: "We don't do that here." }); 13 | } 14 | 15 | try { 16 | const sessionId = await shopify.session.getCurrentId({ 17 | isOnline: true, 18 | rawRequest: req, 19 | rawResponse: res, 20 | }); 21 | const session = await sessionHandler.loadSession(sessionId); 22 | const response = await shopify.clients.graphqlProxy({ 23 | session, 24 | rawBody: req.body, 25 | }); 26 | 27 | res.status(200).send(response.body); 28 | } catch (e) { 29 | console.error("An error occured at /api/graphql", e); 30 | return res.status(403).send(e); 31 | } 32 | }; 33 | 34 | withMiddleware("verifyRequest")(handler); 35 | -------------------------------------------------------------------------------- /nextjs/pages/api/index.js: -------------------------------------------------------------------------------- 1 | //TEMP 2 | import clientProvider from "@/utils/clientProvider"; 3 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 4 | 5 | /** 6 | * @param {import("next").NextApiRequest} req - The HTTP request object. 7 | * @param {import("next").NextApiResponse} res - The HTTP response object. 8 | */ 9 | const handler = async (req, res) => { 10 | if (req.method === "GET") { 11 | try { 12 | const { client } = await clientProvider.online.graphqlClient({ 13 | req, 14 | res, 15 | }); 16 | const activeWebhooks = await client.request( 17 | `{ 18 | webhookSubscriptions(first: 25) { 19 | edges { 20 | node { 21 | topic 22 | endpoint { 23 | __typename 24 | ... on WebhookHttpEndpoint { 25 | callbackUrl 26 | } 27 | } 28 | } 29 | } 30 | } 31 | }` 32 | ); 33 | return res.status(200).send(activeWebhooks); 34 | } catch (e) { 35 | console.error(`---> An error occured`, e); 36 | return res.status(400).send({ text: "Bad request" }); 37 | } 38 | } else { 39 | res.status(400).send({ text: "Bad request" }); 40 | } 41 | }; 42 | 43 | export default withMiddleware("verifyRequest")(handler); 44 | -------------------------------------------------------------------------------- /nextjs/pages/api/proxy_route/json.js: -------------------------------------------------------------------------------- 1 | import clientProvider from "@/utils/clientProvider"; 2 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 3 | 4 | /** 5 | * @param {import("next").NextApiRequest} req - The HTTP request object. 6 | * @param {import("next").NextApiResponse} res - The HTTP response object. 7 | */ 8 | const handler = async (req, res) => { 9 | const { client } = await clientProvider.offline.graphqlClient({ 10 | shop: req.user_shop, 11 | }); 12 | 13 | return res.status(200).send({ content: "Proxy Be Working" }); 14 | }; 15 | 16 | export default withMiddleware("verifyProxy")(handler); 17 | -------------------------------------------------------------------------------- /nextjs/pages/api/proxy_route/wishlist/add_product.js: -------------------------------------------------------------------------------- 1 | import withMiddleware from "@/utils/middleware/withMiddleware.js"; 2 | import prisma from "@/utils/prisma"; 3 | 4 | const handler = async (req, res) => { 5 | if (req.method !== "POST") { 6 | //GET, POST, PUT, DELETE 7 | console.log("Serve this request only if method type is POST"); 8 | return res.status(405).send({ error: true }); 9 | } 10 | try { 11 | const reqbody = req.body; 12 | 13 | //Find wishlist for customers 14 | const wishlistsForCustomer = await prisma.customer.findMany({ 15 | where: { 16 | id: req.customer_id, 17 | }, 18 | select: { 19 | wishlists: true, 20 | }, 21 | }); 22 | 23 | //If wishlists are found, see if that wishlist id is valid 24 | if (wishlistsForCustomer.length > 0) { 25 | let wishlistToUpdate = reqbody.wishlist_id ? reqbody.wishlist_id : null; 26 | 27 | wishlistsForCustomer.map((wishlist) => { 28 | wishlistToUpdate = wishlist.wishlists.find((list) => 29 | wishlistToUpdate 30 | ? JSON.stringify(list?.id) === JSON.stringify(wishlistToUpdate) 31 | : list.isDefault === true 32 | ); 33 | }); 34 | 35 | //TODO:- Rewrite this to make it simpler 36 | // What's happening: If it's default, it gives an object, if it's not default, it gives the id as string 37 | wishlistToUpdate?.id 38 | ? (wishlistToUpdate = wishlistToUpdate?.id) 39 | : wishlistToUpdate; 40 | 41 | if (!wishlistToUpdate) { 42 | return res.status(404).send({ error: "No wishlist found" }); 43 | } else { 44 | const updateDefaultWishlist = await prisma.wishlists.update({ 45 | where: { 46 | id: wishlistToUpdate, 47 | }, 48 | data: { 49 | wishlist_product: { 50 | connectOrCreate: { 51 | where: { 52 | product_id_variant_id: { 53 | product_id: reqbody.product_id, 54 | variant_id: reqbody.variant_id, 55 | }, 56 | }, 57 | create: { 58 | product_id: reqbody.product_id, 59 | variant_id: reqbody.variant_id, 60 | title: reqbody.title, 61 | variant_title: reqbody.variant_title, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }); 67 | } 68 | } else { 69 | //Else create a new default wishlist 70 | const createDefaultWishlistForCustomer = await prisma.customer.create({ 71 | data: { 72 | id: req.customer_id, 73 | name: "Not Logged In", 74 | email: "Not Logged In", 75 | wishlists: { 76 | create: { 77 | name: "Default Wishlist", 78 | isDefault: true, 79 | wishlist_product: { 80 | create: { 81 | product_id: reqbody.product_id, 82 | variant_id: reqbody.variant_id, 83 | title: reqbody.title, 84 | variant_title: reqbody.variant_title, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }); 91 | } 92 | 93 | return res.status(200).send({ content: "Prcoxy Be Working" }); 94 | } catch (e) { 95 | console.error(e); 96 | return res.status(403).send({ error: true }); 97 | } 98 | }; 99 | 100 | export default withMiddleware("verifyProxy")(handler); 101 | -------------------------------------------------------------------------------- /nextjs/pages/api/webhooks/[...webhookTopic].js: -------------------------------------------------------------------------------- 1 | /** 2 | * DO NOT EDIT THIS FILE DIRECTLY 3 | * Head over to utils/shopify.js to create your webhooks 4 | * and write your webhook functions in utils/webhooks. 5 | * If you don't know the format, use the `createwebhook` snippet when using VSCode 6 | * to get a boilerplate function for webhooks. 7 | * To update this file, run `npm run update:config` or `bun run update:config` 8 | */ 9 | 10 | import ordersHandler from "@/utils/webhooks/orders.js"; 11 | import shopify from "@/utils/shopify.js"; 12 | import appUninstallHandler from "@/utils/webhooks/app_uninstalled.js"; 13 | 14 | async function buffer(readable) { 15 | const chunks = []; 16 | for await (const chunk of readable) { 17 | chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); 18 | } 19 | return Buffer.concat(chunks); 20 | } 21 | 22 | export default async function handler(req, res) { 23 | if (req.method !== "POST") { 24 | return res.status(400).send("It ain't POST mate."); 25 | } 26 | 27 | const topic = req.headers["x-shopify-topic"] || ""; 28 | const shop = req.headers["x-shopify-shop-domain"] || ""; 29 | const apiVersion = req.headers["x-shopify-api-version"] || ""; 30 | const webhookId = req.headers["x-shopify-webhook-id"] || ""; 31 | 32 | const buff = await buffer(req); 33 | const rawBody = buff.toString("utf8"); 34 | 35 | try { 36 | const validateWebhook = await shopify.webhooks.validate({ 37 | rawBody: rawBody, 38 | rawRequest: req, 39 | rawResponse: res, 40 | }); 41 | 42 | //SWITCHCASE 43 | switch (validateWebhook.topic) { 44 | case "APP_UNINSTALLED": 45 | appUninstallHandler( 46 | validateWebhook.topic, 47 | shop, 48 | rawBody, 49 | webhookId, 50 | apiVersion 51 | ); 52 | break; 53 | case "ORDERS_CREATE": 54 | case "ORDERS_UPDATED": 55 | ordersHandler( 56 | validateWebhook.topic, 57 | shop, 58 | rawBody, 59 | webhookId, 60 | apiVersion 61 | ); 62 | break; 63 | default: 64 | throw new Error(`Can't find a handler for ${topic}`); 65 | } 66 | //SWITCHCASE END 67 | 68 | console.log(`--> Processed ${topic} from ${shop}`); 69 | return res.status(200).send({ message: "ok" }); 70 | } catch (e) { 71 | console.error( 72 | `---> Error while processing webhooks for ${shop} at ${topic} | ${e.message}` 73 | ); 74 | 75 | if (!res.headersSent) { 76 | console.error("No headers sent"); 77 | } 78 | return res.status(500).send({ message: "Error" }); 79 | } 80 | } 81 | 82 | export const config = { 83 | api: { 84 | bodyParser: false, 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /nextjs/pages/debug/billing.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | Button, 4 | Card, 5 | DataTable, 6 | InlineStack, 7 | Layout, 8 | Page, 9 | Text, 10 | } from "@shopify/polaris"; 11 | import { useRouter } from "next/router"; 12 | import { useEffect, useState } from "react"; 13 | 14 | const BillingAPI = () => { 15 | const router = useRouter(); 16 | const [responseData, setResponseData] = useState(""); 17 | 18 | async function fetchContent() { 19 | setResponseData("loading..."); 20 | const res = await fetch("/api/apps/debug/createNewSubscription"); 21 | const data = await res.json(); 22 | if (data.error) { 23 | setResponseData(data.error); 24 | } else if (data.confirmationUrl) { 25 | setResponseData("Redirecting"); 26 | const { confirmationUrl } = data; 27 | open(confirmationUrl, "_top"); 28 | } 29 | } 30 | 31 | return ( 32 | router.push("/debug"), 37 | }} 38 | > 39 | 40 | 41 | 42 | 43 | 44 | Subscribe your merchant to a test $10.25 plan and redirect to 45 | your home page. 46 | 47 | 48 | { 49 | /* If we have an error, it'll pop up here. */ 50 | responseData &&

{responseData}

51 | } 52 | 53 | 61 | 62 |
63 |
64 |
65 | 66 | 67 | 68 |
69 |
70 | ); 71 | }; 72 | 73 | const ActiveSubscriptions = () => { 74 | const [rows, setRows] = useState([]); 75 | 76 | async function getActiveSubscriptions() { 77 | const res = await fetch("/api/apps/debug/getActiveSubscriptions"); 78 | const data = await res.json(); 79 | 80 | //MARK:- Replace this yet another amazing implementation with swr or react-query 81 | let rowsData = []; 82 | const activeSubscriptions = data.data.appInstallation.activeSubscriptions; 83 | 84 | if (activeSubscriptions.length === 0) { 85 | rowsData.push(["No Plan", "N/A", "N/A", "USD 0.00"]); 86 | } else { 87 | console.log("Rendering Data"); 88 | Object.entries(activeSubscriptions).map(([key, value]) => { 89 | const { name, status, test } = value; 90 | const { amount, currencyCode } = 91 | value.lineItems[0].plan.pricingDetails.price; 92 | rowsData.push([name, status, `${test}`, `${currencyCode} ${amount}`]); 93 | }); 94 | } 95 | setRows(rowsData); 96 | } 97 | useEffect(() => { 98 | getActiveSubscriptions(); 99 | }, []); 100 | 101 | return ( 102 | 103 | 104 | Active Subscriptions 105 | 110 | 111 | 112 | ); 113 | }; 114 | 115 | export default BillingAPI; 116 | -------------------------------------------------------------------------------- /nextjs/pages/debug/data.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Layout, 3 | Card, 4 | Page, 5 | BlockStack, 6 | Text, 7 | InlineStack, 8 | Button, 9 | } from "@shopify/polaris"; 10 | import { useRouter } from "next/router"; 11 | import { useEffect, useState } from "react"; 12 | 13 | const useDataFetcher = (initialState, url, options) => { 14 | const [data, setData] = useState(initialState); 15 | 16 | const fetchData = async () => { 17 | setData("loading..."); 18 | const result = await (await fetch(url, options)).json(); 19 | setData(result.text); 20 | }; 21 | 22 | return [data, fetchData]; 23 | }; 24 | 25 | const DataCard = ({ method, url, data, onRefetch }) => ( 26 | 27 | 28 | 29 | 30 | {method} {url}: {data} 31 | 32 | 33 | 36 | 37 | 38 | 39 | 40 | ); 41 | 42 | const GetData = () => { 43 | const router = useRouter(); 44 | 45 | const postOptions = { 46 | headers: { 47 | Accept: "application/json", 48 | "Content-Type": "application/json", 49 | }, 50 | method: "POST", 51 | body: JSON.stringify({ content: "Body of POST request" }), 52 | }; 53 | 54 | const [responseData, fetchContent] = useDataFetcher("", "/api/apps"); 55 | const [responseDataPost, fetchContentPost] = useDataFetcher( 56 | "", 57 | "/api/apps", 58 | postOptions 59 | ); 60 | const [responseDataGQL, fetchContentGQL] = useDataFetcher( 61 | "", 62 | "/api/apps/debug/gql" 63 | ); 64 | 65 | useEffect(() => { 66 | fetchContent(); 67 | fetchContentPost(); 68 | fetchContentGQL(); 69 | }, []); 70 | 71 | return ( 72 | router.push("/debug") }} 76 | > 77 | 78 | 84 | 90 | 96 | 97 | 98 | ); 99 | }; 100 | 101 | export default GetData; 102 | -------------------------------------------------------------------------------- /nextjs/pages/debug/index.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | InlineStack, 5 | Layout, 6 | Page, 7 | Text, 8 | BlockStack, 9 | } from "@shopify/polaris"; 10 | import { useRouter } from "next/router"; 11 | 12 | const DebugIndex = () => { 13 | const router = useRouter(); 14 | 15 | return ( 16 | <> 17 | router.push("/") }} 21 | > 22 | 23 | 24 | 25 | 26 | 27 | Webhooks 28 | 29 | Explored actively registered webhooks 30 | 31 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | Data Fetching 48 | 49 | 50 | Send GET, POST and GraphQL queries to your app's backend. 51 | 52 | 53 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Billing API 70 | 71 | 72 | Subscribe merchant to a plan and explore existing plans. 73 | 74 | 75 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Resource Picker 92 | 93 | See how to use AppBridge CDN's Resource Picker 94 | 95 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | ); 111 | }; 112 | 113 | export default DebugIndex; 114 | -------------------------------------------------------------------------------- /nextjs/pages/debug/resourcePicker.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | Button, 4 | Card, 5 | Layout, 6 | Page, 7 | Text, 8 | TextField, 9 | } from "@shopify/polaris"; 10 | import { useRouter } from "next/router"; 11 | import { useState } from "react"; 12 | 13 | const ResourcePicker = () => { 14 | const router = useRouter(); 15 | const [initialQuery, setInitialQuery] = useState(""); 16 | const [resourcePickerSelection, setResourcePickerSelection] = useState(""); 17 | 18 | async function openResourcePicker(initQuery) { 19 | const selected = await window?.shopify?.resourcePicker({ 20 | type: "product", 21 | query: initQuery, 22 | filter: { 23 | hidden: false, 24 | variants: true, 25 | }, 26 | action: "select", 27 | multiple: false, 28 | }); 29 | 30 | if (selected) { 31 | setResourcePickerSelection(JSON.stringify(selected, null, 2)); 32 | setInitialQuery(""); 33 | } 34 | } 35 | 36 | return ( 37 | <> 38 | { 44 | open( 45 | "https://shopify.dev/docs/api/app-bridge-library/reference/resource-picker", 46 | "_blank" 47 | ); 48 | }, 49 | }} 50 | backAction={{ 51 | onAction: () => { 52 | router.push("/debug"); 53 | }, 54 | }} 55 | > 56 | 57 | 58 | 59 | 60 | 61 | Start typing to search for a product 62 | 63 | { 66 | setInitialQuery(value); 67 | openResourcePicker(value); 68 | }} 69 | connectedRight={ 70 | <> 71 | 79 | 80 | } 81 | /> 82 | 83 | 84 | 85 | 86 | 87 | 88 | Selection JSON 89 |
{resourcePickerSelection}
90 |
91 |
92 |
93 |
94 |
95 | 96 | ); 97 | }; 98 | 99 | export default ResourcePicker; 100 | -------------------------------------------------------------------------------- /nextjs/pages/debug/webhooks.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | Card, 4 | DataTable, 5 | Layout, 6 | Page, 7 | Text, 8 | } from "@shopify/polaris"; 9 | import { useRouter } from "next/router"; 10 | import { useEffect, useState } from "react"; 11 | 12 | const ActiveWebhooks = () => { 13 | const router = useRouter(); 14 | 15 | const [rows, setRows] = useState([ 16 | ["Loading", "I haven't implemented swr or react query yet."], 17 | ]); 18 | 19 | async function fetchWebhooks() { 20 | const res = await fetch("/api/apps/debug/activeWebhooks"); 21 | const data = await res.json(); 22 | let rowData = []; 23 | Object.entries(data.data.webhookSubscriptions.edges).map(([key, value]) => { 24 | const topic = value.node.topic; 25 | const callbackUrl = value.node.endpoint.callbackUrl; 26 | rowData.push([topic, callbackUrl]); 27 | }); 28 | setRows(rowData); 29 | } 30 | 31 | useEffect(() => { 32 | fetchWebhooks(); 33 | }, []); 34 | 35 | return ( 36 | <> 37 | router.push("/debug") }} 40 | > 41 | 42 | 43 | 44 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Note 56 | 57 | 58 | Webhooks are registered when the app is installed, or when 59 | tokens are refetched by going through the authentication 60 | process. If your Callback URL isn't the same as your current 61 | URL (happens usually during dev when using ngrok), you need to 62 | go through the auth process again. 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default ActiveWebhooks; 74 | -------------------------------------------------------------------------------- /nextjs/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import SetupGuideComponent from "@/components/home/SetupGuideComponent"; 2 | import isInitialLoad from "@/utils/middleware/isInitialLoad"; 3 | import { 4 | Banner, 5 | BlockStack, 6 | Box, 7 | Button, 8 | Card, 9 | Layout, 10 | Link, 11 | List, 12 | Page, 13 | Text, 14 | } from "@shopify/polaris"; 15 | import { useRouter } from "next/router"; 16 | import { useState } from "react"; 17 | 18 | export async function getServerSideProps(context) { 19 | //DO NOT REMOVE THIS. 20 | return await isInitialLoad(context); 21 | //DO NOT REMOVE THIS. 22 | } 23 | 24 | const Index = () => { 25 | const router = useRouter(); 26 | const [items, setItems] = useState([ 27 | { 28 | id: 0, 29 | title: "Enable Theme Extension", 30 | description: "Skidaddle skadoodle you know how it be.", 31 | image: { 32 | url: "https://cdn.shopify.com/shopifycloud/shopify/assets/admin/home/onboarding/shop_pay_task-70830ae12d3f01fed1da23e607dc58bc726325144c29f96c949baca598ee3ef6.svg", 33 | alt: "Illustration highlighting ShopPay integration", 34 | }, 35 | complete: false, 36 | primaryButton: { 37 | content: "Add product", 38 | // props: { 39 | // url: "https://www.example.com", 40 | // external: true, 41 | // }, 42 | }, 43 | secondaryButton: { 44 | content: "30s tutorial", 45 | props: { 46 | url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&pp=ygUIcmlja3JvbGw%3D", 47 | external: true, 48 | }, 49 | }, 50 | }, 51 | { 52 | id: 1, 53 | title: "Enable new account experience", 54 | description: "Skibbidi", 55 | image: { 56 | url: "https://cdn.shopify.com/shopifycloud/shopify/assets/admin/home/onboarding/detail-images/home-onboard-share-store-b265242552d9ed38399455a5e4472c147e421cb43d72a0db26d2943b55bdb307.svg", 57 | alt: "Illustration showing an online storefront with a 'share' icon in top right corner", 58 | }, 59 | complete: false, 60 | primaryButton: { 61 | content: "Enabled", 62 | props: { 63 | onAction: () => alert("Enabled"), 64 | }, 65 | }, 66 | }, 67 | ]); 68 | 69 | // Example of step complete handler, adjust for your use case 70 | const onStepComplete = async (id) => { 71 | try { 72 | setItems((prev) => 73 | prev.map((item) => 74 | item.id === id ? { ...item, complete: !item.complete } : item 75 | ) 76 | ); 77 | } catch (e) { 78 | console.error(e); 79 | } 80 | }; 81 | 82 | return ( 83 | <> 84 | 85 | 86 | 87 | 88 | 89 | 90 | Setup block to ensure merchant sets up the theme extension 91 | 92 | Analytics (polaris viz) 93 | 94 | See which customer has wishlisted a certain product 95 | 96 | 97 | 98 | Click on product to see customers who wishlisted it 99 | 100 | 101 | Click on customer to see what products they wishlisted 102 | 103 | 104 | 105 | Orders create and update webhooks so if a new order comes in 106 | with line item attribute of `_wishlist`, that means the 107 | product was in customer's wishlist. Remove it from wishlist 108 | and add it to analytics. 109 | 110 | 111 | 112 | 113 | 114 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | Setup component from{" "} 128 | 132 | RAAbbott/polaris-components 133 | 134 | 135 | 136 | 137 | 138 | ); 139 | }; 140 | 141 | export default Index; 142 | -------------------------------------------------------------------------------- /nextjs/pages/info.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | InlineStack, 4 | Button, 5 | Card, 6 | Layout, 7 | Page, 8 | Text, 9 | } from "@shopify/polaris"; 10 | import { useRouter } from "next/router"; 11 | 12 | const InfoPage = () => { 13 | return ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | Wishlist App Repo 21 | Take a look at the code for the wishlist app 22 | 23 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Heading 44 | Regular Text Content 45 | 46 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default InfoPage; 65 | -------------------------------------------------------------------------------- /nextjs/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | model stores { 15 | shop String @id 16 | isActive Boolean? @default(false) 17 | 18 | @@index([shop]) 19 | } 20 | 21 | model session { 22 | id String @id 23 | content String? @db.Text 24 | shop String? 25 | 26 | @@index([id]) 27 | @@index([shop]) 28 | } 29 | 30 | model friend_code { 31 | //when a customer sends an invite to another customer to share their wishlist, we create an entry 32 | // and send emails. 33 | //Once accepted, we add the customers to their respective `friend` entry. 34 | id String @id 35 | sending_customer String 36 | rec_customer String 37 | code String @default(uuid(7)) 38 | status status @default(sent) 39 | accepted Boolean @default(false) 40 | //baseline 41 | created_at DateTime @default(now()) 42 | updated_at DateTime @updatedAt 43 | 44 | @@unique([sending_customer, rec_customer]) 45 | } 46 | 47 | enum status { 48 | sent 49 | accepted 50 | rejected 51 | } 52 | 53 | model customer { 54 | id String @id //customer gid 55 | //pii 56 | name String? 57 | email String? 58 | wishlists wishlists[] 59 | 60 | @@index([id]) 61 | } 62 | 63 | model wishlists { 64 | id String @id @default(uuid()) 65 | name String 66 | isDefault Boolean @default(false) 67 | customer customer? @relation(fields: [customerId], references: [id]) 68 | customerId String? 69 | wishlist_product wishlist_product[] 70 | 71 | @@index([id]) 72 | @@index([customerId]) 73 | } 74 | 75 | model wishlist_product { 76 | id String @id @default(uuid()) 77 | product_id String 78 | variant_id String 79 | title String 80 | variant_title String @default("Default Title") 81 | wishlists wishlists[] 82 | 83 | @@unique([product_id, variant_id]) 84 | } 85 | -------------------------------------------------------------------------------- /nextjs/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextjs/utils/clientProvider.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles session management for Shopify integration. 3 | * @module clientProvider 4 | */ 5 | 6 | import sessionHandler from "./sessionHandler.js"; 7 | import shopify from "./shopify.js"; 8 | 9 | /** 10 | * Fetches the offline session associated with a shop. 11 | * @async 12 | * @param {string} shop - The shop's domain. 13 | */ 14 | const fetchOfflineSession = async (shop) => { 15 | const sessionID = shopify.session.getOfflineId(shop); 16 | const session = await sessionHandler.loadSession(sessionID); 17 | return session; 18 | }; 19 | 20 | /** 21 | * Provides methods to create Shopify REST clients for offline access. 22 | * @namespace offline 23 | */ 24 | const offline = { 25 | /** 26 | * Creates a Shopify GraphQL client for offline access. 27 | * @async 28 | * @param {Object} params - The request and response objects. 29 | * @param {string} params.shop - The shop's domain 30 | */ 31 | graphqlClient: async ({ shop }) => { 32 | const session = await fetchOfflineSession(shop); 33 | const client = new shopify.clients.Graphql({ session }); 34 | return { client, shop, session }; 35 | }, 36 | /** 37 | * Creates a Shopify REST client for offline access. 38 | * @async 39 | * @param {Object} params - The parameters. 40 | * @param {string} params.shop - The shop's domain. 41 | */ 42 | restClient: async ({ shop }) => { 43 | const session = await fetchOfflineSession(shop); 44 | const client = new shopify.clients.Rest({ 45 | session, 46 | //@ts-ignore 47 | apiVersion: process.env.SHOPIFY_API_VERSION, 48 | }); 49 | return { client, shop, session }; 50 | }, 51 | }; 52 | 53 | /** 54 | * Fetches the online session associated with a request. 55 | * @async 56 | * @param {Object} params - The request and response objects. 57 | * @param {import('next').NextApiRequest} params.req - The Next.js API request object 58 | * @param {import('next').NextApiResponse} params.res - The Next.js API response object 59 | */ 60 | const fetchOnlineSession = async ({ req, res }) => { 61 | const sessionID = await shopify.session.getCurrentId({ 62 | isOnline: true, 63 | rawRequest: req, 64 | rawResponse: res, 65 | }); 66 | const session = await sessionHandler.loadSession(sessionID); 67 | return session; 68 | }; 69 | 70 | /** 71 | * Provides methods to create Shopify clients for online access. 72 | * @namespace online 73 | */ 74 | const online = { 75 | /** 76 | * Creates a Shopify GraphQL client for online access. 77 | * @async 78 | * @param {Object} params - The request and response objects. 79 | * @param {import('next').NextApiRequest} params.req - The Next.js API request object 80 | * @param {import('next').NextApiResponse} params.res - The Next.js API response object 81 | */ 82 | graphqlClient: async ({ req, res }) => { 83 | const session = await fetchOnlineSession({ req, res }); 84 | const client = new shopify.clients.Graphql({ session }); 85 | const { shop } = session; 86 | return { client, shop, session }; 87 | }, 88 | /** 89 | * Creates a Shopify REST client for online access. 90 | * @async 91 | * @param {Object} params - The request and response objects. 92 | * @param {import('next').NextApiRequest} params.req - The Next.js API request object 93 | * @param {import('next').NextApiResponse} params.res - The Next.js API response object 94 | */ 95 | restClient: async ({ req, res }) => { 96 | const session = await fetchOnlineSession({ req, res }); 97 | const { shop } = session; 98 | const client = new shopify.clients.Rest({ 99 | session, 100 | //@ts-ignore 101 | apiVersion: process.env.SHOPIFY_API_VERSION, 102 | }); 103 | return { client, shop, session }; 104 | }, 105 | }; 106 | 107 | /** 108 | * Provides Shopify client providers for both online and offline access. 109 | * @namespace clientProvider 110 | */ 111 | const clientProvider = { 112 | offline, 113 | online, 114 | }; 115 | 116 | export default clientProvider; 117 | -------------------------------------------------------------------------------- /nextjs/utils/cryption.js: -------------------------------------------------------------------------------- 1 | import Cryptr from "cryptr"; 2 | 3 | const cryption = new Cryptr(process.env.ENCRYPTION_STRING); 4 | 5 | export default cryption; 6 | -------------------------------------------------------------------------------- /nextjs/utils/freshInstall.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Do not remove the Prisma query that upserts the shop to `true`. 4 | * 5 | */ 6 | import prisma from "./prisma"; 7 | 8 | /** 9 | * @async 10 | * @function freshInstall 11 | * @param {Object} params - The function parameters container. 12 | * @param {string} params.shop - The shop URL in the format '*.myshopify.com'. 13 | */ 14 | const freshInstall = async ({ shop }) => { 15 | try { 16 | console.log("This is a fresh install, running onboarding functions"); 17 | 18 | await prisma.stores.upsert({ 19 | where: { 20 | shop: shop, 21 | }, 22 | update: { 23 | shop: shop, 24 | isActive: true, 25 | }, 26 | create: { 27 | shop: shop, 28 | isActive: true, 29 | }, 30 | }); 31 | 32 | //Other functions start here 33 | } catch (e) { 34 | console.error( 35 | `---> An error occured in freshInstall function: ${e.message}`, 36 | e 37 | ); 38 | } 39 | }; 40 | 41 | export default freshInstall; 42 | -------------------------------------------------------------------------------- /nextjs/utils/middleware/isInitialLoad.js: -------------------------------------------------------------------------------- 1 | import { RequestedTokenType } from "@shopify/shopify-api"; 2 | import sessionHandler from "../sessionHandler"; 3 | import shopify from "../shopify"; 4 | import freshInstall from "../freshInstall"; 5 | import prisma from "../prisma"; 6 | 7 | /** 8 | * @async 9 | * @param {{ 10 | * params: { [key: string]: string | undefined }, 11 | * req: import('http').IncomingMessage, 12 | * res: import('http').ServerResponse, 13 | * query: { [key: string]: string | string[] }, 14 | * preview?: boolean, 15 | * previewData?: any, 16 | * resolvedUrl: string, 17 | * locale?: string, 18 | * locales?: string[], 19 | * defaultLocale?: string 20 | * }} context 21 | * @returns {Promise<{props: { [key: string]: any } | undefined}>} Object with props to be passed to the page component. 22 | */ 23 | const isInitialLoad = async (context) => { 24 | try { 25 | const shop = context.query.shop; 26 | const idToken = context.query.id_token; 27 | 28 | //Initial Load 29 | if (idToken && shop) { 30 | const { session: offlineSession } = await shopify.auth.tokenExchange({ 31 | sessionToken: idToken, 32 | shop, 33 | requestedTokenType: RequestedTokenType.OfflineAccessToken, 34 | }); 35 | 36 | const { session: onlineSession } = await shopify.auth.tokenExchange({ 37 | sessionToken: idToken, 38 | shop, 39 | requestedTokenType: RequestedTokenType.OnlineAccessToken, 40 | }); 41 | 42 | await sessionHandler.storeSession(offlineSession); 43 | await sessionHandler.storeSession(onlineSession); 44 | 45 | const isFreshInstall = await prisma.stores.findFirst({ 46 | where: { 47 | shop: onlineSession.shop, 48 | }, 49 | }); 50 | 51 | if (!isFreshInstall || isFreshInstall?.isActive === false) { 52 | // !isFreshInstall -> New Install 53 | // isFreshInstall?.isActive === false -> Reinstall 54 | await freshInstall({ shop: onlineSession.shop }); 55 | } 56 | } else { 57 | // The user has visited the page again. 58 | // We know this because we're not preserving any url params and idToken doesn't exist here 59 | } 60 | return { 61 | props: { 62 | data: "ok", 63 | }, 64 | }; 65 | } catch (e) { 66 | if (e.message.startsWith("InvalidJwtError")) { 67 | console.error( 68 | "JWT Error - happens in dev mode and can be safely ignored, even in prod." 69 | ); 70 | } else { 71 | console.error(`---> An error occured at isInitialLoad: ${e.message}`, e); 72 | return { 73 | props: { 74 | serverError: true, 75 | }, 76 | }; 77 | } 78 | return { 79 | props: { 80 | data: "ok", 81 | }, 82 | }; 83 | } 84 | }; 85 | 86 | export default isInitialLoad; 87 | -------------------------------------------------------------------------------- /nextjs/utils/middleware/verifyHmac.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { NextResponse } from "next/server.js"; 3 | import shopify from "@/utils/shopify.js"; 4 | 5 | /** 6 | * @param {import('next').NextApiRequest} req - The incoming request object. 7 | * @param {import('next').NextApiResponse} res - The response object. 8 | * @param {import('next').NextApiHandler} next - Callback to pass control to the next middleware function in the Next.js API route. 9 | */ 10 | const verifyHmac = async (req, res, next) => { 11 | try { 12 | const generateHash = crypto 13 | .createHmac("SHA256", process.env.SHOPIFY_API_SECRET) 14 | .update(JSON.stringify(req.body), "utf8") 15 | .digest("base64"); 16 | 17 | const hmac = req.headers["x-shopify-hmac-sha256"]; 18 | 19 | if (shopify.auth.safeCompare(generateHash, hmac)) { 20 | await next(); 21 | } else { 22 | return res 23 | .status(401) 24 | .send({ success: false, message: "HMAC verification failed" }); 25 | } 26 | } catch (e) { 27 | console.log(`---> An error occured while verifying HMAC`, e.message); 28 | return new NextResponse( 29 | JSON.stringify({ success: false, message: "HMAC verification failed" }), 30 | { 31 | status: 401, 32 | headers: { 33 | "content-type": "application/json", 34 | }, 35 | } 36 | ); 37 | } 38 | }; 39 | 40 | export default verifyHmac; 41 | -------------------------------------------------------------------------------- /nextjs/utils/middleware/verifyProxy.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | /** 4 | * @param {import('next').NextApiRequest} req - The incoming request object. 5 | * @param {import('next').NextApiResponse} res - The response object. 6 | * @param {import('next').NextApiHandler} next - Callback to pass control to the next middleware function in the Next.js API route. 7 | */ 8 | const verifyProxy = async (req, res, next) => { 9 | const { signature } = req.query; 10 | 11 | const queryURI = encodeQueryData(req.query) 12 | .replace("/?", "") 13 | .replace(/&signature=[^&]*/, "") 14 | .split("&") 15 | .map((x) => decodeURIComponent(x)) 16 | .sort() 17 | .join(""); 18 | 19 | const calculatedSignature = crypto 20 | .createHmac("sha256", process.env.SHOPIFY_API_SECRET) 21 | .update(queryURI, "utf-8") 22 | .digest("hex"); 23 | 24 | if (calculatedSignature === signature) { 25 | req.user_shop = req.query.shop; //myshopify domain 26 | req.customer_id = req.query.logged_in_customer_id; 27 | await next(); 28 | } else { 29 | return res.status(401).send({ 30 | success: false, 31 | message: "Signature verification failed", 32 | }); 33 | } 34 | }; 35 | 36 | /** 37 | * Encodes the provided data into a URL query string format. 38 | * 39 | * @param {Record} data - The data to be encoded. 40 | * @returns {string} The encoded query string. 41 | */ 42 | function encodeQueryData(data) { 43 | const queryString = []; 44 | for (let d in data) queryString.push(d + "=" + encodeURIComponent(data[d])); 45 | return queryString.join("&"); 46 | } 47 | 48 | export default verifyProxy; 49 | -------------------------------------------------------------------------------- /nextjs/utils/middleware/verifyRequest.js: -------------------------------------------------------------------------------- 1 | import sessionHandler from "@/utils/sessionHandler.js"; 2 | import shopify from "@/utils/shopify.js"; 3 | import { RequestedTokenType, Session } from "@shopify/shopify-api"; 4 | import validateJWT from "../validateJWT.js"; 5 | 6 | /** 7 | * 8 | * @async 9 | * @function verifyRequest 10 | * @param {import('next').NextApiRequest} req - The Next.js API request object, expected to have an 'authorization' header. 11 | * @param {import('next').NextApiResponse} res - The Next.js API response object, used to send back error messages if needed. 12 | * @param {import('next').NextApiHandler} next - Callback to pass control to the next middleware function in the Next.js API route. 13 | * @throws Will throw an error if the authorization header is missing or invalid, or if no shop is found in the payload. 14 | */ 15 | const verifyRequest = async (req, res, next) => { 16 | try { 17 | const authHeader = req.headers["authorization"]; 18 | if (!authHeader) { 19 | throw Error("No authorization header found."); 20 | } 21 | 22 | const payload = validateJWT(authHeader.split(" ")[1]); 23 | 24 | let shop = shopify.utils.sanitizeShop(payload.dest.replace("https://", "")); 25 | if (!shop) { 26 | throw Error("No shop found, not a valid request"); 27 | } 28 | 29 | const sessionId = await shopify.session.getCurrentId({ 30 | isOnline: true, 31 | rawRequest: req, 32 | rawResponse: res, 33 | }); 34 | 35 | let session = await sessionHandler.loadSession(sessionId); 36 | if (!session) { 37 | session = await getSession({ shop, authHeader }); 38 | } 39 | 40 | if ( 41 | new Date(session?.expires) > new Date() && 42 | shopify.config.scopes.equals(session?.scope) 43 | ) { 44 | } else { 45 | session = await getSession({ shop, authHeader }); 46 | } 47 | 48 | //Add session and shop to the request object so any subsequent routes that use this middleware can access it 49 | req.user_session = session; 50 | req.user_shop = session.shop; 51 | 52 | await next(); 53 | 54 | return; 55 | } catch (e) { 56 | console.error( 57 | `---> An error happened at verifyRequest middleware: ${e.message}` 58 | ); 59 | return res.status(401).send({ error: "Unauthorized call" }); 60 | } 61 | }; 62 | 63 | export default verifyRequest; 64 | 65 | /** 66 | * Retrieves and stores session information based on the provided authentication header and offline flag. 67 | * 68 | * @async 69 | * @function getSession 70 | * @param {Object} params - The function parameters. 71 | * @param {string} params.shop - The xxx.myshopify.com url of the requesting store. 72 | * @param {string} params.authHeader - The authorization header containing the session token. 73 | * @returns {Promise} The online session object 74 | */ 75 | 76 | async function getSession({ shop, authHeader }) { 77 | try { 78 | const sessionToken = authHeader.split(" ")[1]; 79 | 80 | const { session: onlineSession } = await shopify.auth.tokenExchange({ 81 | sessionToken, 82 | shop, 83 | requestedTokenType: RequestedTokenType.OnlineAccessToken, 84 | }); 85 | 86 | sessionHandler.storeSession(onlineSession); 87 | 88 | const { session: offlineSession } = await shopify.auth.tokenExchange({ 89 | sessionToken, 90 | shop, 91 | requestedTokenType: RequestedTokenType.OfflineAccessToken, 92 | }); 93 | 94 | sessionHandler.storeSession(offlineSession); 95 | 96 | return new Session(onlineSession); 97 | } catch (e) { 98 | console.error( 99 | `---> Error happened while pulling session from Shopify: ${e.message}` 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /nextjs/utils/middleware/withMiddleware.js: -------------------------------------------------------------------------------- 1 | import { label } from "next-api-middleware"; 2 | import verifyHmac from "./verifyHmac.js"; 3 | import verifyProxy from "./verifyProxy.js"; 4 | import verifyRequest from "./verifyRequest.js"; 5 | 6 | const withMiddleware = label({ 7 | verifyRequest: verifyRequest, 8 | verifyProxy: verifyProxy, 9 | verifyHmac: verifyHmac, 10 | }); 11 | 12 | export default withMiddleware; 13 | -------------------------------------------------------------------------------- /nextjs/utils/prisma.js: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | /** @type {PrismaClient} */ 4 | let prisma; 5 | let isProd = process.env.NODE_ENV === "production"; 6 | 7 | if (isProd) { 8 | prisma = new PrismaClient(); 9 | } else { 10 | if (!global.prisma) { 11 | global.prisma = new PrismaClient(); 12 | } 13 | prisma = global.prisma; 14 | } 15 | 16 | export default prisma; 17 | -------------------------------------------------------------------------------- /nextjs/utils/sessionHandler.js: -------------------------------------------------------------------------------- 1 | import { Session } from "@shopify/shopify-api"; 2 | import cryption from "./cryption.js"; 3 | import prisma from "./prisma.js"; 4 | 5 | /** 6 | * Stores the session data into the database. 7 | * 8 | * @param {Session} session - The Shopify session object. 9 | * @returns {Promise} Returns true if the operation was successful. 10 | */ 11 | const storeSession = async (session) => { 12 | await prisma.session.upsert({ 13 | where: { id: session.id }, 14 | update: { 15 | content: cryption.encrypt(JSON.stringify(session)), 16 | shop: session.shop, 17 | }, 18 | create: { 19 | id: session.id, 20 | content: cryption.encrypt(JSON.stringify(session)), 21 | shop: session.shop, 22 | }, 23 | }); 24 | 25 | return true; 26 | }; 27 | 28 | /** 29 | * Loads the session data from the database. 30 | * 31 | * @param {string} id - The session ID. 32 | * @returns {Promise} Returns the Shopify session object or undefined if not found. 33 | */ 34 | const loadSession = async (id) => { 35 | const sessionResult = await prisma.session.findUnique({ where: { id } }); 36 | 37 | if (sessionResult === null) { 38 | return undefined; 39 | } 40 | if (sessionResult.content.length > 0) { 41 | const sessionObj = JSON.parse(cryption.decrypt(sessionResult.content)); 42 | return new Session(sessionObj); 43 | } 44 | return undefined; 45 | }; 46 | 47 | /** 48 | * Deletes the session data from the database. 49 | * 50 | * @param {string} id - The session ID. 51 | * @returns {Promise} Returns true if the operation was successful. 52 | */ 53 | const deleteSession = async (id) => { 54 | await prisma.session.deleteMany({ where: { id } }); 55 | 56 | return true; 57 | }; 58 | 59 | /** 60 | * Session handler object containing storeSession, loadSession, and deleteSession functions. 61 | */ 62 | const sessionHandler = { storeSession, loadSession, deleteSession }; 63 | 64 | export default sessionHandler; 65 | -------------------------------------------------------------------------------- /nextjs/utils/setupCheck.js: -------------------------------------------------------------------------------- 1 | const setupCheck = () => { 2 | try { 3 | const { 4 | SHOPIFY_API_KEY: apiKey, 5 | SHOPIFY_API_SECRET: apiSecret, 6 | SHOPIFY_API_SCOPES: apiScopes, 7 | SHOPIFY_APP_URL: appUrl, 8 | SHOPIFY_API_VERSION: apiVersion, 9 | ENCRYPTION_STRING: encString, 10 | DATABASE_URL: databaseURL, 11 | APP_NAME: appName, 12 | APP_HANDLE: appHandle, 13 | APP_PROXY_PREFIX: proxyPrefix, 14 | APP_PROXY_SUBPATH: proxySubpath, 15 | } = process.env; 16 | 17 | if (typeof apiKey === "undefined") { 18 | throw Error("---> API Key is undefined."); 19 | } 20 | if (typeof apiSecret === "undefined") { 21 | throw Error("---> API Secret is undefined."); 22 | } 23 | if (typeof apiScopes === "undefined") { 24 | throw Error("---> API Scopes are undefined."); 25 | } 26 | if (typeof appUrl === "undefined") { 27 | throw Error("---> App URL is undefined."); 28 | } else if (!appUrl.includes("https://")) { 29 | console.error("---> Please use HTTPS for SHOPIFY_APP_URL."); 30 | } 31 | if (typeof apiVersion === "undefined") { 32 | throw Error("---> API Version is undefined."); 33 | } 34 | if (typeof encString === "undefined") { 35 | throw Error("---> Encryption String is undefined."); 36 | } 37 | 38 | if (typeof databaseURL === "undefined") { 39 | throw Error("---> Database string is undefined."); 40 | } 41 | 42 | if (typeof appName === "undefined" || appName.length < 1) { 43 | throw Error( 44 | `---> App Name is ${appName.length < 1 ? "not entered properly" : "undefined"}.` 45 | ); 46 | } 47 | if (typeof appHandle === "undefined") { 48 | throw Error("---> App Handle is undefined."); 49 | } 50 | if (appHandle.includes(" ")) { 51 | throw Error("---> Handle must be URL encoded and cannot contain spaces."); 52 | } 53 | 54 | if (typeof proxySubpath === "undefined") { 55 | console.warn( 56 | "---> App Proxy subpath is undefined and will not be used. Make sure your app doesn't use App proxy" 57 | ); 58 | } else { 59 | if (typeof proxyPrefix === "undefined") { 60 | throw Error("---> App proxy prefix is undefined"); 61 | } 62 | switch (proxyPrefix) { 63 | case "apps": 64 | case "a": 65 | case "community": 66 | case "tools": 67 | break; 68 | default: 69 | throw Error( 70 | "Invalid App proxy prefix, please make sure the value is either of these:\napps\na\ncommunity\ntools" 71 | ); 72 | } 73 | } 74 | 75 | console.log("--> Setup checks passed successfully."); 76 | } catch (e) { 77 | console.error(e.message); 78 | } 79 | }; 80 | 81 | export default setupCheck; 82 | -------------------------------------------------------------------------------- /nextjs/utils/shopify.js: -------------------------------------------------------------------------------- 1 | import { LogSeverity, shopifyApi } from "@shopify/shopify-api"; 2 | import "@shopify/shopify-api/adapters/node"; 3 | import appUninstallHandler from "./webhooks/app_uninstalled.js"; 4 | import ordersHandler from "./webhooks/orders.js"; 5 | 6 | const isDev = process.env.NODE_ENV === "development"; 7 | 8 | // Setup Shopify configuration 9 | let shopify = shopifyApi({ 10 | apiKey: process.env.SHOPIFY_API_KEY, 11 | apiSecretKey: process.env.SHOPIFY_API_SECRET, 12 | scopes: process.env.SHOPIFY_API_SCOPES, 13 | hostName: process.env.SHOPIFY_APP_URL.replace(/https:\/\//, ""), 14 | hostScheme: "https", 15 | apiVersion: process.env.SHOPIFY_API_VERSION, 16 | isEmbeddedApp: true, 17 | logger: { level: isDev ? LogSeverity.Error : LogSeverity.Error }, 18 | }); 19 | 20 | /* 21 | Template for adding new topics: 22 | ``` 23 | { 24 | topics: ["",""] //Get this from `https://shopify.dev/docs/api/webhooks?reference=toml` 25 | url: "/api/webhooks/topic_name" //this can be AWS, PubSub or HTTP routes. 26 | callback: () //This HAS to be in utils/webhooks/ and created with the `createwebhook` snippet. 27 | filter: "" //Optional - filter what webhooks you recieve 28 | include_fields: ["",""] //Optional - decide what fields you want to recieve 29 | } 30 | ``` 31 | */ 32 | 33 | //Add custom user properties to base shopify obj 34 | shopify = { 35 | ...shopify, 36 | user: { 37 | /** 38 | * @type {Array<{ 39 | * topics: import("@/_developer/types/webhookTopics.js").WebhookTopics["topic"], 40 | * url: string, 41 | * callback: Function, 42 | * filter?: string, 43 | * include_fields?: string[] 44 | * }>} 45 | */ 46 | webhooks: [ 47 | { 48 | topics: ["app/uninstalled"], 49 | url: "/api/webhooks/app_uninstalled", 50 | callback: appUninstallHandler, 51 | }, 52 | { 53 | topics: ["orders/create", "orders/updated"], 54 | url: "/api/webhooks/orders", 55 | callback: ordersHandler, 56 | }, 57 | ], 58 | }, 59 | }; 60 | 61 | export default shopify; 62 | -------------------------------------------------------------------------------- /nextjs/utils/validateJWT.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | /** 4 | * 5 | * Validate your JWT token against the secret. 6 | * 7 | * @param {String} token - JWT Token 8 | * @param {String} secret - Signature secret. By default uses the `process.env.SHOPIFY_API_SECRET` value 9 | * @returns {Object} Decoded JWT payload. 10 | */ 11 | function validateJWT(token, secret = process.env.SHOPIFY_API_SECRET) { 12 | const parts = token.split("."); 13 | if (parts.length !== 3) { 14 | throw new Error("JWT: Token structure incorrect"); 15 | } 16 | 17 | const header = parts[0]; 18 | const payload = parts[1]; 19 | const signature = parts[2]; 20 | 21 | const headerJson = Buffer.from(header, "base64").toString(); 22 | const payloadJson = Buffer.from(payload, "base64").toString(); 23 | 24 | // Verify the signature 25 | const signatureCheck = crypto 26 | .createHmac("sha256", secret) 27 | .update(`${header}.${payload}`) 28 | .digest("base64"); 29 | 30 | const safeSignatureCheck = signatureCheck 31 | .replace(/\+/g, "-") 32 | .replace(/\//g, "_") 33 | .replace(/=+$/, ""); 34 | 35 | if (safeSignatureCheck !== signature) { 36 | throw new Error("Invalid token signature"); 37 | } 38 | 39 | return JSON.parse(payloadJson); 40 | } 41 | 42 | export default validateJWT; 43 | -------------------------------------------------------------------------------- /nextjs/utils/webhooks/app_uninstalled.js: -------------------------------------------------------------------------------- 1 | // To create a new webhook, create a new `.js` folder in /utils/webhooks/ and use the project snippet 2 | // `createwebhook` to generate webhook boilerplate 3 | 4 | /** 5 | * @typedef { import("@/_developer/types/2024-07/webhooks.js").APP_UNINSTALLED} AppUninstalled 6 | */ 7 | 8 | import prisma from "../prisma.js"; 9 | 10 | const appUninstallHandler = async ( 11 | topic, 12 | shop, 13 | webhookRequestBody, 14 | webhookId, 15 | apiVersion 16 | ) => { 17 | try { 18 | /** @type {AppUninstalled} */ 19 | const webhookBody = JSON.parse(webhookRequestBody); 20 | 21 | await prisma.session.deleteMany({ where: { shop } }); 22 | await prisma.stores.upsert({ 23 | where: { shop: shop }, 24 | update: { isActive: false }, 25 | create: { shop: shop, isActive: false }, 26 | }); 27 | } catch (e) { 28 | console.error(e); 29 | } 30 | }; 31 | 32 | export default appUninstallHandler; 33 | -------------------------------------------------------------------------------- /nextjs/utils/webhooks/orders.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Replace TOPIC_NAME with a Webhook Topic to enable autocomplete 3 | * @typedef { import("@/_developer/types/2024-07/webhooks.js").ORDERS_UPDATED } webhookTopic 4 | */ 5 | 6 | const ordersHandler = async ( 7 | topic, 8 | shop, 9 | webhookRequestBody, 10 | webhookId, 11 | apiVersion 12 | ) => { 13 | try { 14 | /** @type {webhookTopic} */ 15 | const webhookBody = JSON.parse(webhookRequestBody); 16 | } catch (e) { 17 | console.error(e); 18 | } 19 | }; 20 | 21 | export default ordersHandler; 22 | -------------------------------------------------------------------------------- /nextjs/zz.html: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /nextjs/zz.md: -------------------------------------------------------------------------------- 1 | # Schema 2 | 3 | `customers` <> `product_wishlist` --> `products` 4 | 5 | - a customer can have multiple products 6 | - product data is stationary, except for the customers reference 7 | - for a customer, we need info on when they added a product to wishlist, which stays in `product_wishlist` 8 | - when they added to wishlist 9 | - when they bought it 10 | - the difference of this would give me median time between adding to wishlist and buying it 11 | - basic filtering 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-wishlist-live-app", 3 | "version": "2024.08.31", 4 | "description": "A Wishlist app built thrice with Express, Remix and Next.js, and Shopify Extensions.", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretty": "prettier --write ./", 8 | "update": "ncu -u" 9 | }, 10 | "devDependencies": { 11 | "prettier": "^3.3.3" 12 | } 13 | } 14 | --------------------------------------------------------------------------------