├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── dependabot.yml ├── probots.yml └── workflows │ ├── ci.yml │ ├── merge_patch_dependencies.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .syncpackrc.json ├── .vscode └── extensions.json ├── .yarn └── releases │ └── yarn-3.2.3.cjs ├── .yarnrc.yml ├── Dockerfile ├── LICENSE.md ├── README.md ├── SECURITY.md ├── babel.config.js ├── config ├── eslint │ ├── index.js │ └── rules │ │ ├── graphql-rules.js │ │ ├── js-rules.js │ │ ├── react-rules.js │ │ ├── ts-rules.js │ │ └── unicorn-rules.js └── graphql-codegen │ └── index.js ├── docs ├── fly-io.md └── heroku.md ├── package.json ├── scripts ├── .eslintrc.cjs ├── generators │ └── tsconfig.ts └── tsconfig.json ├── shopify.app.toml ├── tsconfig.base.json ├── tsconfig.json ├── web ├── axe-common │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── tools │ │ │ ├── ArrayUtils.ts │ │ │ ├── LoadEnvs.ts │ │ │ └── index.ts │ └── tsconfig.json ├── backend │ ├── package.json │ ├── shopify.web.toml │ ├── src │ │ ├── app_installations.ts │ │ ├── gdpr.ts │ │ ├── helpers │ │ │ ├── ensure-billing.ts │ │ │ ├── product-creator.ts │ │ │ ├── redirect-to-auth.ts │ │ │ └── return-top-level-redirection.ts │ │ ├── index.ts │ │ └── middleware │ │ │ ├── auth.ts │ │ │ └── verify-request.ts │ └── tsconfig.json └── frontend │ ├── .gitignore │ ├── App.tsx │ ├── LICENSE.md │ ├── README.md │ ├── Routes.tsx │ ├── assets │ ├── empty-state.svg │ ├── home-trophy.png │ └── index.js │ ├── components │ ├── ProductsCard.tsx │ ├── index.ts │ └── providers │ │ ├── AppBridgeProvider.tsx │ │ ├── PolarisProvider.tsx │ │ ├── QueryProvider.tsx │ │ └── index.ts │ ├── dev_embed.js │ ├── hooks │ ├── index.ts │ ├── useAppQuery.ts │ └── useAuthenticatedFetch.ts │ ├── index.html │ ├── index.jsx │ ├── package.json │ ├── pages │ ├── ExitIframe.tsx │ ├── NotFound.tsx │ ├── index.tsx │ └── pagename.tsx │ ├── shopify.web.toml │ ├── tsconfig.json │ ├── types │ ├── Children.ts │ └── global.d.ts │ └── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | web/node_modules 2 | web/frontend/node_modules 3 | web/frontend/dist 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://localhost:27017 2 | MONGODB_NAME=local 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | node_modules/ 4 | .yarn/ 5 | .pnp.* 6 | schema.graphql 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./config/eslint'); 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary 3 | /.yarn/unplugged/** binary 4 | 5 | * text=auto eol=lf 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: 'npm' 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: '/' 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: 'weekly' 10 | commit-message: 11 | prefix: 'fix' 12 | prefix-development: 'build' 13 | include: 'scope' 14 | allow: 15 | - dependency-type: 'production' 16 | -------------------------------------------------------------------------------- /.github/probots.yml: -------------------------------------------------------------------------------- 1 | enabled: 2 | - cla 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | env: 8 | NODE_OPTIONS: --max_old_space_size=6144 # assert node processes to use 6Gb (GA limit = 7Gb) 9 | 10 | jobs: 11 | prepare-cache: 12 | name: Prepare cache 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version-file: '.nvmrc' 21 | 22 | - name: Cache node modules 23 | id: cache-yarn 24 | uses: actions/cache@v3 25 | env: 26 | cache-name: cache-node-modules 27 | with: 28 | # npm cache files are stored in `~/.npm` on Linux/macOS 29 | path: ./node_modules 30 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 31 | 32 | - name: Install dependencies 33 | if: steps.cache-yarn.outputs.cache-hit != 'true' 34 | run: | 35 | yarn dedupe --check 36 | 37 | - name: Check or update Yarn cache 38 | run: yarn install --immutable 39 | 40 | check-lint: 41 | name: Check - Lint 42 | needs: prepare-cache 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 10 45 | steps: 46 | - uses: actions/checkout@v3 47 | 48 | - uses: actions/setup-node@v3 49 | with: 50 | node-version-file: '.nvmrc' 51 | 52 | - name: Cache node modules 53 | id: cache-yarn 54 | uses: actions/cache@v3 55 | env: 56 | cache-name: cache-node-modules 57 | with: 58 | # npm cache files are stored in `~/.npm` on Linux/macOS 59 | path: ./node_modules 60 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 61 | 62 | - name: Check - Lint 63 | timeout-minutes: 10 64 | run: yarn c:lint 65 | 66 | check-prettier: 67 | name: Check - Prettier 68 | needs: prepare-cache 69 | runs-on: ubuntu-latest 70 | timeout-minutes: 10 71 | steps: 72 | - uses: actions/checkout@v3 73 | 74 | - uses: actions/setup-node@v3 75 | with: 76 | node-version-file: '.nvmrc' 77 | 78 | - name: Cache node modules 79 | id: cache-yarn 80 | uses: actions/cache@v3 81 | env: 82 | cache-name: cache-node-modules 83 | with: 84 | # npm cache files are stored in `~/.npm` on Linux/macOS 85 | path: ./node_modules 86 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 87 | 88 | - name: Check - Prettier 89 | timeout-minutes: 10 90 | run: yarn c:format 91 | 92 | check-type: 93 | name: Check - Type 94 | needs: prepare-cache 95 | runs-on: ubuntu-latest 96 | timeout-minutes: 10 97 | steps: 98 | - uses: actions/checkout@v3 99 | 100 | - uses: actions/setup-node@v3 101 | with: 102 | node-version-file: '.nvmrc' 103 | 104 | - name: Cache node modules 105 | id: cache-yarn 106 | uses: actions/cache@v3 107 | env: 108 | cache-name: cache-node-modules 109 | with: 110 | # npm cache files are stored in `~/.npm` on Linux/macOS 111 | path: ./node_modules 112 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 113 | 114 | - name: Check - Type 115 | timeout-minutes: 10 116 | run: yarn c:type 117 | -------------------------------------------------------------------------------- /.github/workflows/merge_patch_dependencies.yml: -------------------------------------------------------------------------------- 1 | on: pull_request_target 2 | 3 | name: 'Dependabot: auto-merge patch versions' 4 | 5 | jobs: 6 | approve-dependabot-pr: 7 | if: ${{ github.actor == 'dependabot[bot]' }} 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Dependabot metadata 11 | id: dependabot-metadata 12 | uses: dependabot/fetch-metadata@v1 13 | with: 14 | github-token: '${{ secrets.GITHUB_TOKEN }}' 15 | - name: Approve and merge Dependabot PRs for patch versions 16 | if: ${{github.event.workflow_run.conclusion == 'success' && steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch'}} 17 | uses: actions/github-script@v5 18 | with: 19 | github-token: '${{ secrets.GITHUB_TOKEN }}' 20 | script: | 21 | await github.rest.pulls.createReview({ 22 | pull_number: context.issue.number, 23 | owner: context.repo.owner, 24 | repo: context.repo.repo, 25 | event: 'APPROVE', 26 | }) 27 | 28 | await github.rest.pulls.merge({ 29 | merge_method: "merge", 30 | owner: repository.owner, 31 | pull_number: pullRequest.number, 32 | repo: repository.repo, 33 | }) 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version-file: '.nvmrc' 24 | 25 | - name: Cache node modules 26 | id: cache-yarn 27 | uses: actions/cache@v3 28 | env: 29 | cache-name: cache-node-modules 30 | with: 31 | # npm cache files are stored in `~/.npm` on Linux/macOS 32 | path: ./node_modules 33 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 34 | 35 | - name: Install dependencies 36 | if: steps.cache-yarn.outputs.cache-hit != 'true' 37 | run: | 38 | yarn dedupe --check 39 | 40 | - name: Check or update Yarn cache 41 | run: yarn install --immutable 42 | 43 | - name: Build 44 | run: yarn build 45 | 46 | - run: npx semantic-release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment Configuration 2 | .env 3 | 4 | # Dependency directory 5 | node_modules 6 | 7 | # Test coverage directory 8 | coverage 9 | 10 | # Ignore Apple macOS Desktop Services Store 11 | .DS_Store 12 | 13 | # Logs 14 | logs 15 | *.log 16 | 17 | # ngrok tunnel file 18 | config/tunnel.pid 19 | 20 | # vite build output 21 | dist/ 22 | 23 | # extensions build output 24 | extensions/*/build 25 | 26 | # Node library SQLite database 27 | web/database.sqlite 28 | 29 | # Yarn 2+ without zero installs 30 | .pnp.* 31 | .yarn/* 32 | !.yarn/patches 33 | !.yarn/plugins 34 | !.yarn/releases 35 | !.yarn/sdks 36 | !.yarn/versions 37 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | auto-install-peers=true 3 | shamefully-hoist=true 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.15.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | dist/ 4 | coverage/ 5 | node_modules/ 6 | .yarn/ 7 | .pnp.* 8 | *.log 9 | *.lock 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.syncpackrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sortAz": [], 3 | "sortFirst": [ 4 | "name", 5 | "description", 6 | "version", 7 | "private", 8 | "packageManager", 9 | "license", 10 | "author", 11 | "workspaces", 12 | "main", 13 | "scripts", 14 | "dependencies", 15 | "peerDependencies", 16 | "devDependencies" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["shopify.polaris-for-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.2.3.cjs 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | ENV SHOPIFY_API_KEY $SHOPIFY_API_KEY 4 | EXPOSE 8081 5 | WORKDIR /app 6 | COPY web . 7 | RUN yarn install 8 | RUN yarn workspace backend build 9 | CMD ["yarn", "workspace", "backend", "run", "serve"] 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shopify 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 | # Ralph's Custom Shopify App Node Starter Template 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) 4 | 5 | Hey! I'm ralph and I made this template for you to use as a starting point for your Node.js/React Shopify App. 6 | 7 | Feel free to collaborate on this project and lets empower the Shopify Dev Community! 8 | 9 | #### Follow Our Journey: 10 | 11 | - [![Twitter Follow](https://img.shields.io/twitter/follow/RalphEcom?style=social)](https://twitter.com/RalphEcom) 12 | - [![Twitter Follow](https://img.shields.io/twitter/follow/Aksel_SaaS?style=social)](https://twitter.com/aksel_saas) 13 | 14 | ## Summary 15 | 16 | We are using yarn workspaces to segment our projects into multiple packages. 17 | Packages found in `packages`: 18 | 19 | - backend: contains all the code related to the backend of the app (api, database, etc) 20 | - frontend: contains the dashboard info 21 | - axe/common: contains cool utils that you can use anywhere in your app 22 | - _shopify network (coming soon)_ [Will contain all the graphql queries and mutations to interact with the shopify api] 23 | 24 | ## How to setup local dev environment 25 | 26 | - Install dependencies: `yarn install` 27 | - Create file `.env` with the following variables: 28 | - MONGODB_URI: your mongodb uri 29 | - MONGODB_NAME: your mongodb name 30 | - Run `yarn dev` 31 | 32 | ## Next Steps 33 | 34 | - [ ] Add Typeorm ORM to the project with MongoDB (or maybe mongoose and lets forget typeORM?) 35 | - [ ] Add translations (for multiple languages) 36 | - [x] Implement Subscriptions (handling charging customers etc...) 37 | - [ ] open to suggestions/more ideas! 38 | 39 | ## Bugs ? 40 | 41 | Have a bug ? create an issue about it and I can help you 42 | 43 | ## Feature Ideas ? 44 | 45 | Have a good idea ? create an issue about it and lets make it happen! 46 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported versions 4 | 5 | ### New features 6 | 7 | New features will only be added to the master branch and will not be made available in point releases. 8 | 9 | ### Bug fixes 10 | 11 | Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from. 12 | 13 | ### Security issues 14 | 15 | Only the latest release series will receive patches and new versions in case of a security issue. 16 | 17 | ### Severe security issues 18 | 19 | For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team. 20 | 21 | ### Unsupported Release Series 22 | 23 | When a release series is no longer supported, it's your own responsibility to deal with bugs and security issues. If you are not comfortable maintaining your own versions, you should upgrade to a supported version. 24 | 25 | ## Reporting a bug 26 | 27 | All security bugs in shopify repositories should be reported to [our hackerone program](https://hackerone.com/shopify) 28 | Shopify's whitehat program is our way to reward security researchers for finding serious security vulnerabilities in the In Scope properties listed at the bottom of this page, including our core application (all functionality associated with a Shopify store, particularly your-store.myshopify.com/admin) and certain ancillary applications. 29 | 30 | ## Disclosure Policy 31 | 32 | We look forward to working with all security researchers and strive to be respectful, always assume the best and treat others as peers. We expect the same in return from all participants. To achieve this, our team strives to: 33 | 34 | - Reply to all reports within one business day and triage within two business days (if applicable) 35 | - Be as transparent as possible, answering all inquires about our report decisions and adding hackers to duplicate HackerOne reports 36 | - Award bounties within a week of resolution (excluding extenuating circumstances) 37 | - Only close reports as N/A when the issue reported is included in Known Issues, Ineligible Vulnerabilities Types or lacks evidence of a vulnerability 38 | 39 | **The following rules must be followed in order for any rewards to be paid:** 40 | 41 | - You may only test against shops you have created which include your HackerOne YOURHANDLE @ wearehackerone.com registered email address. 42 | - You must not attempt to gain access to, or interact with, any shops other than those created by you. 43 | - The use of commercial scanners is prohibited (e.g., Nessus). 44 | - Rules for reporting must be followed. 45 | - Do not disclose any issues publicly before they have been resolved. 46 | - Shopify reserves the right to modify the rules for this program or deem any submissions invalid at any time. Shopify may cancel the whitehat program without notice at any time. 47 | - Contacting Shopify Support over chat, email or phone about your HackerOne report is not allowed. We may disqualify you from receiving a reward, or from participating in the program altogether. 48 | - You are not an employee of Shopify; employees should report bugs to the internal bug bounty program. 49 | - You hereby represent, warrant and covenant that any content you submit to Shopify is an original work of authorship and that you are legally entitled to grant the rights and privileges conveyed by these terms. You further represent, warrant and covenant that the consent of no other person or entity is or will be necessary for Shopify to use the submitted content. 50 | - By submitting content to Shopify, you irrevocably waive all moral rights which you may have in the content. 51 | - All content submitted by you to Shopify under this program is licensed under the MIT License. 52 | - You must report any discovered vulnerability to Shopify as soon as you have validated the vulnerability. 53 | - Failure to follow any of the foregoing rules will disqualify you from participating in this program. 54 | 55 | \*\* Please see our [Hackerone Profile](https://hackerone.com/shopify) for full details 56 | 57 | ## Receiving Security Updates 58 | 59 | To recieve all general updates to vulnerabilities, please subscribe to our hackerone [Hacktivity](https://hackerone.com/shopify/hacktivity) 60 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | /** 3 | * @type {{ 4 | * cache: { using: (arg: () => unknown) => void; }; 5 | * }} 6 | **/ 7 | api 8 | ) => { 9 | api.cache.using(() => process.env.NODE_ENV); 10 | return { 11 | babelrcRoots: ['web/*'], 12 | presets: [ 13 | ['@babel/preset-env', { targets: { node: 'current' } }], 14 | ['@babel/preset-typescript', { onlyRemoveTypeImports: true }], 15 | ], 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /config/eslint/index.js: -------------------------------------------------------------------------------- 1 | const { graphqlExtends, graphqlRules } = require('./rules/graphql-rules'); 2 | const { jsRules } = require('./rules/js-rules'); 3 | const { reactExtends, reactRules } = require('./rules/react-rules'); 4 | const { tsExtends, tsRules } = require('./rules/ts-rules'); 5 | const { unicornExtends, unicornRules } = require('./rules/unicorn-rules'); 6 | 7 | /** 8 | * @type {import('eslint').Linter.Config} 9 | */ 10 | module.exports = { 11 | root: true, 12 | env: { 13 | node: true, 14 | es6: true, 15 | }, 16 | parserOptions: { 17 | project: [ 18 | './tsconfig?(.eslint).json', 19 | './web/*/tsconfig?(.eslint).json', 20 | './scripts/tsconfig.json', 21 | ], 22 | tsconfigRootDir: '.', 23 | ecmaVersion: 8, // enable ES6+ features 24 | }, 25 | extends: ['eslint:recommended'], 26 | overrides: [ 27 | { 28 | files: ['**/*.js', '**/*.mjs'], 29 | parser: '@babel/eslint-parser', 30 | parserOptions: { 31 | sourceType: 'module', 32 | }, 33 | env: { 34 | browser: false, 35 | node: true, 36 | es6: true, 37 | }, 38 | plugins: ['import'], 39 | extends: ['eslint:recommended'], 40 | rules: Object.assign({}, jsRules, { 41 | 'prefer-object-spread': 'off', 42 | }), 43 | }, 44 | 45 | { 46 | files: ['**/*.ts', '**/*.tsx'], 47 | parser: '@typescript-eslint/parser', 48 | settings: { 49 | react: { version: '18.2.0' }, 50 | 'import/parsers': { 51 | '@typescript-eslint/parser': ['.ts', '.tsx'], 52 | }, 53 | 'import/resolver': { 54 | typescript: { 55 | // always try to resolve types under `@types` directory 56 | // even it doesn't contain any source code, like `@types/unist` 57 | alwaysTryTypes: true, 58 | }, 59 | }, 60 | }, 61 | env: { 62 | browser: true, 63 | node: true, 64 | es6: true, 65 | }, 66 | plugins: ['import', '@typescript-eslint'], 67 | extends: ['eslint:recommended'] 68 | .concat(tsExtends) 69 | .concat(reactExtends) 70 | .concat(unicornExtends), 71 | rules: Object.assign({}, jsRules, tsRules, reactRules, unicornRules, { 72 | 'unicorn/prevent-abbreviations': 'off', 73 | 'unicorn/prefer-module': 'off', 74 | 'unicorn/numeric-separators-style': 'off', 75 | }), 76 | }, 77 | 78 | { 79 | files: ['**/*.graphql'], 80 | extends: graphqlExtends, 81 | rules: Object.assign({}, graphqlRules, {}), 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /config/eslint/rules/graphql-rules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GraphQL rules 3 | * 4 | * @see https://github.com/B2o5T/graphql-eslint 5 | */ 6 | module.exports = { 7 | graphqlExtends: [ 8 | 'plugin:@graphql-eslint/schema-recommended', 9 | 'plugin:@graphql-eslint/operations-recommended', 10 | ], 11 | graphqlRules: { 12 | '@graphql-eslint/naming-convention': 'off', 13 | 14 | '@graphql-eslint/no-anonymous-operations': 'error', 15 | '@graphql-eslint/match-document-filename': [ 16 | 'error', 17 | { 18 | query: 'PascalCase', 19 | mutation: 'PascalCase', 20 | subscription: 'PascalCase', 21 | fragment: 'PascalCase', 22 | }, 23 | ], 24 | '@graphql-eslint/unique-fragment-name': 'error', 25 | '@graphql-eslint/unique-operation-name': 'error', 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /config/eslint/rules/js-rules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Denied global var uses 3 | */ 4 | const restrictedGlobalList = [ 5 | 'global', 6 | // 'window', TODO: put it back and find an alternative way to use it 7 | 'localStorage', 8 | 'postMessage', 9 | 'blur', 10 | 'focus', 11 | 'close', 12 | 'frames', 13 | 'self', 14 | 'parent', 15 | 'opener', 16 | 'top', 17 | 'length', 18 | 'closed', 19 | // 'location', TODO: put it back and find an alternative way to use it 20 | 'origin', 21 | 'name', 22 | 'locationbar', 23 | 'menubar', 24 | 'personalbar', 25 | 'scrollbars', 26 | 'statusbar', 27 | 'toolbar', 28 | 'status', 29 | 'frameElement', 30 | 'navigator', 31 | 'customElements', 32 | 'external', 33 | 'screen', 34 | 'innerWidth', 35 | 'innerHeight', 36 | 'scrollX', 37 | 'pageXOffset', 38 | 'scrollY', 39 | 'pageYOffset', 40 | 'screenX', 41 | 'screenY', 42 | 'outerWidth', 43 | 'outerHeight', 44 | 'devicePixelRatio', 45 | 'clientInformation', 46 | 'screenLeft', 47 | 'screenTop', 48 | 'defaultStatus', 49 | 'defaultstatus', 50 | 'styleMedia', 51 | 'onanimationend', 52 | 'onanimationiteration', 53 | 'onanimationstart', 54 | 'onsearch', 55 | 'ontransitionend', 56 | 'onwebkitanimationend', 57 | 'onwebkitanimationiteration', 58 | 'onwebkitanimationstart', 59 | 'onwebkittransitionend', 60 | 'isSecureContext', 61 | 'onabort', 62 | 'onblur', 63 | 'oncancel', 64 | 'oncanplay', 65 | 'oncanplaythrough', 66 | 'onchange', 67 | 'onclick', 68 | 'onclose', 69 | 'oncontextmenu', 70 | 'oncuechange', 71 | 'ondblclick', 72 | 'ondrag', 73 | 'ondragend', 74 | 'ondragenter', 75 | 'ondragleave', 76 | 'ondragover', 77 | 'ondragstart', 78 | 'ondrop', 79 | 'ondurationchange', 80 | 'onemptied', 81 | 'onended', 82 | 'onerror', 83 | 'onfocus', 84 | 'oninput', 85 | 'oninvalid', 86 | 'onkeydown', 87 | 'onkeypress', 88 | 'onkeyup', 89 | 'onload', 90 | 'onloadeddata', 91 | 'onloadedmetadata', 92 | 'onloadstart', 93 | 'onmousedown', 94 | 'onmouseenter', 95 | 'onmouseleave', 96 | 'onmousemove', 97 | 'onmouseout', 98 | 'onmouseover', 99 | 'onmouseup', 100 | 'onmousewheel', 101 | 'onpause', 102 | 'onplay', 103 | 'onplaying', 104 | 'onprogress', 105 | 'onratechange', 106 | 'onreset', 107 | 'onresize', 108 | 'onscroll', 109 | 'onseeked', 110 | 'onseeking', 111 | 'onselect', 112 | 'onstalled', 113 | 'onsubmit', 114 | 'onsuspend', 115 | 'ontimeupdate', 116 | 'ontoggle', 117 | 'onvolumechange', 118 | 'onwaiting', 119 | 'onwheel', 120 | 'onauxclick', 121 | 'ongotpointercapture', 122 | 'onlostpointercapture', 123 | 'onpointerdown', 124 | 'onpointermove', 125 | 'onpointerup', 126 | 'onpointercancel', 127 | 'onpointerover', 128 | 'onpointerout', 129 | 'onpointerenter', 130 | 'onpointerleave', 131 | 'onafterprint', 132 | 'onbeforeprint', 133 | 'onbeforeunload', 134 | 'onhashchange', 135 | 'onlanguagechange', 136 | 'onmessage', 137 | 'onmessageerror', 138 | 'onoffline', 139 | 'ononline', 140 | 'onpagehide', 141 | 'onpageshow', 142 | 'onpopstate', 143 | 'onrejectionhandled', 144 | 'onstorage', 145 | 'onunhandledrejection', 146 | 'onunload', 147 | 'performance', 148 | 'stop', 149 | 'open', 150 | 'print', 151 | 'captureEvents', 152 | 'releaseEvents', 153 | 'getComputedStyle', 154 | 'matchMedia', 155 | 'moveTo', 156 | 'moveBy', 157 | 'resizeTo', 158 | 'resizeBy', 159 | 'getSelection', 160 | 'find', 161 | 'createImageBitmap', 162 | 'scroll', 163 | 'scrollTo', 164 | 'scrollBy', 165 | 'onappinstalled', 166 | 'onbeforeinstallprompt', 167 | 'crypto', 168 | 'ondevicemotion', 169 | 'ondeviceorientation', 170 | 'ondeviceorientationabsolute', 171 | 'indexedDB', 172 | 'webkitStorageInfo', 173 | 'chrome', 174 | 'visualViewport', 175 | 'speechSynthesis', 176 | 'webkitRequestFileSystem', 177 | 'webkitResolveLocalFileSystemURL', 178 | 'openDatabase', 179 | ]; 180 | 181 | /** 182 | * Common JS rules 183 | * 184 | * @see https://eslint.org/docs/rules/ 185 | */ 186 | module.exports = { 187 | jsRules: { 188 | 'array-callback-return': ['error'], 189 | 'no-await-in-loop': ['off'], 190 | 'no-duplicate-imports': ['error'], 191 | 'no-self-compare': ['error'], 192 | 'no-template-curly-in-string': ['error'], 193 | 'no-unmodified-loop-condition': ['error'], 194 | 'no-unreachable-loop': ['error'], 195 | 'require-atomic-updates': ['error'], 196 | 'arrow-body-style': ['error', 'as-needed'], 197 | 'block-scoped-var': ['error'], 198 | curly: ['error'], 199 | 'default-case-last': ['error'], 200 | 'dot-notation': ['error'], 201 | eqeqeq: ['error'], 202 | 'func-names': ['error', 'as-needed'], 203 | 'func-style': ['error'], 204 | 'id-length': ['error', { exceptions: ['_', 'i', 't', 'e'] }], 205 | 'max-params': ['error'], 206 | 'no-alert': ['error'], 207 | 'no-array-constructor': ['error'], 208 | 'no-bitwise': ['error'], 209 | 'no-caller': ['error'], 210 | 'no-console': ['warn', { allow: ['error', 'warn'] }], 211 | 'no-continue': ['error'], 212 | 'no-else-return': ['error'], 213 | 'no-empty-function': ['error', { allow: ['constructors'] }], 214 | 'no-eval': ['error'], 215 | 'no-floating-decimal': ['error'], 216 | 'no-implied-eval': ['error'], 217 | 'no-invalid-this': ['error'], 218 | 'no-iterator': ['error'], 219 | 'no-label-var': ['error'], 220 | 'no-lone-blocks': ['error'], 221 | 'no-lonely-if': ['error'], 222 | 'no-loop-func': ['error'], 223 | 'no-mixed-operators': ['error'], 224 | 'no-multi-assign': ['error'], 225 | 'no-nested-ternary': ['error'], 226 | 'no-new-wrappers': ['error'], 227 | 'no-proto': ['error'], 228 | 'no-restricted-globals': ['error'].concat(restrictedGlobalList), 229 | 'no-return-assign': ['error'], 230 | 'no-shadow': ['error'], 231 | 'no-underscore-dangle': ['error', { allow: ['_id'] }], 232 | 'no-unneeded-ternary': ['error'], 233 | 'no-unused-expressions': ['error', { allowShortCircuit: true }], 234 | 'no-useless-call': ['error'], 235 | 'no-useless-computed-key': ['error'], 236 | 'no-useless-concat': ['error'], 237 | 'no-useless-rename': ['error'], 238 | 'no-useless-return': ['error'], 239 | 'no-var': ['error'], 240 | 'prefer-const': ['error'], 241 | 'prefer-object-spread': ['error'], 242 | 'prefer-rest-params': ['error'], 243 | 'prefer-spread': ['error'], 244 | 'prefer-template': ['error'], 245 | 'eol-last': ['error'], 246 | }, 247 | }; 248 | -------------------------------------------------------------------------------- /config/eslint/rules/react-rules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React rules 3 | * 4 | * @see https://github.com/yannickcr/eslint-plugin-react#list-of-supported-rules 5 | */ 6 | module.exports = { 7 | reactExtends: [ 8 | 'plugin:react/recommended', 9 | 'plugin:react/jsx-runtime', 10 | 'plugin:react-hooks/recommended', 11 | 'plugin:jsx-a11y/recommended', 12 | ], 13 | reactRules: { 14 | 'react/no-array-index-key': ['error'], 15 | 'react/no-this-in-sfc': ['error'], 16 | 'react/no-unstable-nested-components': ['error', { allowAsProps: true }], 17 | 'react/prefer-stateless-function': ['error'], 18 | 'react/self-closing-comp': ['error'], 19 | 'react/void-dom-elements-no-children': ['error'], 20 | 'react/jsx-boolean-value': ['error'], 21 | 'react/jsx-curly-brace-presence': ['error', 'never'], 22 | 'react/jsx-filename-extension': [ 23 | 'error', 24 | { 25 | allow: 'as-needed', 26 | extensions: ['.tsx'], 27 | }, 28 | ], 29 | 'react/jsx-fragments': ['error'], 30 | 'react/jsx-no-constructed-context-values': ['error'], 31 | 'react/jsx-no-useless-fragment': ['error'], 32 | 'react/jsx-pascal-case': ['error'], 33 | 34 | // useless in TS context 35 | 'react/prop-types': 'off', 36 | 37 | // false-positives with jsx returned by common functions 38 | 'react/display-name': 'off', 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /config/eslint/rules/ts-rules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common TS rules 3 | * 4 | * @see https://typescript-eslint.io/rules/ 5 | */ 6 | module.exports = { 7 | tsExtends: [ 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:import/typescript', 10 | ], 11 | tsRules: { 12 | '@typescript-eslint/array-type': ['error'], 13 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 14 | '@typescript-eslint/method-signature-style': ['error'], 15 | '@typescript-eslint/no-confusing-non-null-assertion': ['error'], 16 | '@typescript-eslint/no-extraneous-class': [ 17 | 'error', 18 | { allowWithDecorator: true }, 19 | ], 20 | '@typescript-eslint/no-invalid-void-type': ['error'], 21 | '@typescript-eslint/no-meaningless-void-operator': ['error'], 22 | '@typescript-eslint/no-non-null-asserted-nullish-coalescing': ['error'], 23 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': ['error'], 24 | '@typescript-eslint/no-unnecessary-condition': ['error'], 25 | '@typescript-eslint/no-unnecessary-type-arguments': ['error'], 26 | '@typescript-eslint/no-unused-vars': ['error', { args: 'none' }], 27 | '@typescript-eslint/non-nullable-type-assertion-style': ['error'], 28 | '@typescript-eslint/prefer-for-of': ['error'], 29 | '@typescript-eslint/prefer-includes': ['error'], 30 | // disabled since rule is quite broken 31 | // @see https://github.com/typescript-eslint/typescript-eslint/issues/1768 32 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 33 | '@typescript-eslint/prefer-optional-chain': ['error'], 34 | '@typescript-eslint/prefer-regexp-exec': ['error'], 35 | '@typescript-eslint/prefer-string-starts-ends-with': ['error'], 36 | '@typescript-eslint/switch-exhaustiveness-check': ['error'], 37 | '@typescript-eslint/type-annotation-spacing': ['error'], 38 | '@typescript-eslint/unified-signatures': ['error'], 39 | '@typescript-eslint/consistent-indexed-object-style': ['error', 'record'], 40 | 41 | // Non-null assertion is useful when we know value being non-null (or should be) 42 | // Some example: Array.find() use, or graphql return data (data!.byPath!.prop!) 43 | // A null value is often easy to debug since it generally throws an error 44 | '@typescript-eslint/no-non-null-assertion': 'off', 45 | 46 | 'import/no-relative-packages': 'error', 47 | 'import/no-anonymous-default-export': [ 48 | 'error', 49 | { allowCallExpression: false }, 50 | ], 51 | 52 | // This rule is not compatible with Next.js's components 53 | 'jsx-a11y/anchor-is-valid': 'off', 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /config/eslint/rules/unicorn-rules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Unicorn rules 3 | * 4 | * @see https://github.com/sindresorhus/eslint-plugin-unicorn 5 | */ 6 | module.exports = { 7 | unicornExtends: ['plugin:unicorn/recommended'], 8 | unicornRules: { 9 | 'unicorn/prevent-abbreviations': 'off', 10 | 'unicorn/prefer-module': 'off', 11 | 'unicorn/numeric-separators-style': 'off', 12 | 'unicorn/filename-case': ['off'], 13 | 'unicorn/no-null': 'off', 14 | 'unicorn/no-array-reduce': 'off', 15 | 'unicorn/no-array-callback-reference': 'off', 16 | 'unicorn/no-useless-undefined': 'off', 17 | 'unicorn/no-array-for-each': 'off', 18 | 19 | // Not compatible with Next.js v12 20 | // @see https://github.com/vercel/next.js/issues/28774 21 | 'unicorn/prefer-node-protocol': 'off', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /config/graphql-codegen/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | 4 | const basePath = process.env.CURRENT_CWD || process.env.INIT_CWD; 5 | if (!basePath) { 6 | throw new Error('Env INIT_CWD or CURRENT_CWD not defined'); 7 | } 8 | 9 | const getSchemaPath = () => { 10 | const schemaPath = process.argv[4]; 11 | if (schemaPath) { 12 | return schemaPath; 13 | } 14 | 15 | return `${basePath}/schema.graphql`; 16 | }; 17 | 18 | const pluginHeader = { 19 | add: { 20 | content: '// GENERATED FILE - DO NOT EDIT', 21 | }, 22 | }; 23 | 24 | const documentsPattern = path.join(basePath, 'src/graphql/**/*.graphql'); 25 | 26 | const hasDocuments = glob.sync(documentsPattern).length > 0; 27 | 28 | module.exports = { 29 | overwrite: true, 30 | schema: getSchemaPath(), 31 | documents: hasDocuments ? documentsPattern : undefined, 32 | generates: { 33 | [path.join(basePath, 'src/graphql/types.generated.ts')]: { 34 | plugins: ['typescript', pluginHeader], 35 | config: { 36 | maybeValue: 'T | undefined', 37 | defaultScalarType: 'unknown', 38 | scalars: { 39 | DateTime: 'string', 40 | }, 41 | }, 42 | }, 43 | [path.join(basePath, 'src/graphql/typePolicies.generated.ts')]: [ 44 | 'typescript-apollo-client-helpers', 45 | pluginHeader, 46 | ], 47 | [path.join(basePath, 'src/graphql/')]: { 48 | preset: 'near-operation-file', 49 | presetConfig: { 50 | baseTypesPath: 'types.generated.ts', 51 | }, 52 | plugins: [ 53 | 'typescript-operations', 54 | 'typescript-react-apollo', 55 | pluginHeader, 56 | ], 57 | config: { 58 | preResolveTypes: false, 59 | skipTypename: true, 60 | withRefetchFn: true, 61 | }, 62 | }, 63 | }, 64 | hooks: { 65 | afterAllFileWrite: ['prettier -w'], 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /docs/fly-io.md: -------------------------------------------------------------------------------- 1 | # Hosting and deploying to fly.io 2 | 3 | ## Create a fly.io account 4 | 5 | 1. Go to [fly.io](https://fly.io) and click on _Get Started_. 6 | 1. [Download and install](https://fly.io/docs/flyctl/installing/) the Fly CLI 7 | 1. From the command line, sign up for Fly: `flyctl auth signup`. You can sign up with an email address or with a GitHub account. 8 | 1. Fill in credit card information and click _Subscribe_. 9 | 10 | ## Build and deploy a container 11 | 12 | 1. Create an app using `flyctl launch`. You can choose your own app name or press enter to let Fly pick an app name. Choose a region for deployment (it should default to the closest one to you). Choose _No_ for DB. Choose _No_ to deploy now. 13 | 1. To create a new app in the Partner Dashboard or to link the app to an existing app, run the following command using your preferred package manager: 14 | 15 | Using yarn: 16 | 17 | ```shell 18 | yarn run info --web-env 19 | ``` 20 | 21 | Using npm: 22 | 23 | ```shell 24 | npm run info --web-env 25 | ``` 26 | 27 | Using pnpm: 28 | 29 | ```shell 30 | pnpm run info --web-env 31 | ``` 32 | 33 | Take note of the `SCOPES`, `SHOPIFY_API_KEY` and the `SHOPIFY_API_SECRET` values, as you'll need them in the next steps. 34 | 35 | 1. Make the following changes to the `fly.toml` file. 36 | 37 | - In the `[env]` section, add the following environment variables (in a `"` delimited string): 38 | 39 | | Variable | Description/value | 40 | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 41 | | `BACKEND_PORT` | The port on which to run the app; set to the same value as the `EXPOSE` line in the `Dockerfile` (`Dockerfile` default value is `8081`. | 42 | | `HOST` | Set this to the URL of the new app, which can be constructed by taking the `app` variable at the very top of the `fly.toml` file, prepending it with `https://` and adding `.fly.dev` to the end, e.g, if `app` is `"fancy-cloud-1234"`, then `HOST` should be set to `https://fancy-cloud-1234.fly.dev` | 43 | | `SCOPES` | Can be obtained from the `run info --web-env` command in the previous step | 44 | | `SHOPIFY_API_KEY` | Can be obtained from the `run info --web-env` command in the previous step | 45 | 46 | - In the `[[services]]` section, change the value of `internal_port` to match the `BACKEND_PORT` value. 47 | 48 | - Example: 49 | 50 | ```ini 51 | : 52 | : 53 | [env] 54 | BACKEND_PORT = "8081" 55 | HOST = "https://fancy-cloud-1234.fly.dev" 56 | SCOPES = "write_products" 57 | SHOPIFY_API_KEY = "ReplaceWithKEYFromEnvCommand" 58 | 59 | : 60 | : 61 | 62 | [[services]] 63 | internal_port = 8081 64 | : 65 | : 66 | ``` 67 | 68 | 1. Set the API secret environment variable for your app: 69 | 70 | ```shell 71 | flyctl secrets set SHOPIFY_API_SECRET=ReplaceWithSECRETFromEnvCommand 72 | ``` 73 | 74 | 1. Build and deploy the app - note that you'll need the `SHOPIFY_API_KEY` to pass to the command 75 | 76 | ```shell 77 | flyctl deploy --build-arg SHOPIFY_API_KEY=ReplaceWithKEYFromEnvCommand --remote-only 78 | ``` 79 | 80 | ## Update URLs in Partner Dashboard and test your app 81 | 82 | 1. Update main and callback URLs in Partner Dashboard to point to new app. The main app URL should point to 83 | 84 | ```text 85 | https://my-app-name.fly.dev 86 | ``` 87 | 88 | and the callback URL should be 89 | 90 | ```text 91 | https://my-app-name.fly.dev/api/auth/callback 92 | ``` 93 | 94 | 1. Test the deployed app by browsing to 95 | 96 | ```text 97 | https://my-app-name.fly.dev/api/auth?shop=my-dev-shop-name.myshopify.com 98 | ``` 99 | 100 | ## Deploy a new version of the app 101 | 102 | 1. After updating your code with new features and fixes, rebuild and redeploy using: 103 | 104 | ```shell 105 | flyctl deploy --build-arg SHOPIFY_API_KEY=ReplaceWithKEYFromEnvCommand --remote-only 106 | ``` 107 | 108 | ## To scale to multiple regions 109 | 110 | 1. Add a new region using `flyctl regions add CODE`, where `CODE` is the three-letter code for the region. To obtain a list of regions and code, run `flyctl platform regions`. 111 | 1. Scale to two instances - `flyctl scale count 2` 112 | -------------------------------------------------------------------------------- /docs/heroku.md: -------------------------------------------------------------------------------- 1 | # Hosting and deploying to Heroku 2 | 3 | > Note: this deployment to Heroku relies on `git` so your app will need to be committed to a `git` repository. If you haven't done so yet, run the following commands to initialize and commit your source code to a `git` repository: 4 | 5 | ```shell 6 | # be at the top-level of your app directory 7 | git init 8 | git add . 9 | git commit -m "Initial version" 10 | ``` 11 | 12 | ## Create and login to a Heroku account 13 | 14 | 1. Go to [heroku.com](https://heroku.com) and click on _Sign up_ 15 | 1. [Download and install](https://devcenter.heroku.com/articles/heroku-cli#install-the-heroku-cli) the Heroku CLI 16 | 1. Login to the Heroku CLI using `heroku login` 17 | 18 | ## Build and deploy from Git to a Docker container 19 | 20 | 1. Login to Heroku Container Registry: `heroku container:login` 21 | 1. Create an app in Heroku using `heroku create -a my-app-name -s container`. This will configure Heroku with a container-based app and create a git remote named `heroku` for deploying the app. It will also return the URL to where the app will run when deployed, in the form of: 22 | 23 | ```text 24 | https://my-app-name.herokuapp.com 25 | ``` 26 | 27 | 1. To create a new app in the Partner Dashboard or to link the app to an existing app, run the following command using your preferred package manager: 28 | 29 | Using yarn: 30 | 31 | ```shell 32 | yarn run info --web-env 33 | ``` 34 | 35 | Using npm: 36 | 37 | ```shell 38 | npm run info --web-env 39 | ``` 40 | 41 | Using pnpm: 42 | 43 | ```shell 44 | pnpm run info --web-env 45 | ``` 46 | 47 | Take note of the `SCOPES`, `SHOPIFY_API_KEY` and the `SHOPIFY_API_SECRET` values, as you'll need them in the next steps. 48 | 49 | 1. Configure the environment variables `HOST`, `SCOPES`, `SHOPIFY_API_KEY`, and `SHOPIFY_API_SECRET` for your app. For example: 50 | 51 | ```shell 52 | heroku config:set HOST=https://my-app-name.herokuapp.com 53 | heroku config:set SCOPES=write_products 54 | heroku config:set SHOPIFY_API_KEY=ReplaceWithKEYFromEnvCommand 55 | heroku config:set SHOPIFY_API_SECRET=ReplaceWithSECRETFromEnvCommand 56 | ``` 57 | 58 | Note that these commands can be combined into a single command: 59 | 60 | ```shell 61 | heroku config:set HOST=... SCOPES=... SHOPIFY_API_KEY=... SHOPIFY_API_SECRET=... 62 | ``` 63 | 64 | 1. At the top-level directory of your app's source code, create a `heroku.yml` file with the following content: 65 | 66 | ```yaml 67 | build: 68 | docker: 69 | web: Dockerfile 70 | config: 71 | SHOPIFY_API_KEY: ReplaceWithKEYFromEnvCommand 72 | ``` 73 | 74 | Commit the `heroku.yml` file to your git repository: 75 | 76 | ```shell 77 | git add heroku.yml 78 | git commit -m "Add Heroku manifest" 79 | ``` 80 | 81 | 1. Push the app to Heroku. This will automatically build the `docker` image and deploy the app. 82 | 83 | ```shell 84 | git push heroku main 85 | ``` 86 | 87 | ## Update URLs in Partner Dashboard and test your app 88 | 89 | 1. Update main and callback URLs in Partner Dashboard to point to new app. The main app URL should point to 90 | 91 | ```text 92 | https://my-app-name.herokuapp.com 93 | ``` 94 | 95 | and the callback URL should be 96 | 97 | ```text 98 | https://my-app-name.herokuapp.com/api/auth/callback 99 | ``` 100 | 101 | 1. Test the deployed app by browsing to 102 | 103 | ```text 104 | https://my-app-name.herokuapp.com/api/auth?shop=my-dev-shop-name.myshopify.com 105 | ``` 106 | 107 | ## Deploy a new version of the app 108 | 109 | 1. Update code and commit to git. If updates were made on a branch, merge branch with `main` 110 | 1. Push `main` to Heroku: `git push heroku main` - this will automatically deploy the new version of your app. 111 | 112 | > Heroku's dynos should restart automatically after setting the environment variables or pushing a new update from git. If you need to restart the dynos manually, use `heroku ps:restart`. 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ralphs-shopify-app-template-node", 3 | "version": "0.0.0-development", 4 | "private": true, 5 | "packageManager": "yarn@3.2.3", 6 | "license": "UNLICENSED", 7 | "workspaces": [ 8 | "web/*" 9 | ], 10 | "scripts": { 11 | "build": "shopify app build", 12 | "c:format": "prettier -c $INIT_CWD", 13 | "c:format:fix": "prettier -w $INIT_CWD", 14 | "c:lint": "eslint $INIT_CWD --cache --cache-location node_modules/.cache/.eslintcache", 15 | "c:lint:ci": "eslint $INIT_CWD", 16 | "c:lint:fix": "eslint $INIT_CWD --cache --cache-location node_modules/.cache/.eslintcache --fix", 17 | "c:pkg:deps": "syncpack list-mismatches && syncpack lint-semver-ranges", 18 | "c:pkg:deps:fix": "syncpack fix-mismatches && syncpack set-semver-ranges", 19 | "c:pkg:fix": "yarn c:pkg:deps:fix && yarn c:pkg:format:fix", 20 | "c:pkg:format:fix": "syncpack format", 21 | "c:test": "yarn c:test:frontend && yarn c:test:backend", 22 | "c:test:backend": "jest $INIT_CWD/backend --cache --passWithNoTests", 23 | "c:test:backend:ci": "jest $INIT_CWD --cache --passWithNoTests --ci -i", 24 | "c:test:backend:watch": "jest $INIT_CWD --cache --passWithNoTests --watch", 25 | "c:test:frontend": "vitest --reporter=verbose", 26 | "c:type": "cd $INIT_CWD && tsc -b", 27 | "deploy": "shopify app deploy", 28 | "dev": "shopify app dev", 29 | "gen:tsconfig": "ts-node scripts/generators/tsconfig.ts", 30 | "info": "shopify app info", 31 | "scaffold": "shopify app scaffold", 32 | "shopify": "shopify", 33 | "semantic-release": "semantic-release" 34 | }, 35 | "dependencies": { 36 | "@apollo/client": "3.6.9", 37 | "@shopify/app": "3.16.3", 38 | "@shopify/app-bridge": "3.7.3", 39 | "@shopify/app-bridge-react": "3.2.5", 40 | "@shopify/app-bridge-utils": "3.2.5", 41 | "@shopify/cli": "3.16.3", 42 | "@shopify/polaris": "10.6.0", 43 | "@shopify/shopify-api": "6.2.0", 44 | "@vitejs/plugin-react": "2.1.0", 45 | "compression": "1.7.4", 46 | "cookie-parser": "1.4.6", 47 | "cross-env": "7.0.3", 48 | "dotenv": "16.0.2", 49 | "express": "4.18.1", 50 | "graphql": "16.6.0", 51 | "react": "18.2.0", 52 | "react-dom": "18.2.0", 53 | "serve-static": "1.15.0", 54 | "vite": "3.1.6" 55 | }, 56 | "devDependencies": { 57 | "@axe/common": "0.1.0", 58 | "@babel/core": "7.18.13", 59 | "@babel/eslint-parser": "7.18.9", 60 | "@babel/preset-env": "7.18.10", 61 | "@babel/preset-typescript": "7.18.6", 62 | "@graphql-eslint/eslint-plugin": "3.10.7", 63 | "@types/eslint": "8.4.6", 64 | "@types/glob": "8.0.0", 65 | "@types/node": "18.7.14", 66 | "@types/shelljs": "0.8.11", 67 | "@typescript-eslint/eslint-plugin": "5.36.1", 68 | "@typescript-eslint/parser": "5.36.1", 69 | "eslint": "8.23.0", 70 | "eslint-config-prettier": "8.5.0", 71 | "eslint-import-resolver-typescript": "3.5.0", 72 | "eslint-plugin-import": "2.26.0", 73 | "eslint-plugin-jsx-a11y": "6.6.1", 74 | "eslint-plugin-react": "7.31.2", 75 | "eslint-plugin-react-hooks": "4.6.0", 76 | "eslint-plugin-unicorn": "43.0.2", 77 | "glob": "8.0.3", 78 | "prettier": "2.7.1", 79 | "pretty-quick": "3.1.3", 80 | "sass": "1.54.8", 81 | "semantic-release": "^19.0.5", 82 | "shelljs": "0.8.5", 83 | "syncpack": "8.2.4", 84 | "ts-node": "10.9.1", 85 | "tsconfig-paths": "4.1.0", 86 | "tslib": "2.4.0", 87 | "typescript": "4.8.2", 88 | "typescript-plugin-css-modules": "3.4.0", 89 | "vitest": "0.22.1" 90 | }, 91 | "repository": { 92 | "type": "git", 93 | "url": "https://github.com/Crunchyman-ralph/shopify-app-node-monorepo-express-vite.git" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /scripts/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | node: true, 5 | es6: true, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /scripts/generators/tsconfig.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | 4 | /** 5 | * Generate tsconfig.json files for every packages including root. 6 | * Define paths and references. 7 | */ 8 | 9 | import path from 'path'; 10 | import fs from 'fs'; 11 | import glob from 'glob'; 12 | import shx from 'shelljs'; 13 | import { ArrayUtils } from '@axe/common'; 14 | 15 | type Pkg = { 16 | name: string; 17 | workspaces?: string[]; 18 | dependencies?: Record; 19 | devDependencies?: Record; 20 | peerDependencies?: Record; 21 | }; 22 | 23 | if (!process.env.PROJECT_CWD) { 24 | throw new TypeError('PROJECT_CWD env not defined'); 25 | } 26 | const rootPath = process.env.PROJECT_CWD; 27 | 28 | const { workspaces = [] }: Pkg = require(path.resolve( 29 | rootPath, 30 | 'package.json' 31 | )); 32 | 33 | const tsPkgs = workspaces 34 | .flatMap((pattern) => glob.sync(pattern)) 35 | .map((pt) => { 36 | const pkg: Pkg = require(path.resolve(pt, 'package.json')); 37 | 38 | const everyDeps = { 39 | ...pkg.dependencies, 40 | ...pkg.devDependencies, 41 | ...pkg.peerDependencies, 42 | }; 43 | 44 | const deps = ArrayUtils.unique( 45 | Object.keys(everyDeps).filter((dep) => 46 | everyDeps[dep].startsWith('workspace:') 47 | ) 48 | ); 49 | 50 | return { 51 | name: pkg.name, 52 | dir: path.resolve(pt), 53 | relative: `./${path.join(pt)}`, 54 | deps, 55 | }; 56 | }); 57 | 58 | // generate tsconfig.json for each package 59 | tsPkgs.forEach(({ name, dir, deps }) => { 60 | const isApp = !name.startsWith('@axe/'); 61 | 62 | const references = deps.map((dep) => { 63 | const tsPkg = tsPkgs.find((tp) => tp.name === dep); 64 | 65 | return { path: path.relative(dir, tsPkg!.dir) }; 66 | }); 67 | 68 | const paths = { 69 | [`${name}/*`]: [isApp ? './*' : './src/*'], 70 | }; 71 | 72 | const tsConfigPath = path.resolve(dir, 'tsconfig.json'); 73 | 74 | const existingTsConfig: { 75 | compilerOptions?: Record; 76 | include?: string[]; 77 | } = fs.existsSync(tsConfigPath) ? require(tsConfigPath) : {}; 78 | 79 | const includeExts = ['ts', 'tsx', 'js', 'json'].map( 80 | (ext) => (isApp ? './**/*.' : './src/**/*.') + ext 81 | ); 82 | 83 | fs.writeFileSync( 84 | tsConfigPath, 85 | JSON.stringify( 86 | { 87 | ...existingTsConfig, 88 | extends: '../../tsconfig.base.json', 89 | compilerOptions: { 90 | ...existingTsConfig.compilerOptions, 91 | rootDir: isApp ? undefined : './src', 92 | outDir: './node_modules/.cache/dist', 93 | tsBuildInfoFile: './node_modules/.cache/.tsbuildinfo', 94 | paths, 95 | }, 96 | include: ArrayUtils.unique([ 97 | ...includeExts, 98 | ...(existingTsConfig.include || []), 99 | ]), 100 | references, 101 | }, 102 | null, 103 | 2 104 | ) 105 | ); 106 | }); 107 | 108 | // generate root tsconfig.json 109 | fs.writeFileSync( 110 | path.resolve(rootPath, `tsconfig.json`), 111 | `/* This file is automatically generated by scripts/generators/tsconfig.js */\n${JSON.stringify( 112 | { 113 | extends: './tsconfig.base.json', 114 | compilerOptions: { 115 | noEmit: true, 116 | tsBuildInfoFile: './node_modules/.cache/.tsbuildinfo', 117 | }, 118 | include: ['*.js', '*.json', 'scripts/**/*.js', 'config/**/*.js'], 119 | references: [ 120 | { 121 | path: 'scripts', 122 | }, 123 | ...tsPkgs.map(({ dir }) => ({ 124 | path: path.relative(rootPath, dir), 125 | })), 126 | ], 127 | }, 128 | null, 129 | 2 130 | )}` 131 | ); 132 | 133 | // format every tsconfig.json files 134 | shx.exec(`yarn prettier -w "${rootPath}/**/tsconfig.json"`); 135 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./node_modules/.cache/dist", 5 | "tsBuildInfoFile": "./node_modules/.cache/.tsbuildinfo" 6 | }, 7 | "includes": ["."] 8 | } 9 | -------------------------------------------------------------------------------- /shopify.app.toml: -------------------------------------------------------------------------------- 1 | # This file stores configurations for your Shopify app. 2 | 3 | scopes = "write_products" 4 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "composite": true, 5 | "jsx": "react-jsx", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "allowSyntheticDefaultImports": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "sourceMap": true, 12 | "declaration": true, 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "importHelpers": true, 18 | "target": "es2015", 19 | "module": "esnext", 20 | "lib": ["es2021", "dom"], 21 | "skipLibCheck": true, 22 | "skipDefaultLibCheck": true, 23 | "resolveJsonModule": true, 24 | "isolatedModules": true, 25 | "incremental": true, 26 | "declarationMap": true 27 | }, 28 | "exclude": [ 29 | "**/.yarn", 30 | "**/node_modules", 31 | "**/dist", 32 | "**/.next", 33 | "**/jest.*.js" 34 | ], 35 | "ts-node": { 36 | "compilerOptions": { 37 | "module": "commonjs" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* This file is automatically generated by scripts/generators/tsconfig.js */ 2 | { 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "noEmit": true, 6 | "tsBuildInfoFile": "./node_modules/.cache/.tsbuildinfo" 7 | }, 8 | "include": ["*.js", "*.json", "scripts/**/*.js", "config/**/*.js"], 9 | "references": [ 10 | { 11 | "path": "scripts" 12 | }, 13 | { 14 | "path": "web/axe-common" 15 | }, 16 | { 17 | "path": "web/backend" 18 | }, 19 | { 20 | "path": "web/frontend" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /web/axe-common/jest.config.js: -------------------------------------------------------------------------------- 1 | const { base, getModuleNameMapper } = require('../../jest.config.base'); 2 | 3 | module.exports = Object.assign({}, base, { 4 | displayName: require('./package.json').name, 5 | testEnvironment: 'jsdom', 6 | roots: ['/src'], 7 | 8 | moduleNameMapper: getModuleNameMapper(require('./tsconfig.json')), 9 | 10 | setupFilesAfterEnv: ['/src/SetupTests.ts'], 11 | }); 12 | -------------------------------------------------------------------------------- /web/axe-common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@axe/common", 3 | "version": "0.1.0", 4 | "private": true, 5 | "packageManager": "yarn@3.2.0", 6 | "license": "UNLICENSED", 7 | "main": "src/index.ts", 8 | "peerDependencies": { 9 | "dotenv": "16.0.2" 10 | }, 11 | "devDependencies": { 12 | "@types/jest": "29.0.0", 13 | "@types/node": "18.7.14", 14 | "dotenv": "16.0.2", 15 | "jest": "29.0.1", 16 | "tslib": "2.4.0" 17 | }, 18 | "exports": { 19 | ".": "./src/index.ts", 20 | "./*": "./src/*" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/axe-common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './tools'; 2 | -------------------------------------------------------------------------------- /web/axe-common/src/tools/ArrayUtils.ts: -------------------------------------------------------------------------------- 1 | export const ArrayUtils = { 2 | emptyArray: [], 3 | /** 4 | * Return first array item, handling undefined value 5 | */ 6 | first: (array: I[]): I | undefined => array[0], 7 | 8 | /** 9 | * Return last array item, handling undefined value 10 | */ 11 | last: (array: I[]): I | undefined => array[array.length - 1], 12 | 13 | filterNonNullable: (item: I): item is NonNullable => 14 | item !== undefined && item !== null, 15 | 16 | newArrayByLength: (length: number, firstIndex = 1) => 17 | Array.from({ length }) 18 | .fill(null) 19 | .map((_, i) => i + firstIndex), 20 | 21 | unique: (arr: I[]) => [...new Set(arr)], 22 | }; 23 | -------------------------------------------------------------------------------- /web/axe-common/src/tools/LoadEnvs.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | const defaultPaths = ['../../.env', '../../.env.local', '.env', '.env.local']; 4 | 5 | const testPaths = ['../../.env.test', '.env.test']; 6 | 7 | export const LoadEnvs = { 8 | loadEnvs: (context: 'default' | 'test' = 'default') => { 9 | const paths = context === 'test' ? testPaths : defaultPaths; 10 | 11 | const entries = paths 12 | .map((path) => dotenv.config({ path }).parsed || {}) 13 | .flatMap(Object.entries); 14 | 15 | return entries.reduce((env: Record, entry) => { 16 | env[entry[0]] = entry[1]; 17 | 18 | return env; 19 | }, {}); 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /web/axe-common/src/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ArrayUtils'; 2 | export * from './LoadEnvs'; 3 | -------------------------------------------------------------------------------- /web/axe-common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./node_modules/.cache/dist", 6 | "tsBuildInfoFile": "./node_modules/.cache/.tsbuildinfo", 7 | "paths": { 8 | "@axe/common/*": ["./src/*"] 9 | } 10 | }, 11 | "include": [ 12 | "./src/**/*.ts", 13 | "./src/**/*.tsx", 14 | "./src/**/*.js", 15 | "./src/**/*.json" 16 | ], 17 | "references": [] 18 | } 19 | -------------------------------------------------------------------------------- /web/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "private": true, 4 | "license": "UNLICENSED", 5 | "main": "src/index", 6 | "scripts": { 7 | "debug": "ts-node --inspect-brk src/index.ts", 8 | "dev": "cross-env NODE_ENV=development nodemon src/index.ts --ignore ./frontend", 9 | "serve": "cross-env NODE_ENV=production ts-node src/index.ts" 10 | }, 11 | "dependencies": { 12 | "@shopify/shopify-api": "6.2.0", 13 | "compression": "1.7.4", 14 | "cookie-parser": "1.4.6", 15 | "cross-env": "7.0.3", 16 | "express": "4.18.1", 17 | "serve-static": "1.15.0" 18 | }, 19 | "devDependencies": { 20 | "@types/compression": "1.7.2", 21 | "@types/cookie-parser": "1.4.3", 22 | "@types/express": "4.17.13", 23 | "jsonwebtoken": "8.5.1", 24 | "nodemon": "2.0.19", 25 | "prettier": "2.7.1", 26 | "pretty-quick": "3.1.3", 27 | "ts-node": "10.9.1" 28 | }, 29 | "engines": { 30 | "node": ">=14.13.1" 31 | }, 32 | "type": "commonjs" 33 | } 34 | -------------------------------------------------------------------------------- /web/backend/shopify.web.toml: -------------------------------------------------------------------------------- 1 | type="backend" 2 | 3 | [commands] 4 | dev = "yarn run dev" 5 | -------------------------------------------------------------------------------- /web/backend/src/app_installations.ts: -------------------------------------------------------------------------------- 1 | import { Shopify } from '@shopify/shopify-api'; 2 | 3 | export const AppInstallations = { 4 | includes: async function (shopDomain: string) { 5 | const shopSessions = 6 | await Shopify.Context.SESSION_STORAGE.findSessionsByShop?.(shopDomain); 7 | 8 | if (!shopSessions) { 9 | return false; 10 | } 11 | 12 | if (shopSessions.length > 0) { 13 | for (const session of shopSessions) { 14 | if (session.accessToken) { 15 | return true; 16 | } 17 | } 18 | } 19 | 20 | return false; 21 | }, 22 | 23 | delete: async function (shopDomain: string) { 24 | const shopSessions = 25 | await Shopify.Context.SESSION_STORAGE.findSessionsByShop?.(shopDomain); 26 | 27 | if (!shopSessions) { 28 | return false; 29 | } 30 | 31 | if (shopSessions.length > 0) { 32 | await Shopify.Context.SESSION_STORAGE.deleteSessions?.( 33 | shopSessions.map((session) => session.id) 34 | ); 35 | } 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /web/backend/src/gdpr.ts: -------------------------------------------------------------------------------- 1 | import { Shopify } from '@shopify/shopify-api'; 2 | 3 | export const setupGDPRWebHooks = (path: string) => { 4 | /** 5 | * Customers can request their data from a store owner. When this happens, 6 | * Shopify invokes this webhook. 7 | * 8 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#customers-data_request 9 | */ 10 | Shopify.Webhooks.Registry.addHandler('CUSTOMERS_DATA_REQUEST', { 11 | path, 12 | webhookHandler: async (topic, shop, body) => { 13 | // const payload = JSON.parse(body); 14 | // Payload has the following shape: 15 | // { 16 | // "shop_id": 954889, 17 | // "shop_domain": "{shop}.myshopify.com", 18 | // "orders_requested": [ 19 | // 299938, 20 | // 280263, 21 | // 220458 22 | // ], 23 | // "customer": { 24 | // "id": 191167, 25 | // "email": "john@example.com", 26 | // "phone": "555-625-1199" 27 | // }, 28 | // "data_request": { 29 | // "id": 9999 30 | // } 31 | // } 32 | }, 33 | }); 34 | 35 | /** 36 | * Store owners can request that data is deleted on behalf of a customer. When 37 | * this happens, Shopify invokes this webhook. 38 | * 39 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#customers-redact 40 | */ 41 | Shopify.Webhooks.Registry.addHandler('CUSTOMERS_REDACT', { 42 | path, 43 | webhookHandler: async (topic, shop, body) => { 44 | // const payload = JSON.parse(body); 45 | // Payload has the following shape: 46 | // { 47 | // "shop_id": 954889, 48 | // "shop_domain": "{shop}.myshopify.com", 49 | // "customer": { 50 | // "id": 191167, 51 | // "email": "john@example.com", 52 | // "phone": "555-625-1199" 53 | // }, 54 | // "orders_to_redact": [ 55 | // 299938, 56 | // 280263, 57 | // 220458 58 | // ] 59 | // } 60 | }, 61 | }); 62 | 63 | /** 64 | * 48 hours after a store owner uninstalls your app, Shopify invokes this 65 | * webhook. 66 | * 67 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#shop-redact 68 | */ 69 | Shopify.Webhooks.Registry.addHandler('SHOP_REDACT', { 70 | path, 71 | webhookHandler: async (topic, shop, body) => { 72 | // const payload = JSON.parse(body); 73 | // Payload has the following shape: 74 | // { 75 | // "shop_id": 954889, 76 | // "shop_domain": "{shop}.myshopify.com" 77 | // } 78 | }, 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /web/backend/src/helpers/ensure-billing.ts: -------------------------------------------------------------------------------- 1 | import { SessionInterface, Shopify } from '@shopify/shopify-api'; 2 | import { GraphqlClient } from '@shopify/shopify-api/dist/clients/graphql'; 3 | 4 | export type BillingOptions = { 5 | required: boolean; 6 | chargeName: string; 7 | amount: number; 8 | currencyCode: string; // TODO: Change type to CurrencyCode from schema.graphql 9 | interval: typeof BillingInterval[keyof typeof BillingInterval]; 10 | }; 11 | 12 | export const BillingInterval = { 13 | OneTime: 'ONE_TIME', 14 | Every30Days: 'EVERY_30_DAYS', 15 | Annual: 'ANNUAL', 16 | } as const; 17 | 18 | const RECURRING_INTERVALS = new Set([ 19 | BillingInterval.Every30Days, 20 | BillingInterval.Annual, 21 | ]); 22 | 23 | let isProd: boolean; 24 | 25 | /** 26 | * You may want to charge merchants for using your app. This helper provides that function by checking if the current 27 | * merchant has an active one-time payment or subscription named `chargeName`. If no payment is found, 28 | * this helper requests it and returns a confirmation URL so that the merchant can approve the purchase. 29 | * 30 | * Learn more about billing in our documentation: https://shopify.dev/apps/billing 31 | */ 32 | export default async function ensureBilling( 33 | session: SessionInterface, 34 | { chargeName, amount, currencyCode, interval }: BillingOptions, 35 | isProdOverride = process.env.NODE_ENV === 'production' 36 | ) { 37 | if (!Object.values(BillingInterval).includes(interval)) { 38 | throw `Unrecognized billing interval '${interval}'`; 39 | } 40 | 41 | isProd = isProdOverride; 42 | 43 | let hasPayment; 44 | let confirmationUrl = null; 45 | 46 | if (await hasActivePayment(session, { chargeName, interval })) { 47 | hasPayment = true; 48 | } else { 49 | hasPayment = false; 50 | confirmationUrl = await requestPayment(session, { 51 | chargeName, 52 | amount, 53 | currencyCode, 54 | interval, 55 | }); 56 | } 57 | 58 | return [hasPayment, confirmationUrl]; 59 | } 60 | const hasActivePayment = async ( 61 | session: SessionInterface, 62 | { chargeName, interval }: Pick 63 | ) => { 64 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken); 65 | 66 | if (isRecurring(interval)) { 67 | const currentInstallations = (await client.query({ 68 | data: RECURRING_PURCHASES_QUERY, 69 | })) as any; 70 | const subscriptions = 71 | currentInstallations.body.data.currentAppInstallation.activeSubscriptions; 72 | 73 | for (let i = 0, len = subscriptions.length; i < len; i++) { 74 | if ( 75 | subscriptions[i].name === chargeName && 76 | (!isProd || !subscriptions[i].test) 77 | ) { 78 | return true; 79 | } 80 | } 81 | } else { 82 | let purchases; 83 | let endCursor = null; 84 | do { 85 | const currentInstallations = (await client.query({ 86 | data: { 87 | query: ONE_TIME_PURCHASES_QUERY, 88 | variables: { endCursor }, 89 | }, 90 | })) as any; 91 | purchases = 92 | currentInstallations.body.data.currentAppInstallation.oneTimePurchases; 93 | 94 | for (let i = 0, len = purchases.edges.length; i < len; i++) { 95 | const node = purchases.edges[i].node; 96 | if ( 97 | node.name === chargeName && 98 | (!isProd || !node.test) && 99 | node.status === 'ACTIVE' 100 | ) { 101 | return true; 102 | } 103 | } 104 | 105 | endCursor = purchases.pageInfo.endCursor; 106 | } while (purchases.pageInfo.hasNextPage); 107 | } 108 | 109 | return false; 110 | }; 111 | 112 | const requestPayment = async ( 113 | session: SessionInterface, 114 | { 115 | chargeName, 116 | amount, 117 | currencyCode, 118 | interval, 119 | }: Omit 120 | ) => { 121 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken); 122 | const returnUrl = `https://${Shopify.Context.HOST_NAME}?shop=${ 123 | session.shop 124 | }&host=${Buffer.from(`${session.shop}/admin`).toString('base64')}`; 125 | 126 | let data; 127 | if (isRecurring(interval)) { 128 | const mutationResponse = (await requestRecurringPayment(client, returnUrl, { 129 | chargeName, 130 | amount, 131 | currencyCode, 132 | interval, 133 | })) as any; 134 | data = mutationResponse.body.data.appSubscriptionCreate; 135 | } else { 136 | const mutationResponse = await requestSinglePayment(client, returnUrl, { 137 | chargeName, 138 | amount, 139 | currencyCode, 140 | }); 141 | data = mutationResponse.body.data.appPurchaseOneTimeCreate; 142 | } 143 | 144 | if (data.userErrors.length > 0) { 145 | throw new ShopifyBillingError( 146 | 'Error while billing the store', 147 | data.userErrors 148 | ); 149 | } 150 | 151 | return data.confirmationUrl; 152 | }; 153 | 154 | const requestRecurringPayment = async ( 155 | client: GraphqlClient, 156 | returnUrl: string, 157 | { 158 | chargeName, 159 | amount, 160 | currencyCode, 161 | interval, 162 | }: Omit 163 | ) => { 164 | const mutationResponse = (await client.query({ 165 | data: { 166 | query: RECURRING_PURCHASE_MUTATION, 167 | variables: { 168 | name: chargeName, 169 | lineItems: [ 170 | { 171 | plan: { 172 | appRecurringPricingDetails: { 173 | interval, 174 | price: { amount, currencyCode }, 175 | }, 176 | }, 177 | }, 178 | ], 179 | returnUrl, 180 | test: !isProd, 181 | }, 182 | }, 183 | })) as any; 184 | 185 | if (mutationResponse.body.errors && mutationResponse.body.errors.length > 0) { 186 | throw new ShopifyBillingError( 187 | 'Error while billing the store', 188 | mutationResponse.body.errors 189 | ); 190 | } 191 | 192 | return mutationResponse; 193 | }; 194 | 195 | const requestSinglePayment = async ( 196 | client: GraphqlClient, 197 | returnUrl: string, 198 | { 199 | chargeName, 200 | amount, 201 | currencyCode, 202 | }: Omit 203 | ) => { 204 | const mutationResponse = (await client.query({ 205 | data: { 206 | query: ONE_TIME_PURCHASE_MUTATION, 207 | variables: { 208 | name: chargeName, 209 | price: { amount, currencyCode }, 210 | returnUrl, 211 | test: process.env.NODE_ENV !== 'production', 212 | }, 213 | }, 214 | })) as any; 215 | 216 | if (mutationResponse.body.errors && mutationResponse.body.errors.length > 0) { 217 | throw new ShopifyBillingError( 218 | 'Error while billing the store', 219 | mutationResponse.body.errors 220 | ); 221 | } 222 | 223 | return mutationResponse; 224 | }; 225 | 226 | const isRecurring = (interval: string) => 227 | RECURRING_INTERVALS.has( 228 | interval as Exclude 229 | ); 230 | 231 | export class ShopifyBillingError { 232 | name; 233 | stack; 234 | message; 235 | errorData; 236 | prototype; 237 | 238 | constructor(message: string, errorData: any) { 239 | this.name = 'ShopifyBillingError'; 240 | this.stack = new Error('Shopify Billing Error').stack; 241 | 242 | this.message = message; 243 | this.errorData = errorData; 244 | this.prototype = new Error('Shopify Billing Error'); 245 | } 246 | } 247 | 248 | const RECURRING_PURCHASES_QUERY = ` 249 | query appSubscription { 250 | currentAppInstallation { 251 | activeSubscriptions { 252 | name, test 253 | } 254 | } 255 | } 256 | `; 257 | 258 | const ONE_TIME_PURCHASES_QUERY = ` 259 | query appPurchases($endCursor: String) { 260 | currentAppInstallation { 261 | oneTimePurchases(first: 250, sortKey: CREATED_AT, after: $endCursor) { 262 | edges { 263 | node { 264 | name, test, status 265 | } 266 | } 267 | pageInfo { 268 | hasNextPage, endCursor 269 | } 270 | } 271 | } 272 | } 273 | `; 274 | 275 | const RECURRING_PURCHASE_MUTATION = ` 276 | mutation test( 277 | $name: String! 278 | $lineItems: [AppSubscriptionLineItemInput!]! 279 | $returnUrl: URL! 280 | $test: Boolean 281 | ) { 282 | appSubscriptionCreate( 283 | name: $name 284 | lineItems: $lineItems 285 | returnUrl: $returnUrl 286 | test: $test 287 | ) { 288 | confirmationUrl 289 | userErrors { 290 | field 291 | message 292 | } 293 | } 294 | } 295 | `; 296 | 297 | const ONE_TIME_PURCHASE_MUTATION = ` 298 | mutation test( 299 | $name: String! 300 | $price: MoneyInput! 301 | $returnUrl: URL! 302 | $test: Boolean 303 | ) { 304 | appPurchaseOneTimeCreate( 305 | name: $name 306 | price: $price 307 | returnUrl: $returnUrl 308 | test: $test 309 | ) { 310 | confirmationUrl 311 | userErrors { 312 | field 313 | message 314 | } 315 | } 316 | } 317 | `; 318 | -------------------------------------------------------------------------------- /web/backend/src/helpers/product-creator.ts: -------------------------------------------------------------------------------- 1 | import { SessionInterface, Shopify } from '@shopify/shopify-api'; 2 | 3 | const ADJECTIVES = [ 4 | 'autumn', 5 | 'hidden', 6 | 'bitter', 7 | 'misty', 8 | 'silent', 9 | 'empty', 10 | 'dry', 11 | 'dark', 12 | 'summer', 13 | 'icy', 14 | 'delicate', 15 | 'quiet', 16 | 'white', 17 | 'cool', 18 | 'spring', 19 | 'winter', 20 | 'patient', 21 | 'twilight', 22 | 'dawn', 23 | 'crimson', 24 | 'wispy', 25 | 'weathered', 26 | 'blue', 27 | 'billowing', 28 | 'broken', 29 | 'cold', 30 | 'damp', 31 | 'falling', 32 | 'frosty', 33 | 'green', 34 | 'long', 35 | ]; 36 | 37 | const NOUNS = [ 38 | 'waterfall', 39 | 'river', 40 | 'breeze', 41 | 'moon', 42 | 'rain', 43 | 'wind', 44 | 'sea', 45 | 'morning', 46 | 'snow', 47 | 'lake', 48 | 'sunset', 49 | 'pine', 50 | 'shadow', 51 | 'leaf', 52 | 'dawn', 53 | 'glitter', 54 | 'forest', 55 | 'hill', 56 | 'cloud', 57 | 'meadow', 58 | 'sun', 59 | 'glade', 60 | 'bird', 61 | 'brook', 62 | 'butterfly', 63 | 'bush', 64 | 'dew', 65 | 'dust', 66 | 'field', 67 | 'fire', 68 | 'flower', 69 | ]; 70 | 71 | export const DEFAULT_PRODUCTS_COUNT = 5; 72 | const CREATE_PRODUCTS_MUTATION = ` 73 | mutation populateProduct($input: ProductInput!) { 74 | productCreate(input: $input) { 75 | product { 76 | id 77 | } 78 | } 79 | } 80 | `; 81 | 82 | export default async function productCreator( 83 | session: SessionInterface, 84 | count = DEFAULT_PRODUCTS_COUNT 85 | ) { 86 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken); 87 | 88 | try { 89 | for (let i = 0; i < count; i++) { 90 | await client.query({ 91 | data: { 92 | query: CREATE_PRODUCTS_MUTATION, 93 | variables: { 94 | input: { 95 | title: `${randomTitle()}`, 96 | variants: [{ price: randomPrice() }], 97 | }, 98 | }, 99 | }, 100 | }); 101 | } 102 | } catch (error: any) { 103 | const newError = 104 | error instanceof Shopify.Errors.GraphqlQueryError 105 | ? new TypeError( 106 | `${error.message}\n${JSON.stringify(error.response, null, 2)}` 107 | ) 108 | : error; 109 | throw newError; 110 | } 111 | } 112 | 113 | const randomTitle = () => { 114 | const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; 115 | const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; 116 | return `${adjective} ${noun}`; 117 | }; 118 | 119 | const randomPrice = () => 120 | // eslint-disable-next-line no-mixed-operators 121 | Math.round((Math.random() * 10 + Number.EPSILON) * 100) / 100; 122 | -------------------------------------------------------------------------------- /web/backend/src/helpers/redirect-to-auth.ts: -------------------------------------------------------------------------------- 1 | import { Shopify } from '@shopify/shopify-api'; 2 | import { Express, Request, Response } from 'express'; 3 | 4 | export default async function redirectToAuth( 5 | req: Request, 6 | res: Response, 7 | app: Express 8 | ) { 9 | if (!req.query.shop) { 10 | res.status(500); 11 | return res.send('No shop provided'); 12 | } 13 | 14 | if (req.query.embedded === '1') { 15 | return clientSideRedirect(req, res); 16 | } 17 | 18 | return await serverSideRedirect(req, res, app); 19 | } 20 | 21 | const clientSideRedirect = (req: Request, res: Response) => { 22 | const shop = Shopify.Utils.sanitizeShop(req.query.shop as string); 23 | 24 | if (!shop) { 25 | res.status(500); 26 | return res.send('No shop provided'); 27 | } 28 | 29 | const redirectUriParams = new URLSearchParams({ 30 | shop: shop, 31 | host: req.query.host as string, 32 | }).toString(); 33 | const queryParams = new URLSearchParams({ 34 | ...req.query, 35 | shop, 36 | redirectUri: `https://${Shopify.Context.HOST_NAME}/api/auth?${redirectUriParams}`, 37 | }).toString(); 38 | 39 | return res.redirect(`/exitiframe?${queryParams}`); 40 | }; 41 | 42 | const serverSideRedirect = async ( 43 | req: Request, 44 | res: Response, 45 | app: Express 46 | ) => { 47 | const redirectUrl = await Shopify.Auth.beginAuth( 48 | req, 49 | res, 50 | req.query.shop as string, 51 | '/api/auth/callback', 52 | app.get('use-online-tokens') 53 | ); 54 | 55 | return res.redirect(redirectUrl); 56 | }; 57 | -------------------------------------------------------------------------------- /web/backend/src/helpers/return-top-level-redirection.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export default function returnTopLevelRedirection( 4 | req: Request, 5 | res: Response, 6 | redirectUrl: string 7 | ) { 8 | const bearerPresent = req.headers.authorization?.match(/Bearer (.*)/); 9 | 10 | // If the request has a bearer token, the app is currently embedded, and must break out of the iframe to 11 | // re-authenticate 12 | if (bearerPresent) { 13 | res.status(403); 14 | res.header('X-Shopify-API-Request-Failure-Reauthorize', '1'); 15 | res.header('X-Shopify-API-Request-Failure-Reauthorize-Url', redirectUrl); 16 | res.end(); 17 | } else { 18 | res.redirect(redirectUrl); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /web/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { readFileSync } from 'fs'; 3 | import express from 'express'; 4 | import cookieParser from 'cookie-parser'; 5 | import { 6 | Shopify, 7 | LATEST_API_VERSION, 8 | SessionInterface, 9 | } from '@shopify/shopify-api'; 10 | import { LoadEnvs } from '@axe/common'; 11 | 12 | import applyAuthMiddleware from './middleware/auth'; 13 | import verifyRequest from './middleware/verify-request'; 14 | import { setupGDPRWebHooks } from './gdpr'; 15 | import productCreator from './helpers/product-creator'; 16 | import redirectToAuth from './helpers/redirect-to-auth'; 17 | import { BillingInterval, BillingOptions } from './helpers/ensure-billing'; 18 | import { AppInstallations } from './app_installations'; 19 | 20 | const USE_ONLINE_TOKENS = false; 21 | 22 | const PORT = Number.parseInt( 23 | process.env.BACKEND_PORT! || process.env.PORT!, 24 | 10 25 | ); 26 | 27 | LoadEnvs.loadEnvs(); 28 | 29 | // TODO: There should be provided by env vars 30 | const DEV_INDEX_PATH = `${process.cwd()}/../frontend/`; 31 | const PROD_INDEX_PATH = `${process.cwd()}/frontend/dist/`; 32 | 33 | const DB_PATH = process.env.MONGODB_URI as unknown as URL | undefined; 34 | const DB_NAME = process.env.MONGODB_NAME; 35 | 36 | if (!DB_PATH || !DB_NAME) { 37 | throw new Error( 38 | 'MONGODB_URI and MONGODB_NAME must be provided, check your .env' 39 | ); 40 | } 41 | 42 | Shopify.Context.initialize({ 43 | API_KEY: process.env.SHOPIFY_API_KEY!, 44 | API_SECRET_KEY: process.env.SHOPIFY_API_SECRET!, 45 | SCOPES: process.env.SCOPES!.split(','), 46 | HOST_NAME: process.env.HOST!.replace(/https?:\/\//, ''), 47 | HOST_SCHEME: process.env.HOST!.split('://')[0], 48 | API_VERSION: LATEST_API_VERSION, 49 | IS_EMBEDDED_APP: true, 50 | // This should be replaced with your preferred storage strategy 51 | SESSION_STORAGE: new Shopify.Session.MongoDBSessionStorage(DB_PATH, DB_NAME), 52 | }); 53 | 54 | Shopify.Webhooks.Registry.addHandler('APP_UNINSTALLED', { 55 | path: '/api/webhooks', 56 | webhookHandler: async (_topic, shop, _body) => { 57 | await AppInstallations.delete(shop); 58 | }, 59 | }); 60 | 61 | // The transactions with Shopify will always be marked as test transactions, unless NODE_ENV is production. 62 | // See the ensureBilling helper to learn more about billing in this template. 63 | const BILLING_SETTINGS: BillingOptions = { 64 | required: false, 65 | amount: 0, 66 | chargeName: 'default', 67 | currencyCode: 'USD', 68 | interval: BillingInterval.Every30Days, 69 | // This is an example configuration that would do a one-time charge for $5 (only USD is currently supported) 70 | // chargeName: "My Shopify One-Time Charge", 71 | // amount: 5.0, 72 | // currencyCode: "USD", 73 | // interval: BillingInterval.OneTime, 74 | }; 75 | 76 | // This sets up the mandatory GDPR webhooks. You’ll need to fill in the endpoint 77 | // in the “GDPR mandatory webhooks” section in the “App setup” tab, and customize 78 | // the code when you store customer data. 79 | // 80 | // More details can be found on shopify.dev: 81 | // https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks 82 | setupGDPRWebHooks('/api/webhooks'); 83 | 84 | // export for test use only 85 | export const createServer = async ( 86 | root = process.cwd(), 87 | isProd = process.env.NODE_ENV === 'production', 88 | billingSettings = BILLING_SETTINGS 89 | ) => { 90 | const app = express(); 91 | 92 | app.set('use-online-tokens', USE_ONLINE_TOKENS); 93 | app.use(cookieParser(Shopify.Context.API_SECRET_KEY)); 94 | 95 | applyAuthMiddleware(app, { 96 | billing: billingSettings, 97 | }); 98 | 99 | // Do not call app.use(express.json()) before processing webhooks with 100 | // Shopify.Webhooks.Registry.process(). 101 | // See https://github.com/Shopify/shopify-api-node/blob/main/docs/usage/webhooks.md#note-regarding-use-of-body-parsers 102 | // for more details. 103 | app.post('/api/webhooks', async (req, res) => { 104 | try { 105 | await Shopify.Webhooks.Registry.process(req, res); 106 | console.log(`Webhook processed, returned status code 200`); 107 | } catch (error: any) { 108 | console.log(`Failed to process webhook: ${error.message}`); 109 | if (!res.headersSent) { 110 | res.status(500).send(error.message); 111 | } 112 | } 113 | }); 114 | 115 | // All endpoints after this point will require an active session 116 | app.use( 117 | '/api/*', 118 | verifyRequest(app, { 119 | billing: billingSettings, 120 | }) 121 | ); 122 | 123 | app.get('/api/products/count', async (req, res) => { 124 | const session = await Shopify.Utils.loadCurrentSession( 125 | req, 126 | res, 127 | app.get('use-online-tokens') 128 | ); 129 | const { Product } = await import( 130 | `@shopify/shopify-api/dist/rest-resources/${Shopify.Context.API_VERSION}/index.js` 131 | ); 132 | 133 | const countData = await Product.count({ session }); 134 | res.status(200).send(countData); 135 | }); 136 | 137 | app.get('/api/products/create', async (req, res) => { 138 | const session = await Shopify.Utils.loadCurrentSession( 139 | req, 140 | res, 141 | app.get('use-online-tokens') 142 | ); 143 | let status = 200; 144 | let errorMessage = null; 145 | 146 | try { 147 | await productCreator(session as SessionInterface); 148 | } catch (error: any) { 149 | console.log(`Failed to process products/create: ${error.message}`); 150 | status = 500; 151 | errorMessage = error.message; 152 | } 153 | res.status(status).send({ success: status === 200, error: errorMessage }); 154 | }); 155 | 156 | // All endpoints after this point will have access to a request.body 157 | // attribute, as a result of the express.json() middleware 158 | app.use(express.json()); 159 | 160 | app.use((req, res, next) => { 161 | const shop = Shopify.Utils.sanitizeShop(req.query.shop as string); 162 | if (Shopify.Context.IS_EMBEDDED_APP && shop) { 163 | res.setHeader( 164 | 'Content-Security-Policy', 165 | `frame-ancestors https://${encodeURIComponent( 166 | shop 167 | )} https://admin.shopify.com;` 168 | ); 169 | } else { 170 | res.setHeader('Content-Security-Policy', `frame-ancestors 'none';`); 171 | } 172 | next(); 173 | }); 174 | 175 | if (isProd) { 176 | const compression = await import('compression').then( 177 | ({ default: fn }) => fn 178 | ); 179 | const serveStatic = await import('serve-static').then( 180 | ({ default: fn }) => fn 181 | ); 182 | app.use(compression()); 183 | app.use(serveStatic(PROD_INDEX_PATH, { index: false })); 184 | } 185 | 186 | app.use('/*', async (req, res, next) => { 187 | if (typeof req.query.shop !== 'string') { 188 | res.status(500); 189 | return res.send('No shop provided'); 190 | } 191 | 192 | const shop = Shopify.Utils.sanitizeShop(req.query.shop); 193 | 194 | if (!shop) { 195 | res.status(500); 196 | return res.send('No shop provided'); 197 | } 198 | 199 | const appInstalled = await AppInstallations.includes(shop); 200 | 201 | if (!appInstalled && !/^\/exitiframe/i.test(req.originalUrl)) { 202 | return redirectToAuth(req, res, app); 203 | } 204 | 205 | if (Shopify.Context.IS_EMBEDDED_APP && req.query.embedded !== '1') { 206 | const embeddedUrl = Shopify.Utils.getEmbeddedAppUrl(req); 207 | 208 | return res.redirect(embeddedUrl + req.path); 209 | } 210 | 211 | const htmlFile = path.join( 212 | isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH, 213 | 'index.html' 214 | ); 215 | 216 | return res 217 | .status(200) 218 | .set('Content-Type', 'text/html') 219 | .send(readFileSync(htmlFile)); 220 | }); 221 | 222 | return { app }; 223 | }; 224 | 225 | // eslint-disable-next-line unicorn/prefer-top-level-await 226 | createServer().then(({ app }) => app.listen(PORT)); 227 | -------------------------------------------------------------------------------- /web/backend/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthQuery, Shopify } from '@shopify/shopify-api'; 2 | import { gdprTopics } from '@shopify/shopify-api/dist/webhooks/registry'; 3 | import { Express, Request, Response } from 'express'; 4 | 5 | import ensureBilling, { BillingOptions } from '../helpers/ensure-billing'; 6 | import redirectToAuth from '../helpers/redirect-to-auth'; 7 | 8 | type ApplyAuthMiddlewareOptions = { 9 | billing: BillingOptions; 10 | }; 11 | 12 | const defaultOptions: ApplyAuthMiddlewareOptions = { 13 | billing: { 14 | required: false, 15 | amount: 0, 16 | chargeName: 'default', 17 | currencyCode: 'USD', 18 | interval: 'EVERY_30_DAYS', 19 | }, 20 | }; 21 | 22 | export default function applyAuthMiddleware( 23 | app: Express, 24 | { billing }: ApplyAuthMiddlewareOptions = defaultOptions 25 | ) { 26 | app.get('/api/auth', async (req: Request, res: Response) => 27 | redirectToAuth(req, res, app) 28 | ); 29 | 30 | app.get('/api/auth/callback', async (req, res) => { 31 | try { 32 | const session = await Shopify.Auth.validateAuthCallback( 33 | req, 34 | res, 35 | req.query as unknown as AuthQuery 36 | ); 37 | 38 | const responses = await Shopify.Webhooks.Registry.registerAll({ 39 | shop: session.shop, 40 | accessToken: session.accessToken!, 41 | }); 42 | 43 | Object.entries(responses).forEach(([topic, response]: any) => { 44 | // The response from registerAll will include errors for the GDPR topics. These can be safely ignored. 45 | // To register the GDPR topics, please set the appropriate webhook endpoint in the 46 | // 'GDPR mandatory webhooks' section of 'App setup' in the Partners Dashboard. 47 | if (!response.success && !gdprTopics.includes(topic)) { 48 | console.log( 49 | `Failed to register ${topic} webhook: ${response.result.errors[0].message}` 50 | ); 51 | } else { 52 | console.log( 53 | `Failed to register ${topic} webhook: ${JSON.stringify( 54 | response.result.data, 55 | undefined, 56 | 2 57 | )}` 58 | ); 59 | } 60 | }); 61 | 62 | // If billing is required, check if the store needs to be charged right away to minimize the number of redirects. 63 | if (billing.required) { 64 | const [hasPayment, confirmationUrl] = await ensureBilling( 65 | session, 66 | billing 67 | ); 68 | 69 | if (!hasPayment) { 70 | return res.redirect(confirmationUrl); 71 | } 72 | } 73 | 74 | const host = Shopify.Utils.sanitizeHost(req.query.host as string); 75 | const redirectUrl = Shopify.Context.IS_EMBEDDED_APP 76 | ? Shopify.Utils.getEmbeddedAppUrl(req) 77 | : `/?shop=${session.shop}&host=${encodeURIComponent(host!)}`; 78 | 79 | res.redirect(redirectUrl); 80 | } catch (error: any) { 81 | console.warn(error); 82 | switch (true) { 83 | case error instanceof Shopify.Errors.InvalidOAuthError: 84 | res.status(400); 85 | res.send(error.message); 86 | break; 87 | case error instanceof Shopify.Errors.CookieNotFound: 88 | case error instanceof Shopify.Errors.SessionNotFound: 89 | // This is likely because the OAuth session cookie expired before the merchant approved the request 90 | return redirectToAuth(req, res, app); 91 | default: 92 | res.status(500); 93 | res.send(error.message); 94 | break; 95 | } 96 | } 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /web/backend/src/middleware/verify-request.ts: -------------------------------------------------------------------------------- 1 | import { Shopify } from '@shopify/shopify-api'; 2 | import { NextFunction, Request, Response, Express } from 'express'; 3 | import ensureBilling, { 4 | BillingOptions, 5 | ShopifyBillingError, 6 | } from '../helpers/ensure-billing'; 7 | import redirectToAuth from '../helpers/redirect-to-auth'; 8 | 9 | import returnTopLevelRedirection from '../helpers/return-top-level-redirection'; 10 | 11 | const TEST_GRAPHQL_QUERY = ` 12 | { 13 | shop { 14 | name 15 | } 16 | }`; 17 | 18 | type VerifyRequestOptions = { 19 | billing: BillingOptions; 20 | returnHeader: boolean; 21 | }; 22 | 23 | const defaultOptions: VerifyRequestOptions = { 24 | billing: { 25 | required: false, 26 | amount: 0, 27 | chargeName: 'default', 28 | currencyCode: 'USD', 29 | interval: 'EVERY_30_DAYS', 30 | }, 31 | returnHeader: false, 32 | }; 33 | 34 | export default function verifyRequest( 35 | app: Express, 36 | { billing }: Partial = defaultOptions 37 | ) { 38 | return async (req: Request, res: Response, next: NextFunction) => { 39 | const session = await Shopify.Utils.loadCurrentSession( 40 | req, 41 | res, 42 | app.get('use-online-tokens') 43 | ); 44 | 45 | let shop = Shopify.Utils.sanitizeShop(req.query.shop as string); 46 | if (session && shop && session.shop !== shop) { 47 | // The current request is for a different shop. Redirect gracefully. 48 | return redirectToAuth(req, res, app); 49 | } 50 | 51 | if (session?.isActive()) { 52 | try { 53 | if (billing?.required) { 54 | // The request to check billing status serves to validate that the access token is still valid. 55 | const [hasPayment, confirmationUrl] = await ensureBilling(session, { 56 | ...billing, 57 | }); 58 | 59 | if (!hasPayment) { 60 | returnTopLevelRedirection(req, res, confirmationUrl); 61 | return; 62 | } 63 | } else { 64 | // Make a request to ensure the access token is still valid. Otherwise, re-authenticate the user. 65 | const client = new Shopify.Clients.Graphql( 66 | session.shop, 67 | session.accessToken 68 | ); 69 | await client.query({ data: TEST_GRAPHQL_QUERY }); 70 | } 71 | return next(); 72 | } catch (error: any) { 73 | if ( 74 | error instanceof Shopify.Errors.HttpResponseError && 75 | error.response.code === 401 76 | ) { 77 | // Re-authenticate if we get a 401 response 78 | } else if (error instanceof ShopifyBillingError) { 79 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 80 | //@ts-ignore 81 | console.error(error.message, error.errorData[0]); 82 | res.status(500).end(); 83 | return; 84 | } else { 85 | throw error; 86 | } 87 | } 88 | } 89 | 90 | const bearerPresent = req.headers.authorization?.match(/Bearer (.*)/); 91 | if (bearerPresent && !shop) { 92 | if (session) { 93 | shop = session.shop; 94 | } else if (Shopify.Context.IS_EMBEDDED_APP) { 95 | const payload = Shopify.Utils.decodeSessionToken(bearerPresent[1]); 96 | shop = payload.dest.replace('https://', ''); 97 | } 98 | } 99 | 100 | returnTopLevelRedirection( 101 | req, 102 | res, 103 | `/api/auth?shop=${encodeURIComponent(shop!)}` 104 | ); 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /web/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./node_modules/.cache/dist", 5 | "tsBuildInfoFile": "./node_modules/.cache/.tsbuildinfo", 6 | "paths": { 7 | "backend/*": ["./*"] 8 | } 9 | }, 10 | "include": ["./**/*.ts", "./**/*.tsx", "./**/*.js", "./**/*.json"], 11 | "references": [] 12 | } 13 | -------------------------------------------------------------------------------- /web/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Ignore Apple macOS Desktop Services Store 5 | .DS_Store 6 | 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # vite build output 12 | dist/ 13 | 14 | # Partners can use npm, yarn or pnpm with the CLI. 15 | # We ignore lock files so they don't get a package manager mis-match 16 | # Without this, they may get a warning if using a different package manager to us 17 | yarn.lock 18 | package-lock.json 19 | -------------------------------------------------------------------------------- /web/frontend/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom'; 2 | import { NavigationMenu } from '@shopify/app-bridge-react'; 3 | import Routes from './Routes'; 4 | 5 | import { 6 | AppBridgeProvider, 7 | QueryProvider, 8 | PolarisProvider, 9 | } from './components'; 10 | 11 | export default function App() { 12 | // Any .tsx or .jsx files in /pages will become a route 13 | // See documentation for for more info 14 | const pages: Record = import.meta.globEager( 15 | './pages/**/!(*.test.[jt]sx)*.([jt]sx)' 16 | ); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /web/frontend/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shopify 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 | -------------------------------------------------------------------------------- /web/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Shopify React Frontend App 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) 4 | 5 | This repository is the frontend for Shopify’s app starter templates. **You probably don’t want to use this repository directly**, but rather through one of the templates and the [Shopify CLI](https://github.com/Shopify/shopify-cli). 6 | 7 | ## Developer resources 8 | 9 | - [Introduction to Shopify apps](https://shopify.dev/apps/getting-started) 10 | - [App authentication](https://shopify.dev/apps/auth) 11 | - [Shopify CLI command reference](https://shopify.dev/apps/tools/cli/app) 12 | - [Shopify API Library documentation](https://github.com/Shopify/shopify-node-api/tree/main/docs) 13 | 14 | ## License 15 | 16 | This repository is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 17 | -------------------------------------------------------------------------------- /web/frontend/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { Routes as ReactRouterRoutes, Route } from 'react-router-dom'; 2 | 3 | type RoutesProps = { 4 | pages: Record>; 5 | }; 6 | 7 | /** 8 | * File-based routing. 9 | * @desc File-based routing that uses React Router under the hood. 10 | * To create a new route create a new .jsx file in `/pages` with a default export. 11 | * 12 | * Some examples: 13 | * * `/pages/index.jsx` matches `/` 14 | * * `/pages/blog/[id].jsx` matches `/blog/123` 15 | * * `/pages/[...catchAll].jsx` matches any URL not explicitly matched 16 | * 17 | * @param {object} pages value of import.meta.globEager(). See https://vitejs.dev/guide/features.html#glob-import 18 | * 19 | * @return {Routes} `` from React Router, with a `` for each file in `pages` 20 | */ 21 | export default function Routes({ pages }: RoutesProps) { 22 | const routes = useRoutes(pages); 23 | const routeComponents = routes.map(({ path, component: Component }) => ( 24 | } /> 25 | )); 26 | 27 | const NotFound = routes.find(({ path }) => path === '/notFound')?.component; 28 | 29 | return ( 30 | 31 | {routeComponents} 32 | } /> 33 | 34 | ); 35 | } 36 | 37 | const useRoutes = (pages: RoutesProps['pages']) => { 38 | const routes = Object.keys(pages) 39 | .map((key) => { 40 | let path = key 41 | .replace('./pages', '') 42 | .replace(/\.(t|j)sx?$/, '') 43 | /** 44 | * Replace /index with / 45 | */ 46 | .replace(/\/index$/i, '/') 47 | /** 48 | * Only lowercase the first letter. This allows the developer to use camelCase 49 | * dynamic paths while ensuring their standard routes are normalized to lowercase. 50 | */ 51 | .replace(/\b[A-Z]/, (firstLetter) => firstLetter.toLowerCase()) 52 | /** 53 | * Convert /[handle].jsx and /[...handle].jsx to /:handle.jsx for react-router-dom 54 | */ 55 | .replace(/\[(?:\.{3})?(\w+?)]/g, (_match, param) => `:${param}`); 56 | 57 | if (path.endsWith('/') && path !== '/') { 58 | path = path.slice(0, Math.max(0, path.length - 1)); 59 | } 60 | 61 | if (!pages[key].default) { 62 | console.warn(`${key} doesn't export a default React component`); 63 | } 64 | 65 | return { 66 | path, 67 | component: pages[key].default, 68 | }; 69 | }) 70 | .filter((route) => route.component); 71 | 72 | return routes; 73 | }; 74 | -------------------------------------------------------------------------------- /web/frontend/assets/empty-state.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/frontend/assets/home-trophy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crunchyman-ralph/shopify-app-node-monorepo-express-vite/ed5dae07aeb13893497070b03408a2f293289004/web/frontend/assets/home-trophy.png -------------------------------------------------------------------------------- /web/frontend/assets/index.js: -------------------------------------------------------------------------------- 1 | export { default as notFoundImage } from './empty-state.svg'; 2 | export { default as trophyImage } from './home-trophy.png'; 3 | -------------------------------------------------------------------------------- /web/frontend/components/ProductsCard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { 3 | Card, 4 | Heading, 5 | TextContainer, 6 | DisplayText, 7 | TextStyle, 8 | } from '@shopify/polaris'; 9 | import { Toast, ToastProps } from '@shopify/app-bridge-react'; 10 | import { useAppQuery, useAuthenticatedFetch } from '../hooks'; 11 | 12 | export const ProductsCard = () => { 13 | const emptyToastProps = { content: '', onDismiss: () => null }; 14 | const [isLoading, setIsLoading] = useState(true); 15 | const [toastProps, setToastProps] = useState(emptyToastProps); 16 | const fetch = useAuthenticatedFetch(); 17 | 18 | const { 19 | data, 20 | refetch: refetchProductCount, 21 | isLoading: isLoadingCount, 22 | isRefetching: isRefetchingCount, 23 | } = useAppQuery<{ count: number }>({ 24 | url: '/api/products/count', 25 | reactQueryOptions: { 26 | onSuccess: () => { 27 | setIsLoading(false); 28 | }, 29 | }, 30 | }); 31 | 32 | const toastMarkup = toastProps.content && !isRefetchingCount && ( 33 | setToastProps(emptyToastProps)} /> 34 | ); 35 | 36 | const handlePopulate = async () => { 37 | setIsLoading(true); 38 | const response = await fetch('/api/products/create', {}); 39 | 40 | if (response.ok) { 41 | await refetchProductCount(); 42 | setToastProps({ content: '5 products created!', onDismiss: () => null }); 43 | } else { 44 | setIsLoading(false); 45 | setToastProps({ 46 | content: 'There was an error creating products', 47 | error: true, 48 | onDismiss: () => null, 49 | }); 50 | } 51 | }; 52 | 53 | return ( 54 | <> 55 | {toastMarkup} 56 | 65 | 66 |

67 | Sample products are created with a default title and price. You can 68 | remove them at any time. 69 |

70 | 71 | TOTAL PRODUCTS 72 | 73 | 74 | {isLoadingCount ? '-' : data?.count} 75 | 76 | 77 | 78 |
79 |
80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /web/frontend/components/index.ts: -------------------------------------------------------------------------------- 1 | export { ProductsCard } from './ProductsCard'; 2 | export * from './providers'; 3 | -------------------------------------------------------------------------------- /web/frontend/components/providers/AppBridgeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from 'react'; 2 | import { useLocation, useNavigate } from 'react-router-dom'; 3 | import { Provider } from '@shopify/app-bridge-react'; 4 | import { Banner, Layout, Page } from '@shopify/polaris'; 5 | import { Children } from 'frontend/types/Children'; 6 | import { AppConfigV2 } from '@shopify/app-bridge'; 7 | 8 | /** 9 | * A component to configure App Bridge. 10 | * @desc A thin wrapper around AppBridgeProvider that provides the following capabilities: 11 | * 12 | * 1. Ensures that navigating inside the app updates the host URL. 13 | * 2. Configures the App Bridge Provider, which unlocks functionality provided by the host. 14 | * 15 | * See: https://shopify.dev/apps/tools/app-bridge/react-components 16 | */ 17 | export const AppBridgeProvider = ({ children }: Children) => { 18 | const location = useLocation(); 19 | const navigate = useNavigate(); 20 | const history = useMemo( 21 | () => ({ 22 | replace: (path: string) => { 23 | navigate(path, { replace: true }); 24 | }, 25 | }), 26 | [navigate] 27 | ); 28 | 29 | const routerConfig = useMemo( 30 | () => ({ history, location }), 31 | [history, location] 32 | ); 33 | 34 | // The host may be present initially, but later removed by navigation. 35 | // By caching this in state, we ensure that the host is never lost. 36 | // During the lifecycle of an app, these values should never be updated anyway. 37 | // Using state in this way is preferable to useMemo. 38 | // See: https://stackoverflow.com/questions/60482318/version-of-usememo-for-caching-a-value-that-will-never-change 39 | const [appBridgeConfig] = useState(() => { 40 | const host = 41 | new URLSearchParams(location.search).get('host') || 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 43 | // @ts-ignore 44 | // eslint-disable-next-line no-underscore-dangle 45 | window.__SHOPIFY_DEV_HOST; 46 | 47 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 48 | // @ts-ignore 49 | // eslint-disable-next-line no-underscore-dangle 50 | window.__SHOPIFY_DEV_HOST = host; 51 | 52 | return { 53 | host, 54 | apiKey: process.env.SHOPIFY_API_KEY, 55 | forceRedirect: true, 56 | }; 57 | }); 58 | 59 | if (!process.env.SHOPIFY_API_KEY || !appBridgeConfig.host) { 60 | const bannerProps = !process.env.SHOPIFY_API_KEY 61 | ? { 62 | title: 'Missing Shopify API Key', 63 | children: ( 64 | <> 65 | Your app is running without the SHOPIFY_API_KEY environment 66 | variable. Please ensure that it is set when running or building 67 | your React app. 68 | 69 | ), 70 | } 71 | : { 72 | title: 'Missing host query argument', 73 | children: ( 74 | <> 75 | Your app can only load if the URL has a host argument. 76 | Please ensure that it is set, or access your app using the 77 | Partners Dashboard Test your app feature 78 | 79 | ), 80 | }; 81 | 82 | return ( 83 | 84 | 85 | 86 |
87 | 88 |
89 |
90 |
91 |
92 | ); 93 | } 94 | 95 | return ( 96 | 97 | {children} 98 | 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /web/frontend/components/providers/PolarisProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { AppProvider } from '@shopify/polaris'; 3 | import { useNavigate } from '@shopify/app-bridge-react'; 4 | import translations from '@shopify/polaris/locales/en.json'; 5 | import '@shopify/polaris/build/esm/styles.css'; 6 | import { Children } from 'frontend/types/Children'; 7 | 8 | type LinkLikeComponentProps = { 9 | /** The url to link to */ 10 | url: string; 11 | /** The content to display inside the link */ 12 | children?: React.ReactNode; 13 | /** Makes the link open in a new tab */ 14 | external?: boolean; 15 | /** Makes the browser download the url instead of opening it. Provides a hint for the downloaded filename if it is a string value. */ 16 | download?: string | boolean; 17 | [key: string]: any; 18 | }; 19 | 20 | const AppBridgeLink: React.FC = ({ 21 | url, 22 | children, 23 | external, 24 | ...rest 25 | }) => { 26 | const navigate = useNavigate(); 27 | const handleClick = useCallback(() => { 28 | navigate(url); 29 | }, [url]); 30 | 31 | const IS_EXTERNAL_LINK_REGEX = /^(?:[a-z][\d+.a-z-]*:|\/\/)/; 32 | 33 | if (external || IS_EXTERNAL_LINK_REGEX.test(url)) { 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | } 40 | 41 | return ( 42 | // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions 43 | 44 | {children} 45 | 46 | ); 47 | }; 48 | 49 | /** 50 | * Sets up the AppProvider from Polaris. 51 | * @desc PolarisProvider passes a custom link component to Polaris. 52 | * The Link component handles navigation within an embedded app. 53 | * Prefer using this vs any other method such as an anchor. 54 | * Use it by importing Link from Polaris, e.g: 55 | * 56 | * ``` 57 | * import {Link} from '@shopify/polaris' 58 | * 59 | * function MyComponent() { 60 | * return ( 61 | *
Tab 2
62 | * ) 63 | * } 64 | * ``` 65 | * 66 | * PolarisProvider also passes translations to Polaris. 67 | * 68 | */ 69 | export const PolarisProvider: React.FC = ({ children }) => ( 70 | 71 | {children} 72 | 73 | ); 74 | -------------------------------------------------------------------------------- /web/frontend/components/providers/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Children } from 'frontend/types/Children'; 2 | import React from 'react'; 3 | import { 4 | QueryClient, 5 | QueryClientProvider, 6 | QueryCache, 7 | MutationCache, 8 | } from 'react-query'; 9 | 10 | /** 11 | * Sets up the QueryClientProvider from react-query. 12 | * @desc See: https://react-query.tanstack.com/reference/QueryClientProvider#_top 13 | */ 14 | export const QueryProvider: React.FC = ({ children }) => { 15 | const client = new QueryClient({ 16 | queryCache: new QueryCache(), 17 | mutationCache: new MutationCache(), 18 | }); 19 | 20 | return {children}; 21 | }; 22 | -------------------------------------------------------------------------------- /web/frontend/components/providers/index.ts: -------------------------------------------------------------------------------- 1 | export { AppBridgeProvider } from './AppBridgeProvider'; 2 | export { QueryProvider } from './QueryProvider'; 3 | export { PolarisProvider } from './PolarisProvider'; 4 | -------------------------------------------------------------------------------- /web/frontend/dev_embed.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle, no-empty-function, no-undef */ 2 | //@ts-nocheck 3 | import RefreshRuntime from '/@react-refresh'; 4 | 5 | RefreshRuntime.injectIntoGlobalHook(window); 6 | window.$RefreshReg$ = () => {}; 7 | window.$RefreshSig$ = () => (type) => type; 8 | window.__vite_plugin_react_preamble_installed__ = true; 9 | -------------------------------------------------------------------------------- /web/frontend/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useAppQuery } from './useAppQuery'; 2 | export { useAuthenticatedFetch } from './useAuthenticatedFetch'; 3 | -------------------------------------------------------------------------------- /web/frontend/hooks/useAppQuery.ts: -------------------------------------------------------------------------------- 1 | import { useAuthenticatedFetch } from './useAuthenticatedFetch'; 2 | import { useMemo } from 'react'; 3 | import { useQuery, UseQueryOptions } from 'react-query'; 4 | 5 | type UseAppQueryType = { 6 | url: string; 7 | fetchInit?: RequestInit; 8 | reactQueryOptions?: Omit< 9 | UseQueryOptions, 10 | 'queryKey' | 'queryFn' 11 | >; 12 | }; 13 | 14 | /** 15 | * A hook for querying your custom app data. 16 | * @desc A thin wrapper around useAuthenticatedFetch and react-query's useQuery. 17 | * 18 | * @param {Object} options - The options for your query. Accepts 3 keys: 19 | * 20 | * 1. url: The URL to query. E.g: /api/widgets/1` 21 | * 2. fetchInit: The init options for fetch. See: https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters 22 | * 3. reactQueryOptions: The options for `useQuery`. See: https://react-query.tanstack.com/reference/useQuery 23 | * 24 | * @returns Return value of useQuery. See: https://react-query.tanstack.com/reference/useQuery. 25 | */ 26 | export const useAppQuery = ({ 27 | url, 28 | fetchInit = {}, 29 | reactQueryOptions, 30 | }: UseAppQueryType) => { 31 | const authenticatedFetch = useAuthenticatedFetch(); 32 | const fetch = useMemo( 33 | () => async () => { 34 | const response = await authenticatedFetch(url, fetchInit); 35 | return response.json(); 36 | }, 37 | [url, JSON.stringify(fetchInit)] 38 | ); 39 | 40 | return useQuery(url, fetch, { 41 | ...reactQueryOptions, 42 | refetchOnWindowFocus: false, 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /web/frontend/hooks/useAuthenticatedFetch.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedFetch } from '@shopify/app-bridge-utils'; 2 | import { useAppBridge } from '@shopify/app-bridge-react'; 3 | import { Redirect } from '@shopify/app-bridge/actions'; 4 | import { ClientApplication } from '@shopify/app-bridge'; 5 | 6 | /** 7 | * A hook that returns an auth-aware fetch function. 8 | * @desc The returned fetch function that matches the browser's fetch API 9 | * See: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 10 | * It will provide the following functionality: 11 | * 12 | * 1. Add a `X-Shopify-Access-Token` header to the request. 13 | * 2. Check response for `X-Shopify-API-Request-Failure-Reauthorize` header. 14 | * 3. Redirect the user to the reauthorization URL if the header is present. 15 | * 16 | * @returns {Function} fetch function 17 | */ 18 | export const useAuthenticatedFetch = () => { 19 | const app = useAppBridge(); 20 | const fetchFunction = authenticatedFetch(app); 21 | 22 | return async (uri: string, options: RequestInit) => { 23 | const response = await fetchFunction(uri, options); 24 | checkHeadersForReauthorization(response.headers, app); 25 | return response; 26 | }; 27 | }; 28 | 29 | const checkHeadersForReauthorization = ( 30 | headers: Headers, 31 | app: ClientApplication 32 | ) => { 33 | if (headers.get('X-Shopify-API-Request-Failure-Reauthorize') === '1') { 34 | const authUrlHeader = 35 | headers.get('X-Shopify-API-Request-Failure-Reauthorize-Url') || 36 | `/api/auth`; 37 | 38 | const redirect = Redirect.create(app); 39 | redirect.dispatch( 40 | Redirect.Action.REMOTE, 41 | authUrlHeader.startsWith('/') 42 | ? `https://${window.location.host}${authUrlHeader}` 43 | : authUrlHeader 44 | ); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /web/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /web/frontend/index.jsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('app')); 6 | -------------------------------------------------------------------------------- /web/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "build": "vite build", 7 | "coverage": "vitest run --coverage", 8 | "dev": "vite" 9 | }, 10 | "dependencies": { 11 | "@shopify/app-bridge": "3.7.3", 12 | "@shopify/app-bridge-react": "3.2.5", 13 | "@shopify/app-bridge-utils": "3.2.5", 14 | "@shopify/polaris": "10.6.0", 15 | "@vitejs/plugin-react": "2.1.0", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0", 18 | "react-query": "3.39.2", 19 | "react-router-dom": "6.3.0", 20 | "vite": "3.1.6", 21 | "vite-tsconfig-paths": "3.5.0" 22 | }, 23 | "devDependencies": { 24 | "history": "5.3.0", 25 | "jsdom": "20.0.0", 26 | "prettier": "2.7.1", 27 | "vi-fetch": "0.8.0" 28 | }, 29 | "engines": { 30 | "node": ">= 12.16" 31 | }, 32 | "type": "commonjs" 33 | } 34 | -------------------------------------------------------------------------------- /web/frontend/pages/ExitIframe.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from '@shopify/app-bridge/actions'; 2 | import { useAppBridge, Loading } from '@shopify/app-bridge-react'; 3 | import { useEffect } from 'react'; 4 | import { useLocation } from 'react-router-dom'; 5 | 6 | export default function ExitIframe() { 7 | const app = useAppBridge(); 8 | const { search } = useLocation(); 9 | 10 | useEffect(() => { 11 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 12 | if (!!app && !!search) { 13 | const params = new URLSearchParams(search); 14 | const redirectUri = params.get('redirectUri'); 15 | 16 | if (!redirectUri) { 17 | return; 18 | } 19 | 20 | const url = new URL(decodeURIComponent(redirectUri)); 21 | 22 | if (url.hostname === location.hostname) { 23 | const redirect = Redirect.create(app); 24 | redirect.dispatch( 25 | Redirect.Action.REMOTE, 26 | decodeURIComponent(redirectUri) 27 | ); 28 | } 29 | } 30 | }, [app, search]); 31 | 32 | return ; 33 | } 34 | -------------------------------------------------------------------------------- /web/frontend/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Card, EmptyState, Page } from '@shopify/polaris'; 2 | import { notFoundImage } from '../assets'; 3 | 4 | export default function NotFound() { 5 | return ( 6 | 7 | 8 | 9 | 13 |

14 | Check the URL and try again, or use the search bar to find what 15 | you need. 16 |

17 |
18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /web/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Page, 4 | Layout, 5 | TextContainer, 6 | Image, 7 | Stack, 8 | Link, 9 | Heading, 10 | } from '@shopify/polaris'; 11 | import { TitleBar } from '@shopify/app-bridge-react'; 12 | 13 | import { trophyImage } from '../assets'; 14 | 15 | import { ProductsCard } from '../components'; 16 | 17 | export default function HomePage() { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | Nice work on building a Shopify app 🎉 33 |

34 | Your app is ready to explore! It contains everything you 35 | need to get started including the{' '} 36 | 37 | Polaris design system 38 | 39 | ,{' '} 40 | 41 | Shopify Admin API 42 | 43 | , and{' '} 44 | 48 | App Bridge 49 | {' '} 50 | UI library and components. 51 |

52 |

53 | Ready to go? Start populating your app with some sample 54 | products to view and test in your store.{' '} 55 |

56 |

57 | Learn more about building out your app in{' '} 58 | 62 | this Shopify tutorial 63 | {' '} 64 | 📚{' '} 65 |

66 |
67 |
68 | 69 |
70 | Nice work on building a Shopify app 75 |
76 |
77 |
78 |
79 |
80 | 81 | 82 | 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /web/frontend/pages/pagename.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Page, Layout, TextContainer, Heading } from '@shopify/polaris'; 2 | import { TitleBar } from '@shopify/app-bridge-react'; 3 | 4 | export default function PageName() { 5 | return ( 6 | 7 | console.log('Primary action'), 12 | }} 13 | secondaryActions={[ 14 | { 15 | content: 'Secondary action', 16 | onAction: () => console.log('Secondary action'), 17 | }, 18 | ]} 19 | /> 20 | 21 | 22 | 23 | Heading 24 | 25 |

Body

26 |
27 |
28 | 29 | Heading 30 | 31 |

Body

32 |
33 |
34 |
35 | 36 | 37 | Heading 38 | 39 |

Body

40 |
41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /web/frontend/shopify.web.toml: -------------------------------------------------------------------------------- 1 | type="frontend" 2 | 3 | [commands] 4 | dev = "npm run dev" 5 | build = "npm run build" 6 | -------------------------------------------------------------------------------- /web/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./node_modules/.cache/dist", 5 | "tsBuildInfoFile": "./node_modules/.cache/.tsbuildinfo", 6 | "paths": { 7 | "frontend/*": ["./*"] 8 | }, 9 | "types": ["vite/client"] 10 | }, 11 | "include": ["./**/*.ts", "./**/*.tsx", "./**/*.js", "./**/*.json"], 12 | "references": [] 13 | } 14 | -------------------------------------------------------------------------------- /web/frontend/types/Children.ts: -------------------------------------------------------------------------------- 1 | export type Children = { 2 | children: React.ReactNode; 3 | }; 4 | -------------------------------------------------------------------------------- /web/frontend/types/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.module.scss' { 4 | const classes: Readonly>; 5 | export default classes; 6 | } 7 | 8 | declare module '*.png' { 9 | const value: any; 10 | export = value; 11 | } 12 | 13 | declare module '*.svg' { 14 | const value: any; 15 | export = value; 16 | } 17 | 18 | declare module '*.jpg' { 19 | const value: any; 20 | export = value; 21 | } 22 | -------------------------------------------------------------------------------- /web/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | // import https from 'https'; 5 | import react from '@vitejs/plugin-react'; 6 | import tsconfigPaths from 'vite-tsconfig-paths'; 7 | 8 | if ( 9 | process.env.npm_lifecycle_event === 'build' && 10 | !process.env.CI && 11 | !process.env.SHOPIFY_API_KEY 12 | ) { 13 | console.warn( 14 | '\nBuilding the frontend app without an API key. The frontend build will not run without an API key. Set the SHOPIFY_API_KEY environment variable when running the build command.\n' 15 | ); 16 | } 17 | 18 | const proxyOptions = { 19 | target: `http://127.0.0.1:${process.env.BACKEND_PORT}`, 20 | changeOrigin: false, 21 | secure: true, 22 | ws: false, 23 | }; 24 | 25 | const host = process.env.HOST 26 | ? process.env.HOST.replace(/https?:\/\//, '') 27 | : 'localhost'; 28 | 29 | const hmrConfig = 30 | host === 'localhost' 31 | ? { 32 | protocol: 'ws', 33 | host: 'localhost', 34 | port: 64999, 35 | clientPort: 64999, 36 | } 37 | : { 38 | protocol: 'wss', 39 | host: host, 40 | port: Number.parseInt(process.env.FRONTEND_PORT!), 41 | clientPort: 443, 42 | }; 43 | 44 | const config = defineConfig({ 45 | root: path.dirname(fileURLToPath(import.meta.url)), 46 | plugins: [tsconfigPaths(), react()], 47 | define: { 48 | 'process.env.SHOPIFY_API_KEY': JSON.stringify(process.env.SHOPIFY_API_KEY), 49 | }, 50 | resolve: { 51 | preserveSymlinks: true, 52 | }, 53 | server: { 54 | host: 'localhost', 55 | port: Number.parseInt(process.env.FRONTEND_PORT!), 56 | hmr: hmrConfig, 57 | proxy: { 58 | '^/(\\?.*)?$': proxyOptions, 59 | '^/api(/|(\\?.*)?$)': proxyOptions, 60 | }, 61 | }, 62 | }); 63 | 64 | export default config; 65 | --------------------------------------------------------------------------------