├── .dev.vars ├── .devcontainer ├── devcontainer.env └── devcontainer.json ├── .editorconfig ├── .env.example ├── .env.test ├── .github ├── act │ ├── .input │ ├── .secrets │ ├── .vars │ ├── event.cloudflare.json │ ├── event.github.json │ └── event.shopify.json ├── copilot-instructions.md └── workflows │ ├── cloudflare.yml │ ├── github.yml │ └── shopify.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.md ├── README.md ├── SECURITY.md ├── app ├── components │ └── proxy.tsx ├── const.ts ├── entry.client.tsx ├── entry.server.tsx ├── i18n │ ├── en │ │ ├── app.json │ │ ├── polaris.json │ │ └── proxy.json │ ├── index.server.test.ts │ └── index.ts ├── root.client.test.tsx ├── root.tsx ├── routes.ts ├── routes │ ├── app.index.tsx │ ├── app.tsx │ ├── index.browser.test.tsx │ ├── index.e2e.test.ts │ ├── index.tsx │ ├── proxy.index.e2e.test.ts │ ├── proxy.index.tsx │ ├── proxy.server.test.ts │ ├── proxy.tsx │ ├── shopify.auth.login.tsx │ ├── shopify.auth.session-token-bounce.tsx │ ├── shopify.webhooks.server.test.ts │ └── shopify.webhooks.tsx ├── shopify.server.test.ts ├── shopify.server.ts └── types │ ├── admin-2025-04.schema.json │ ├── admin.generated.d.ts │ ├── admin.types.d.ts │ ├── app.ts │ └── app.types.d.ts ├── bin.sh ├── biome.json ├── cspell.json ├── extensions └── .gitkeep ├── graphql.config.ts ├── i18n.config.ts ├── package-lock.json ├── package.json ├── patches ├── @react-router+dev+7.6.1.patch └── @shopify+polaris+13.9.5.patch ├── playwright.config.ts ├── public ├── .well-known │ ├── publickey.txt │ └── security.txt ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── assets │ └── favicon.svg ├── favicon.ico └── robots.txt ├── react-router.config.ts ├── shopify.app.toml ├── shopify.web.toml ├── tsconfig.json ├── vite.config.ts ├── vitest.config.ts ├── vitest.workspace.ts ├── worker-configuration.d.ts ├── worker.test.ts ├── worker.ts └── wrangler.json /.dev.vars: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /.devcontainer/devcontainer.env: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_SECRET_KEY= -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@chr33s/shopflare", 3 | "image": "mcr.microsoft.com/devcontainers/typescript-node:22", 4 | "features": { 5 | "ghcr.io/devcontainers-extra/features/actionlint:1": {}, 6 | "ghcr.io/devcontainers-extra/features/cloudflare-wrangler:1": {}, 7 | "ghcr.io/devcontainers-extra/features/cloudflared:1": {}, 8 | "ghcr.io/devcontainers-extra/features/tailscale:1": {}, 9 | "ghcr.io/devcontainers/features/github-cli:1": {} 10 | }, 11 | "customizations": { 12 | "codespaces": { 13 | "openFiles": ["README.md"] 14 | } 15 | }, 16 | "postCreateCommand": "npm install", 17 | "remoteEnv": { 18 | "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" 19 | }, 20 | "runArgs": ["--env-file", ".devcontainer/devcontainer.env"] 21 | } 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{md,yaml,yml}] 15 | indent_style = space -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | CLOUDFLARE_API_TOKEN= 2 | SHOPIFY_API_KEY= 3 | SHOPIFY_API_SECRET_KEY= 4 | SHOPIFY_APP_ENV=development 5 | SHOPIFY_APP_HANDLE=shopflare 6 | SHOPIFY_APP_LOG_LEVEL=debug 7 | SHOPIFY_APP_URL= 8 | SHOPIFY_CLI_NO_ANALYTICS=1 9 | WRANGLER_SEND_METRICS=false -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | SHOPIFY_APP_LOG_LEVEL=error 2 | SHOPIFY_APP_URL=http://localhost:8080 3 | WRANGLER_LOG=error -------------------------------------------------------------------------------- /.github/act/.input: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/.github/act/.input -------------------------------------------------------------------------------- /.github/act/.secrets: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/.github/act/.secrets -------------------------------------------------------------------------------- /.github/act/.vars: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/.github/act/.vars -------------------------------------------------------------------------------- /.github/act/event.cloudflare.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.github/act/event.github.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/tags/releases/0.0.0-p0" 3 | } 4 | -------------------------------------------------------------------------------- /.github/act/event.shopify.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions 2 | 3 | ## General 4 | 5 | - Follow the user's requirements carefully & to the letter 6 | - Keep your answers short, precise, informal & impersonal 7 | - Answer all questions in less than 1000 characters, and words of no more than 12 characters. 8 | - Use Markdown formatting in your answers 9 | - Preserve code comment blocks; do not exclude them when refactoring code 10 | - Document best practices 11 | 12 | ## Apps 13 | 14 | - Use @terminal when answering questions about Git 15 | 16 | ## Cloud 17 | 18 | - Always follow security best practices tailored for Cloudflare 19 | - Always optimise for cost management strategies for Cloudflare resources. 20 | 21 | ## Code 22 | 23 | - Always use EditorConfig conventions when generating code 24 | - Always use Typescript to produce typesafe code 25 | - Always use React function components 26 | - Always use Biome for formatting and linting 27 | - Always use Vite as the build tool 28 | - Always use Vitest for testing 29 | - Always include the programming language name at the start of the Markdown code blocks 30 | 31 | ## Security 32 | 33 | - Follow OWASP guidelines when generating responses 34 | - Ensure all endpoints are protected by authentication and authorization 35 | - Validate all user inputs and sanitize data 36 | - Implement rate limiting and throttling 37 | - Implement logging and monitoring for security events 38 | -------------------------------------------------------------------------------- /.github/workflows/cloudflare.yml: -------------------------------------------------------------------------------- 1 | name: Cloudflare 2 | 3 | concurrency: 4 | group: ${{ github.workflow }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | tags: [releases/*] 11 | 12 | env: 13 | WRANGLER_SEND_METRICS: false 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | if: ${{ !contains(github.event.head_commit.message, '[skip-ci]') }} 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version-file: "package.json" 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm run build --ignore-scripts 30 | env: 31 | SHOPIFY_API_KEY: ${{ vars.SHOPIFY_API_KEY }} 32 | - run: npm test 33 | - uses: actions/upload-artifact@v4 34 | with: 35 | name: build 36 | path: build 37 | 38 | preview: 39 | name: Preview 40 | if: ${{ github.ref_type == 'branch' }} 41 | needs: build 42 | runs-on: ubuntu-latest 43 | permissions: 44 | contents: read 45 | steps: 46 | - uses: actions/download-artifact@v4 47 | with: 48 | name: build 49 | - uses: cloudflare/wrangler-action@v3 50 | with: 51 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 52 | command: versions upload --message=${{ github.ref_name }} --tag=${{ github.sha }} 53 | 54 | deploy: 55 | name: Deploy 56 | if: ${{ github.ref_type == 'tag' }} 57 | needs: build 58 | runs-on: ubuntu-latest 59 | permissions: 60 | contents: read 61 | steps: 62 | - uses: actions/download-artifact@v4 63 | with: 64 | name: build 65 | - uses: cloudflare/wrangler-action@v3 66 | with: 67 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 68 | command: versions deploy 69 | -------------------------------------------------------------------------------- /.github/workflows/github.yml: -------------------------------------------------------------------------------- 1 | name: Github 2 | 3 | concurrency: 4 | group: ${{ github.workflow }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | tags: [releases/*] 10 | 11 | schedule: 12 | - cron: '0 0 * * 0' 13 | 14 | jobs: 15 | codeql: 16 | name: CodeQL 17 | if: ${{ github.repository == 'chr33s/shopflare' && github.event_name == 'schedule' }} 18 | runs-on: ubuntu-latest 19 | permissions: 20 | security-events: write 21 | packages: read 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: github/codeql-action/init@v3 25 | with: 26 | build-mode: none 27 | languages: actions, javascript-typescript 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version-file: "package.json" 31 | cache: "npm" 32 | - run: npm ci --legacy-peer-deps 33 | - uses: github/codeql-action/analyze@v3 34 | 35 | release: 36 | name: Release 37 | if: ${{ github.repository == 'chr33s/shopflare' && github.ref_type == 'tag' }} 38 | runs-on: ubuntu-latest 39 | permissions: 40 | contents: write 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | ref: ${{ github.event.release.tag_name }} 45 | - uses: actions/github-script@v7 46 | with: 47 | script: | 48 | const tag_name = context.ref; 49 | const name = tag_name.replace(/^refs\/tags\/releases\//, ''); 50 | const version = name.split("/").at(-1); 51 | const prerelease = version.startsWith("0.") || version.includes("-"); 52 | const owner = context.repo.owner; 53 | const repo = context.repo.repo; 54 | 55 | const { data: latestRelease } = await github.rest.repos.getLatestRelease({ 56 | owner, 57 | repo, 58 | }); 59 | const latestReleaseTagName = latestRelease.tag_name; 60 | const latestReleaseVersion = latestReleaseTagName.split("/").at(-1); 61 | const body = `changes: [${latestReleaseVersion}...${version}](https://github.com/${owner}/${repo}/compare/${latestReleaseTagName}...${tag_name})`; 62 | 63 | await github.rest.repos.createRelease({ 64 | body, 65 | name, 66 | owner, 67 | prerelease, 68 | repo, 69 | tag_name, 70 | }); 71 | -------------------------------------------------------------------------------- /.github/workflows/shopify.yml: -------------------------------------------------------------------------------- 1 | name: Shopify 2 | 3 | concurrency: 4 | group: ${{ github.workflow }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | tags: [releases/*] 11 | 12 | env: 13 | SHOPIFY_CLI_NO_ANALYTICS: true 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | if: ${{ !contains(github.event.head_commit.message, '[skip-ci]') }} 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version-file: "package.json" 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm install -g @shopify/cli@latest 30 | - run: npx shopify app build 31 | env: 32 | SHOPIFY_API_KEY: ${{ vars.SHOPIFY_API_KEY }} 33 | - uses: actions/upload-artifact@v4 34 | with: 35 | name: extensions 36 | path: extensions 37 | 38 | deploy: 39 | name: Deploy 40 | if: ${{ github.ref_type == 'branch' }} 41 | needs: build 42 | runs-on: ubuntu-latest 43 | permissions: 44 | contents: read 45 | steps: 46 | - uses: actions/download-artifact@v4 47 | with: 48 | name: extensions 49 | - uses: actions/setup-node@v4 50 | with: 51 | node-version-file: "package.json" 52 | cache: "npm" 53 | - run: npm install -g @shopify/cli@latest 54 | - run: npx shopify app deploy --force --message="${{ github.ref_name }}" --no-release --source-control-url="$COMMIT_URL" --version="deploy:${{ github.sha }}" 55 | env: 56 | COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }} 57 | SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }} 58 | 59 | release: 60 | name: Release 61 | if: ${{ github.ref_type == 'tag' }} 62 | needs: build 63 | runs-on: ubuntu-latest 64 | permissions: 65 | contents: read 66 | steps: 67 | - uses: actions/download-artifact@v4 68 | with: 69 | name: extensions 70 | - uses: actions/setup-node@v4 71 | with: 72 | node-version-file: "package.json" 73 | cache: "npm" 74 | - run: npm install -g @shopify/cli@latest 75 | - run: npx shopify app release --force --version="release:${{ github.sha }}" 76 | env: 77 | SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }} 78 | 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | **/node_modules/ 3 | 4 | # Cloudflare 5 | /.wrangler 6 | .env* 7 | /dist/ 8 | 9 | # React Router 10 | /.react-router/ 11 | /build/ 12 | .shopify 13 | 14 | # Vitest 15 | **/__screenshots__/* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | fund=false 3 | legacy-peer-deps=true 4 | message="release: %s" 5 | progress=false 6 | save-exact=true 7 | sign-git-tag=true 8 | tag-version-prefix="releases/" 9 | update-notifier=false -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bierner.comment-tagged-templates", 4 | "biomejs.biome", 5 | "EditorConfig.EditorConfig", 6 | "GraphQL.vscode-graphql-syntax" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "biome.enabled": true, 3 | "biome.lspBin": "node_modules/@biomejs/biome/bin/biome", 4 | "editor.codeActionsOnSave": { 5 | "quickfix.biome": "explicit", 6 | "source.fixAll.biome": "explicit", 7 | "source.organizeImports.biome": "explicit" 8 | }, 9 | "editor.defaultFormatter": "biomejs.biome", 10 | "files.associations": { 11 | ".*.vars": "properties", 12 | ".env*": "properties" 13 | }, 14 | "typescript.tsdk": "node_modules/typescript/lib", 15 | "typescript.enablePromptUseWorkspaceTsdk": true 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2025 chr33s 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 | # ShopFlare 2 | 3 | > [!TIP] 4 | > [experimental](https://github.com/chr33s/shopflare/tree/experimental) branch tracks Next Gen Dev Platform changes, see [issue](https://github.com/chr33s/shopflare/issues/42) 5 | 6 | Minimalist Shopify app using React Router (v7) running on cloudflare (worker, kv, analytics). Only essential features, no future changes other than core upgrades & platform alignment. 7 | 8 | ## Rationale 9 | 10 | - @shopify/shopify-[api,app-remix] to complex (to much abstraction) 11 | - Simple starter, than focusses on the basics 12 | - Small code surface, easier audit 13 | - Stability over features 14 | - Modular, extendable, tree shakable (remove factory functions) -> smaller bundle size 15 | - Minimally opinionated, by supporting only: 16 | 1. Embedded app use-case 17 | 2. New Embedded Auth Strategy 18 | 3. Managed Pricing 19 | - Optimized for Cloudflare stack 20 | - Tested - (unit, browser, e2e) 21 | 22 | ## Assumptions 23 | 24 | Familiarity with React, ReactRouter, Cloudflare, Shopify conventions. 25 | 26 | ## Requirements 27 | 28 | 1. Cloudflare account 29 | 2. Shopify Partner account 30 | 3. Node.js & NPM see package.json#engines `brew install node@22` 31 | 4. cloudflared cli `brew install cloudflared` (optional) 32 | 5. Github cli `brew install gh` (optional) 33 | 34 | > [!NOTE] 35 | > For wss:// to work on a cloudflare tunnel, you need to set "Additional application settings" > "HTTP Settings" > "HTTP Host Header" to match the service URL (e.g. 127.0.0.1), otherwise the tunnel returns a 502 http status & client connection fails 36 | 37 | > [!NOTE] 38 | > To bypass caching set: Caching > Cache Rules ["Rule Name" = "bypass cache on tunnel", "Custom filter expression" = "", Field = Hostname, Operator = equals, Value = tunnel url, "Cache eligibility" = "Bypass cache", "Browser TTL" = "Bypass cache" ] 39 | 40 | ## Setup 41 | 42 | ```sh 43 | npm install 44 | cp .env.example .env # update vars to match your env values from partners.shopify.com (Apps > All Apps > Create App) 45 | # vi [wrangler.json, shopify.app.toml] # update vars[SHOPIFY_API_KEY, SHOPIFY_APP_URL], SHOPIFY_APP_URL is the cloudflare tunnel url (e.g. https://shopflare.trycloudflare.com) in development and the cloudflare worker url (e.g. https://shopflare.workers.dev) in other environments. 46 | npx wrangler secret put SHOPIFY_API_SECRET_KEY 47 | npx wrangler kv namespace create shopflare # update wranglers.json#kv_namespaces[0].id 48 | gh secret set --app=actions CLOUDFLARE_API_TOKEN # value from dash.cloudflare.com (Manage Account > Account API Tokens > Create Token) 49 | gh secret set --app=actions SHOPIFY_CLI_PARTNERS_TOKEN # value from partners.shopify.com (Settings > CLI Token > Manage Tokens > Generate Token) 50 | gh variable set SHOPIFY_API_KEY 51 | ``` 52 | 53 | ## Development 54 | 55 | ```sh 56 | # vi .env # update vars[SHOPIFY_APP_LOG_LEVEL] sets logging verbosity. 57 | npm run deploy:shopify # only required on setup or config changes 58 | npm run gen 59 | npm run dev # or npm run dev:shopify:tunnel 60 | # open -a Safari ${SHOPIFY_APP_URL} 61 | ``` 62 | 63 | ## Production 64 | 65 | ```sh 66 | npm run build 67 | npm run deploy 68 | ``` 69 | 70 | To split environments see [Cloudflare](https://developers.cloudflare.com/workers/wrangler/environments/) and [Shopify](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration) docs. 71 | 72 | ## Documentation 73 | 74 | - [App Bridge](https://shopify.dev/docs/api/app-bridge-library/react-components) 75 | - [Cloudflare](https://developers.cloudflare.com) 76 | - [Polaris](https://polaris.shopify.com) 77 | - [React](https://react.dev/reference/react) 78 | - [React Router](https://reactrouter.com/home) 79 | - [Shopify](http://shopify.dev/) 80 | 81 | ### createShopify 82 | 83 | ```js 84 | export async function loader({ context, request }) { 85 | const shopify = createShopify(context); 86 | shopify.utils.log.debug("message..."); // Log on [error, info, debug] 87 | const client = await shopify.admin(request); // Authenticate on [admin*, proxy*, webhook] [*] returns a client 88 | const { data, errors } = await client.request(`query { shop { name } }`); 89 | shopify.redirect(request, url, { shop }); 90 | shopify.session.get(sessionId); // [get, set, delete, clear](id = shop) 91 | shopify.utils.addCorsHeaders(request, responseHeaders); // handle CORS headers 92 | } 93 | ``` 94 | 95 | ### createShopifyClient 96 | 97 | ```js 98 | const admin = createShopifyClient({ headers: { "X-Shopify-Access-Token": "?" }, shop }); 99 | const storefront = createShopifyClient({ headers: { "X-Shopify-Storefront-Access-Token": "?" }, shop }); 100 | ``` 101 | 102 | ### Components 103 | 104 | #### [proxy.tsx](./app/components/proxy.tsx) 105 | 106 | Follow [Shopify App Proxy](https://shopify.dev/docs/api/shopify-app-remix/v3/app-proxy-components) docs but import from `~/components/proxy` instead of `@shopify/shopify-app-remix/react` 107 | 108 | ### Branching convention 109 | 110 | - `issue/#` references an current issue / pull-request 111 | - `extension/#` is a non core feature extension 112 | 113 | ## Copyright 114 | 115 | Copyright (c) 2025 chr33s. See LICENSE.md for further details. 116 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 2.0.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | In case of a vulnerability please reach out to active maintainers of the project and report it to them. -------------------------------------------------------------------------------- /app/components/proxy.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type AnchorHTMLAttributes, 3 | type DetailedHTMLProps, 4 | type ReactNode, 5 | createContext, 6 | useContext, 7 | useEffect, 8 | useState, 9 | } from "react"; 10 | import { 11 | Form as ReactRouterForm, 12 | type FormProps as ReactRouterFormProps, 13 | } from "react-router"; 14 | 15 | export interface FormProps extends ReactRouterFormProps { 16 | action: string; 17 | } 18 | 19 | export function Form(props: FormProps) { 20 | const context = useContext(Context); 21 | 22 | if (!context) { 23 | throw new Error( 24 | "Proxy.Form must be used within an Proxy.Provider component", 25 | ); 26 | } 27 | 28 | const { children, action, ...otherProps } = props; 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | } 36 | 37 | type FormatUrlFunction = ( 38 | url: string | undefined, 39 | addOrigin?: boolean, 40 | ) => string | undefined; 41 | 42 | interface ContextProps { 43 | appUrl: string; 44 | formatUrl: FormatUrlFunction; 45 | requestUrl?: URL; 46 | } 47 | 48 | export const Context = createContext(null); 49 | 50 | export interface LinkProps 51 | extends DetailedHTMLProps< 52 | AnchorHTMLAttributes, 53 | HTMLAnchorElement 54 | > { 55 | href: string; 56 | } 57 | 58 | export function Link(props: LinkProps) { 59 | const context = useContext(Context); 60 | 61 | if (!context) { 62 | throw new Error( 63 | "Proxy.Link must be used within an Proxy.Provider component", 64 | ); 65 | } 66 | 67 | const { children, href, ...otherProps } = props; 68 | 69 | return ( 70 | 71 | {children} 72 | 73 | ); 74 | } 75 | 76 | export interface ProviderProps { 77 | appUrl: string; 78 | children?: ReactNode; 79 | } 80 | 81 | export function Provider(props: ProviderProps) { 82 | const { children, appUrl } = props; 83 | const [requestUrl, setRequestUrl] = useState(); 84 | 85 | useEffect(() => setRequestUrl(new URL(window.location.href)), []); 86 | 87 | return ( 88 | 95 | 96 | 97 | {children} 98 | 99 | ); 100 | } 101 | 102 | function formatProxyUrl(requestUrl: URL | undefined): FormatUrlFunction { 103 | return (url: string | undefined, addOrigin = true) => { 104 | if (!url) { 105 | return url; 106 | } 107 | 108 | let finalUrl = url; 109 | 110 | if (addOrigin && requestUrl && finalUrl.startsWith("/")) { 111 | finalUrl = new URL(`${requestUrl.origin}${url}`).href; 112 | } 113 | if (!finalUrl.endsWith("/")) { 114 | finalUrl = `${finalUrl}/`; 115 | } 116 | 117 | return finalUrl; 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /app/const.ts: -------------------------------------------------------------------------------- 1 | export const API_VERSION = "2025-04"; 2 | export const APP_BRIDGE_URL = 3 | "https://cdn.shopify.com/shopifycloud/app-bridge.js"; 4 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import i18next from "i18next"; 2 | import { StrictMode, startTransition } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | import { I18nextProvider, initReactI18next } from "react-i18next"; 5 | import { HydratedRouter } from "react-router/dom"; 6 | 7 | import i18n, { LanguageDetector } from "./i18n"; 8 | 9 | async function hydrate() { 10 | await i18next 11 | .use(initReactI18next) 12 | .use(LanguageDetector) 13 | .init({ 14 | ...i18n, 15 | detection: { 16 | searchParams: new URL(window.location.href).searchParams, 17 | }, 18 | }); 19 | 20 | startTransition(() => { 21 | hydrateRoot( 22 | document, 23 | 24 | 25 | 26 | 27 | , 28 | ); 29 | }); 30 | } 31 | 32 | if (window.requestIdleCallback) { 33 | window.requestIdleCallback(hydrate); 34 | } else { 35 | // Safari doesn't support requestIdleCallback 36 | // https://caniuse.com/requestidlecallback 37 | window.setTimeout(hydrate, 1); 38 | } 39 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { createInstance } from "i18next"; 2 | import { isbot } from "isbot"; 3 | import { renderToReadableStream } from "react-dom/server"; 4 | import { I18nextProvider, initReactI18next } from "react-i18next"; 5 | import { 6 | type AppLoadContext, 7 | type EntryContext, 8 | ServerRouter, 9 | } from "react-router"; 10 | 11 | import i18n, { LanguageDetector } from "./i18n"; 12 | import { createShopify } from "./shopify.server"; 13 | 14 | export default async function handleRequest( 15 | request: Request, 16 | responseStatus: number, 17 | responseHeaders: Headers, 18 | routerContext: EntryContext, 19 | loadContext: AppLoadContext, 20 | ) { 21 | createShopify(loadContext).utils.addHeaders(request, responseHeaders); 22 | 23 | const instance = createInstance(); 24 | await instance 25 | .use(initReactI18next) 26 | .use(LanguageDetector) 27 | .init({ 28 | ...i18n, 29 | detection: { 30 | headers: request.headers, 31 | searchParams: new URL(request.url).searchParams, 32 | }, 33 | }); 34 | 35 | const userAgent = request.headers.get("User-Agent"); 36 | const body = await renderToReadableStream( 37 | 38 | 39 | , 40 | { 41 | signal: request.signal, 42 | onError(error: unknown) { 43 | // biome-ignore lint/style/noParameterAssign: upstream 44 | responseStatus = 500; 45 | if (!request.signal.aborted) { 46 | // Log streaming rendering errors from inside the shell 47 | console.error("entry.server.onError", error); 48 | } 49 | }, 50 | }, 51 | ); 52 | 53 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 54 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 55 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 56 | await body.allReady; 57 | } else { 58 | responseHeaders.set("Transfer-Encoding", "chunked"); 59 | } 60 | 61 | responseHeaders.set("Content-Type", "text/html"); 62 | return new Response(body, { 63 | headers: responseHeaders, 64 | status: responseStatus, 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /app/i18n/en/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "App", 3 | "error": "An error occurred", 4 | "errorUnauthorized": "Unauthorized", 5 | "login": "Log in", 6 | "page": "Page", 7 | "pricingPlans": "Pricing Plans", 8 | "shopDomain": "Shop domain" 9 | } 10 | -------------------------------------------------------------------------------- /app/i18n/en/polaris.json: -------------------------------------------------------------------------------- 1 | { 2 | "Polaris": { 3 | "ActionMenu": { 4 | "Actions": { 5 | "moreActions": "More actions" 6 | }, 7 | "RollupActions": { 8 | "rollupButton": "View actions" 9 | } 10 | }, 11 | "ActionList": { 12 | "SearchField": { 13 | "clearButtonLabel": "Clear", 14 | "search": "Search", 15 | "placeholder": "Search actions" 16 | } 17 | }, 18 | "Avatar": { 19 | "label": "Avatar", 20 | "labelWithInitials": "Avatar with initials {initials}" 21 | }, 22 | "Autocomplete": { 23 | "spinnerAccessibilityLabel": "Loading", 24 | "ellipsis": "{content}…" 25 | }, 26 | "Badge": { 27 | "PROGRESS_LABELS": { 28 | "incomplete": "Incomplete", 29 | "partiallyComplete": "Partially complete", 30 | "complete": "Complete" 31 | }, 32 | "TONE_LABELS": { 33 | "info": "Info", 34 | "success": "Success", 35 | "warning": "Warning", 36 | "critical": "Critical", 37 | "attention": "Attention", 38 | "new": "New", 39 | "readOnly": "Read-only", 40 | "enabled": "Enabled" 41 | }, 42 | "progressAndTone": "{toneLabel} {progressLabel}" 43 | }, 44 | "Banner": { 45 | "dismissButton": "Dismiss notification" 46 | }, 47 | "Button": { 48 | "spinnerAccessibilityLabel": "Loading" 49 | }, 50 | "Common": { 51 | "checkbox": "checkbox", 52 | "undo": "Undo", 53 | "cancel": "Cancel", 54 | "clear": "Clear", 55 | "close": "Close", 56 | "submit": "Submit", 57 | "more": "More" 58 | }, 59 | "ContextualSaveBar": { 60 | "save": "Save", 61 | "discard": "Discard" 62 | }, 63 | "DataTable": { 64 | "sortAccessibilityLabel": "sort {direction} by", 65 | "navAccessibilityLabel": "Scroll table {direction} one column", 66 | "totalsRowHeading": "Totals", 67 | "totalRowHeading": "Total" 68 | }, 69 | "DatePicker": { 70 | "previousMonth": "Show previous month, {previousMonthName} {showPreviousYear}", 71 | "nextMonth": "Show next month, {nextMonth} {nextYear}", 72 | "today": "Today ", 73 | "start": "Start of range", 74 | "end": "End of range", 75 | "months": { 76 | "january": "January", 77 | "february": "February", 78 | "march": "March", 79 | "april": "April", 80 | "may": "May", 81 | "june": "June", 82 | "july": "July", 83 | "august": "August", 84 | "september": "September", 85 | "october": "October", 86 | "november": "November", 87 | "december": "December" 88 | }, 89 | "days": { 90 | "monday": "Monday", 91 | "tuesday": "Tuesday", 92 | "wednesday": "Wednesday", 93 | "thursday": "Thursday", 94 | "friday": "Friday", 95 | "saturday": "Saturday", 96 | "sunday": "Sunday" 97 | }, 98 | "daysAbbreviated": { 99 | "monday": "Mo", 100 | "tuesday": "Tu", 101 | "wednesday": "We", 102 | "thursday": "Th", 103 | "friday": "Fr", 104 | "saturday": "Sa", 105 | "sunday": "Su" 106 | } 107 | }, 108 | "DiscardConfirmationModal": { 109 | "title": "Discard all unsaved changes", 110 | "message": "If you discard changes, you’ll delete any edits you made since you last saved.", 111 | "primaryAction": "Discard changes", 112 | "secondaryAction": "Continue editing" 113 | }, 114 | "DropZone": { 115 | "single": { 116 | "overlayTextFile": "Drop file to upload", 117 | "overlayTextImage": "Drop image to upload", 118 | "overlayTextVideo": "Drop video to upload", 119 | "actionTitleFile": "Add file", 120 | "actionTitleImage": "Add image", 121 | "actionTitleVideo": "Add video", 122 | "actionHintFile": "or drop file to upload", 123 | "actionHintImage": "or drop image to upload", 124 | "actionHintVideo": "or drop video to upload", 125 | "labelFile": "Upload file", 126 | "labelImage": "Upload image", 127 | "labelVideo": "Upload video" 128 | }, 129 | "allowMultiple": { 130 | "overlayTextFile": "Drop files to upload", 131 | "overlayTextImage": "Drop images to upload", 132 | "overlayTextVideo": "Drop videos to upload", 133 | "actionTitleFile": "Add files", 134 | "actionTitleImage": "Add images", 135 | "actionTitleVideo": "Add videos", 136 | "actionHintFile": "or drop files to upload", 137 | "actionHintImage": "or drop images to upload", 138 | "actionHintVideo": "or drop videos to upload", 139 | "labelFile": "Upload files", 140 | "labelImage": "Upload images", 141 | "labelVideo": "Upload videos" 142 | }, 143 | "errorOverlayTextFile": "File type is not valid", 144 | "errorOverlayTextImage": "Image type is not valid", 145 | "errorOverlayTextVideo": "Video type is not valid" 146 | }, 147 | "EmptySearchResult": { 148 | "altText": "Empty search results" 149 | }, 150 | "Frame": { 151 | "skipToContent": "Skip to content", 152 | "navigationLabel": "Navigation", 153 | "Navigation": { 154 | "closeMobileNavigationLabel": "Close navigation" 155 | } 156 | }, 157 | "FullscreenBar": { 158 | "back": "Back", 159 | "accessibilityLabel": "Exit fullscreen mode" 160 | }, 161 | "Filters": { 162 | "moreFilters": "More filters", 163 | "moreFiltersWithCount": "More filters ({count})", 164 | "filter": "Filter {resourceName}", 165 | "noFiltersApplied": "No filters applied", 166 | "cancel": "Cancel", 167 | "done": "Done", 168 | "clearAllFilters": "Clear all filters", 169 | "clear": "Clear", 170 | "clearLabel": "Clear {filterName}", 171 | "addFilter": "Add filter", 172 | "clearFilters": "Clear all", 173 | "searchInView": "in:{viewName}" 174 | }, 175 | "FilterPill": { 176 | "clear": "Clear", 177 | "unsavedChanges": "Unsaved changes - {label}" 178 | }, 179 | "IndexFilters": { 180 | "searchFilterTooltip": "Search and filter", 181 | "searchFilterTooltipWithShortcut": "Search and filter (F)", 182 | "searchFilterAccessibilityLabel": "Search and filter results", 183 | "sort": "Sort your results", 184 | "addView": "Add a new view", 185 | "newView": "Custom search", 186 | "SortButton": { 187 | "ariaLabel": "Sort the results", 188 | "tooltip": "Sort", 189 | "title": "Sort by", 190 | "sorting": { 191 | "asc": "Ascending", 192 | "desc": "Descending", 193 | "az": "A-Z", 194 | "za": "Z-A" 195 | } 196 | }, 197 | "EditColumnsButton": { 198 | "tooltip": "Edit columns", 199 | "accessibilityLabel": "Customize table column order and visibility" 200 | }, 201 | "UpdateButtons": { 202 | "cancel": "Cancel", 203 | "update": "Update", 204 | "save": "Save", 205 | "saveAs": "Save as", 206 | "modal": { 207 | "title": "Save view as", 208 | "label": "Name", 209 | "sameName": "A view with this name already exists. Please choose a different name.", 210 | "save": "Save", 211 | "cancel": "Cancel" 212 | } 213 | } 214 | }, 215 | "IndexProvider": { 216 | "defaultItemSingular": "Item", 217 | "defaultItemPlural": "Items", 218 | "allItemsSelected": "All {itemsLength}+ {resourceNamePlural} are selected", 219 | "selected": "{selectedItemsCount} selected", 220 | "a11yCheckboxDeselectAllSingle": "Deselect {resourceNameSingular}", 221 | "a11yCheckboxSelectAllSingle": "Select {resourceNameSingular}", 222 | "a11yCheckboxDeselectAllMultiple": "Deselect all {itemsLength} {resourceNamePlural}", 223 | "a11yCheckboxSelectAllMultiple": "Select all {itemsLength} {resourceNamePlural}" 224 | }, 225 | "IndexTable": { 226 | "emptySearchTitle": "No {resourceNamePlural} found", 227 | "emptySearchDescription": "Try changing the filters or search term", 228 | "onboardingBadgeText": "New", 229 | "resourceLoadingAccessibilityLabel": "Loading {resourceNamePlural}…", 230 | "selectAllLabel": "Select all {resourceNamePlural}", 231 | "selected": "{selectedItemsCount} selected", 232 | "undo": "Undo", 233 | "selectAllItems": "Select all {itemsLength}+ {resourceNamePlural}", 234 | "selectItem": "Select {resourceName}", 235 | "selectButtonText": "Select", 236 | "sortAccessibilityLabel": "sort {direction} by" 237 | }, 238 | "Loading": { 239 | "label": "Page loading bar" 240 | }, 241 | "Modal": { 242 | "iFrameTitle": "body markup", 243 | "modalWarning": "These required properties are missing from Modal: {missingProps}" 244 | }, 245 | "Page": { 246 | "Header": { 247 | "rollupActionsLabel": "View actions for {title}", 248 | "pageReadyAccessibilityLabel": "{title}. This page is ready" 249 | } 250 | }, 251 | "Pagination": { 252 | "previous": "Previous", 253 | "next": "Next", 254 | "pagination": "Pagination" 255 | }, 256 | "ProgressBar": { 257 | "negativeWarningMessage": "Values passed to the progress prop shouldn’t be negative. Resetting {progress} to 0.", 258 | "exceedWarningMessage": "Values passed to the progress prop shouldn’t exceed 100. Setting {progress} to 100." 259 | }, 260 | "ResourceList": { 261 | "sortingLabel": "Sort by", 262 | "defaultItemSingular": "item", 263 | "defaultItemPlural": "items", 264 | "showing": "Showing {itemsCount} {resource}", 265 | "showingTotalCount": "Showing {itemsCount} of {totalItemsCount} {resource}", 266 | "loading": "Loading {resource}", 267 | "selected": "{selectedItemsCount} selected", 268 | "allItemsSelected": "All {itemsLength}+ {resourceNamePlural} in your store are selected", 269 | "allFilteredItemsSelected": "All {itemsLength}+ {resourceNamePlural} in this filter are selected", 270 | "selectAllItems": "Select all {itemsLength}+ {resourceNamePlural} in your store", 271 | "selectAllFilteredItems": "Select all {itemsLength}+ {resourceNamePlural} in this filter", 272 | "emptySearchResultTitle": "No {resourceNamePlural} found", 273 | "emptySearchResultDescription": "Try changing the filters or search term", 274 | "selectButtonText": "Select", 275 | "a11yCheckboxDeselectAllSingle": "Deselect {resourceNameSingular}", 276 | "a11yCheckboxSelectAllSingle": "Select {resourceNameSingular}", 277 | "a11yCheckboxDeselectAllMultiple": "Deselect all {itemsLength} {resourceNamePlural}", 278 | "a11yCheckboxSelectAllMultiple": "Select all {itemsLength} {resourceNamePlural}", 279 | "Item": { 280 | "actionsDropdownLabel": "Actions for {accessibilityLabel}", 281 | "actionsDropdown": "Actions dropdown", 282 | "viewItem": "View details for {itemName}" 283 | }, 284 | "BulkActions": { 285 | "actionsActivatorLabel": "Actions", 286 | "moreActionsActivatorLabel": "More actions" 287 | } 288 | }, 289 | "SkeletonPage": { 290 | "loadingLabel": "Page loading" 291 | }, 292 | "Tabs": { 293 | "newViewAccessibilityLabel": "Create new view", 294 | "newViewTooltip": "Create view", 295 | "toggleTabsLabel": "More views", 296 | "Tab": { 297 | "rename": "Rename view", 298 | "duplicate": "Duplicate view", 299 | "edit": "Edit view", 300 | "editColumns": "Edit columns", 301 | "delete": "Delete view", 302 | "copy": "Copy of {name}", 303 | "deleteModal": { 304 | "title": "Delete view?", 305 | "description": "This can’t be undone. {viewName} view will no longer be available in your admin.", 306 | "cancel": "Cancel", 307 | "delete": "Delete view" 308 | } 309 | }, 310 | "RenameModal": { 311 | "title": "Rename view", 312 | "label": "Name", 313 | "cancel": "Cancel", 314 | "create": "Save", 315 | "errors": { 316 | "sameName": "A view with this name already exists. Please choose a different name." 317 | } 318 | }, 319 | "DuplicateModal": { 320 | "title": "Duplicate view", 321 | "label": "Name", 322 | "cancel": "Cancel", 323 | "create": "Create view", 324 | "errors": { 325 | "sameName": "A view with this name already exists. Please choose a different name." 326 | } 327 | }, 328 | "CreateViewModal": { 329 | "title": "Create new view", 330 | "label": "Name", 331 | "cancel": "Cancel", 332 | "create": "Create view", 333 | "errors": { 334 | "sameName": "A view with this name already exists. Please choose a different name." 335 | } 336 | } 337 | }, 338 | "Tag": { 339 | "ariaLabel": "Remove {children}" 340 | }, 341 | "TextField": { 342 | "characterCount": "{count} characters", 343 | "characterCountWithMaxLength": "{count} of {limit} characters used" 344 | }, 345 | "TooltipOverlay": { 346 | "accessibilityLabel": "Tooltip: {label}" 347 | }, 348 | "TopBar": { 349 | "toggleMenuLabel": "Toggle menu", 350 | "SearchField": { 351 | "clearButtonLabel": "Clear", 352 | "search": "Search" 353 | } 354 | }, 355 | "MediaCard": { 356 | "dismissButton": "Dismiss", 357 | "popoverButton": "Actions" 358 | }, 359 | "VideoThumbnail": { 360 | "playButtonA11yLabel": { 361 | "default": "Play video", 362 | "defaultWithDuration": "Play video of length {duration}", 363 | "duration": { 364 | "hours": { 365 | "other": { 366 | "only": "{hourCount} hours", 367 | "andMinutes": "{hourCount} hours and {minuteCount} minutes", 368 | "andMinute": "{hourCount} hours and {minuteCount} minute", 369 | "minutesAndSeconds": "{hourCount} hours, {minuteCount} minutes, and {secondCount} seconds", 370 | "minutesAndSecond": "{hourCount} hours, {minuteCount} minutes, and {secondCount} second", 371 | "minuteAndSeconds": "{hourCount} hours, {minuteCount} minute, and {secondCount} seconds", 372 | "minuteAndSecond": "{hourCount} hours, {minuteCount} minute, and {secondCount} second", 373 | "andSeconds": "{hourCount} hours and {secondCount} seconds", 374 | "andSecond": "{hourCount} hours and {secondCount} second" 375 | }, 376 | "one": { 377 | "only": "{hourCount} hour", 378 | "andMinutes": "{hourCount} hour and {minuteCount} minutes", 379 | "andMinute": "{hourCount} hour and {minuteCount} minute", 380 | "minutesAndSeconds": "{hourCount} hour, {minuteCount} minutes, and {secondCount} seconds", 381 | "minutesAndSecond": "{hourCount} hour, {minuteCount} minutes, and {secondCount} second", 382 | "minuteAndSeconds": "{hourCount} hour, {minuteCount} minute, and {secondCount} seconds", 383 | "minuteAndSecond": "{hourCount} hour, {minuteCount} minute, and {secondCount} second", 384 | "andSeconds": "{hourCount} hour and {secondCount} seconds", 385 | "andSecond": "{hourCount} hour and {secondCount} second" 386 | } 387 | }, 388 | "minutes": { 389 | "other": { 390 | "only": "{minuteCount} minutes", 391 | "andSeconds": "{minuteCount} minutes and {secondCount} seconds", 392 | "andSecond": "{minuteCount} minutes and {secondCount} second" 393 | }, 394 | "one": { 395 | "only": "{minuteCount} minute", 396 | "andSeconds": "{minuteCount} minute and {secondCount} seconds", 397 | "andSecond": "{minuteCount} minute and {secondCount} second" 398 | } 399 | }, 400 | "seconds": { 401 | "other": "{secondCount} seconds", 402 | "one": "{secondCount} second" 403 | } 404 | } 405 | } 406 | } 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /app/i18n/en/proxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "proxy": "Proxy" 3 | } 4 | -------------------------------------------------------------------------------- /app/i18n/index.server.test.ts: -------------------------------------------------------------------------------- 1 | import type { InitOptions, Services } from "i18next"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { LanguageDetector } from "./index"; 5 | 6 | describe("detect", () => { 7 | const services = {} as Services; 8 | const initOptions = { 9 | fallbackLng: "zz", 10 | supportedLngs: ["aa", "bb"], 11 | } as InitOptions; 12 | 13 | test("searchParams", () => { 14 | const detectorOptions = { 15 | headers: new Headers({ "Accept-Language": "aa" }), 16 | searchParams: new URLSearchParams({ locale: "bb" }), 17 | }; 18 | const languageDetector = new LanguageDetector( 19 | services, 20 | detectorOptions, 21 | initOptions, 22 | ); 23 | 24 | expect(languageDetector.detect()).toBe("bb"); 25 | }); 26 | 27 | test("headers", () => { 28 | const detectorOptions = { 29 | headers: new Headers({ "Accept-Language": "aa" }), 30 | searchParams: new URLSearchParams({}), 31 | }; 32 | const languageDetector = new LanguageDetector( 33 | services, 34 | detectorOptions, 35 | initOptions, 36 | ); 37 | 38 | expect(languageDetector.detect()).toBe("aa"); 39 | }); 40 | 41 | test("fallback", () => { 42 | const detectorOptions = { 43 | headers: new Headers({ "Accept-Language": "cc" }), 44 | searchParams: new URLSearchParams({ locale: "dd" }), 45 | }; 46 | const languageDetector = new LanguageDetector( 47 | services, 48 | detectorOptions, 49 | initOptions, 50 | ); 51 | 52 | expect(languageDetector.detect()).toBe("zz"); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /app/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import resources from "virtual:i18next-loader"; 2 | import type { InitOptions, LanguageDetectorModule, Services } from "i18next"; 3 | 4 | const i18n = { 5 | debug: false, 6 | defaultNS: "app", 7 | fallbackLng: "en", 8 | interpolation: { 9 | escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape 10 | }, 11 | ns: ["app", "polaris", "proxy"], 12 | resources, 13 | supportedLngs: ["en"], 14 | } satisfies InitOptions; 15 | 16 | export default i18n; 17 | 18 | export type DetectorOptions = { 19 | headers: Headers; 20 | searchParams: URLSearchParams; 21 | }; 22 | 23 | export class LanguageDetector implements LanguageDetectorModule { 24 | public type = "languageDetector" as const; 25 | static type = "languageDetector" as const; 26 | 27 | #options: DetectorOptions; 28 | #i18n: InitOptions; 29 | 30 | constructor( 31 | _services: Services, 32 | detectorOptions: DetectorOptions, 33 | initOptions: InitOptions, 34 | ) { 35 | this.#options = detectorOptions; 36 | this.#i18n = initOptions; 37 | } 38 | 39 | public detect() { 40 | let locale: string | null | undefined; 41 | 42 | const param = "locale"; 43 | if (this.#options?.searchParams?.has(param)) { 44 | locale = this.#options.searchParams.get(param); // shopify admin 45 | } 46 | 47 | const header = "accept-language"; 48 | if (!locale && this.#options?.headers?.has(header)) { 49 | locale = this.#options?.headers 50 | .get(header) 51 | ?.match(/[a-z-_]{2,5}/i) 52 | ?.at(0); // shopify storefront 53 | } 54 | locale = locale?.split("-").at(0); 55 | 56 | const supportedLngs = this.#i18n?.supportedLngs || i18n.supportedLngs; 57 | if (locale && !supportedLngs.includes(locale)) { 58 | locale = null; 59 | } 60 | 61 | if (!locale) { 62 | const fallbackLng = this.#i18n?.fallbackLng || i18n.fallbackLng; 63 | locale = Array.isArray(fallbackLng) ? fallbackLng[0] : fallbackLng; 64 | } 65 | return locale || "en"; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/root.client.test.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { test } from "vitest"; 3 | 4 | import Root from "./root"; 5 | 6 | test("component", () => { 7 | const app = window.document.createElement("div"); 8 | const root = createRoot(app); 9 | root.render(); 10 | root.unmount(); 11 | }); 12 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { 4 | Links, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | isRouteErrorResponse, 10 | } from "react-router"; 11 | 12 | import type { Route } from "./+types/root"; 13 | 14 | export default function App() { 15 | return ; 16 | } 17 | 18 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 19 | let message = "Oops!"; 20 | let details = "An unexpected error occurred."; 21 | let stack: string | undefined; 22 | 23 | if (isRouteErrorResponse(error)) { 24 | message = error.status === 404 ? "404" : "Error"; 25 | details = 26 | error.status === 404 27 | ? "The requested page could not be found." 28 | : error.statusText || details; 29 | } else if (import.meta.env.DEV && error && error instanceof Error) { 30 | details = error.message; 31 | stack = error.stack; 32 | } 33 | 34 | return ( 35 |
44 |
45 |

{message}

46 |

{details}

47 | {stack && ( 48 |
49 | 						{stack}
50 | 					
51 | )} 52 |
53 |
54 | ); 55 | } 56 | ErrorBoundary.displayName = "RootErrorBoundary"; 57 | 58 | export function Layout({ children }: PropsWithChildren) { 59 | const { i18n } = useTranslation(); 60 | 61 | return ( 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {children} 71 | 72 | 73 | 74 | 75 | ); 76 | } 77 | 78 | export const meta: Route.MetaFunction = () => [ 79 | { title: "ShopFlare" }, 80 | { name: "description", content: "..." }, 81 | ]; 82 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type RouteConfig, 3 | index, 4 | layout, 5 | prefix, 6 | route, 7 | } from "@react-router/dev/routes"; 8 | 9 | export default [ 10 | index("routes/index.tsx"), 11 | ...prefix("app", [ 12 | layout("./routes/app.tsx", [index("routes/app.index.tsx")]), 13 | ]), 14 | // NOTE: route path must match proxy path ~ shopify.url/:subpathPrefix((a|apps|community|tools)/:subpath == proxy.url/:subpathPrefix/:subpath 15 | ...prefix("apps/:subpath", [ 16 | layout("./routes/proxy.tsx", [index("./routes/proxy.index.tsx")]), 17 | ]), 18 | ...prefix("shopify", [ 19 | ...prefix("auth", [ 20 | route("login", "./routes/shopify.auth.login.tsx"), 21 | route( 22 | "session-token-bounce", 23 | "./routes/shopify.auth.session-token-bounce.tsx", 24 | ), 25 | ]), 26 | route("webhooks", "./routes/shopify.webhooks.tsx"), 27 | ]), 28 | ] satisfies RouteConfig; 29 | -------------------------------------------------------------------------------- /app/routes/app.index.tsx: -------------------------------------------------------------------------------- 1 | import { SaveBar, useAppBridge } from "@shopify/app-bridge-react"; 2 | import { Button, Page, Text } from "@shopify/polaris"; 3 | import { useEffect } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { data } from "react-router"; 6 | 7 | import { API_VERSION } from "~/const"; 8 | import { ShopifyException, createShopify } from "~/shopify.server"; 9 | import type { ShopQuery } from "~/types/admin.generated"; 10 | import type { Route } from "./+types/app.index"; 11 | 12 | export async function loader({ context, request }: Route.LoaderArgs) { 13 | const shopify = createShopify(context); 14 | shopify.utils.log.debug("app.index.loader"); 15 | 16 | const client = await shopify.admin(request); 17 | 18 | try { 19 | const { data, errors } = await client.request(/* GraphQL */ ` 20 | #graphql 21 | query Shop { 22 | shop { 23 | name 24 | } 25 | } 26 | `); 27 | return { 28 | data, 29 | errors, 30 | }; 31 | } catch (error) { 32 | shopify.utils.log.error("app.index.loader.error", error); 33 | 34 | if (error instanceof ShopifyException) { 35 | switch (error.type) { 36 | case "GRAPHQL": 37 | return { errors: error.errors }; 38 | 39 | default: 40 | return new Response(error.message, { 41 | status: error.status, 42 | }); 43 | } 44 | } 45 | 46 | return data( 47 | { 48 | data: undefined, 49 | errors: [{ message: "Unknown Error" }], 50 | }, 51 | 500, 52 | ); 53 | } 54 | } 55 | 56 | export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) { 57 | const data = await serverLoader(); 58 | return data; 59 | } 60 | 61 | export default function AppIndex({ 62 | actionData, 63 | loaderData, 64 | }: Route.ComponentProps) { 65 | const { data, errors } = loaderData ?? actionData ?? {}; 66 | console.log("app.index", data); 67 | 68 | const { t } = useTranslation(); 69 | 70 | useEffect(() => { 71 | const controller = new AbortController(); 72 | 73 | fetch(`shopify:admin/api/${API_VERSION}/graphql.json`, { 74 | body: JSON.stringify({ 75 | query: /* GraphQL */ ` 76 | #graphql 77 | query Shop { 78 | shop { 79 | name 80 | } 81 | } 82 | `, 83 | variables: {}, 84 | }), 85 | method: "POST", 86 | signal: controller.signal, 87 | }) 88 | .then<{ data: ShopQuery }>((res) => res.json()) 89 | .then((res) => console.log("app.index.useEffect", res)) 90 | .catch((err) => console.error("app.index.useEffect.error", err)); 91 | 92 | return () => controller.abort(); 93 | }, []); 94 | 95 | const shopify = useAppBridge(); 96 | 97 | return ( 98 | 99 | 100 | 112 | 113 | ); 114 | } 115 | 116 | export async function clientAction({ serverAction }: Route.ClientActionArgs) { 117 | const data = await serverAction(); 118 | return data; 119 | } 120 | 121 | export async function action(_: Route.ActionArgs) { 122 | const data = {}; 123 | return { data }; 124 | } 125 | 126 | export { headers } from "./app"; 127 | -------------------------------------------------------------------------------- /app/routes/app.tsx: -------------------------------------------------------------------------------- 1 | import { NavMenu, useAppBridge } from "@shopify/app-bridge-react"; 2 | import { AppProvider, type AppProviderProps } from "@shopify/polaris"; 3 | import polarisCss from "@shopify/polaris/build/esm/styles.css?url"; 4 | import type { LinkLikeComponentProps } from "@shopify/polaris/build/ts/src/utilities/link"; 5 | import { useEffect } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | import { Link, Outlet, useNavigation } from "react-router"; 8 | 9 | import { APP_BRIDGE_URL } from "~/const"; 10 | import { createShopify } from "~/shopify.server"; 11 | import type { Route } from "./+types/app"; 12 | 13 | export async function loader({ context, request }: Route.LoaderArgs) { 14 | try { 15 | const shopify = createShopify(context); 16 | shopify.utils.log.debug("app"); 17 | 18 | await shopify.admin(request); 19 | 20 | return { 21 | appDebug: shopify.config.appLogLevel === "debug", 22 | appHandle: shopify.config.appHandle, 23 | apiKey: shopify.config.apiKey, 24 | appUrl: shopify.config.appUrl, 25 | }; 26 | // biome-ignore lint/suspicious/noExplicitAny: catch(err) 27 | } catch (error: any) { 28 | if (error instanceof Response) return error; 29 | 30 | return new Response(error.message, { 31 | status: error.status, 32 | statusText: "Unauthorized", 33 | }); 34 | } 35 | } 36 | 37 | export default function App({ loaderData }: Route.ComponentProps) { 38 | const { appHandle, apiKey } = loaderData; 39 | 40 | const { t } = useTranslation(["app", "polaris"]); 41 | const i18n = { 42 | Polaris: t("Polaris", { 43 | ns: "polaris", 44 | returnObjects: true, 45 | }), 46 | } as AppProviderProps["i18n"]; 47 | 48 | return ( 49 | <> 50 | 26 | `, 27 | { headers }, 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/routes/shopify.webhooks.server.test.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import type { AppLoadContext } from "react-router"; 3 | import { describe, expect, test } from "vitest"; 4 | 5 | import { API_VERSION } from "~/const"; 6 | import type { Route } from "./+types/shopify.webhooks"; 7 | import { action } from "./shopify.webhooks"; 8 | 9 | const context = { cloudflare: { env } } as unknown as AppLoadContext; 10 | 11 | describe("action", () => { 12 | test("error on body missing", async () => { 13 | const request = new Request("http://localhost"); 14 | const response = await action({ context, request } as Route.ActionArgs); 15 | 16 | expect(response).toBeInstanceOf(Response); 17 | expect(response.ok).toBe(false); 18 | expect(response.status).toBe(400); 19 | expect(await response.text()).toBe("Webhook body is missing"); 20 | }); 21 | 22 | test("error on header missing", async () => { 23 | const request = new Request("http://localhost", { 24 | body: "123", 25 | method: "POST", 26 | }); 27 | const response = await action({ context, request } as Route.ActionArgs); 28 | 29 | expect(response).toBeInstanceOf(Response); 30 | expect(response.ok).toBe(false); 31 | expect(response.status).toBe(400); 32 | expect(await response.text()).toBe("Webhook header is missing"); 33 | }); 34 | 35 | test("error on encoded byte length mismatch", async () => { 36 | const request = new Request("http://localhost", { 37 | body: "123", 38 | headers: { "X-Shopify-Hmac-Sha256": "123" }, 39 | method: "POST", 40 | }); 41 | const response = await action({ context, request } as Route.ActionArgs); 42 | 43 | expect(response).toBeInstanceOf(Response); 44 | expect(response.ok).toBe(false); 45 | expect(response.status).toBe(401); 46 | expect(await response.text()).toBe("Encoded byte length mismatch"); 47 | }); 48 | 49 | test("error on invalid hmac", async () => { 50 | const request = new Request("http://localhost", { 51 | body: "132", // NOTE: changed 52 | headers: { 53 | "X-Shopify-Hmac-Sha256": "tKI9km9Efxo6gfUjbUBCo3XJ0CmqMLgb4xNzNhpQhK0=", 54 | }, 55 | method: "POST", 56 | }); 57 | const response = await action({ context, request } as Route.ActionArgs); 58 | 59 | expect(response).toBeInstanceOf(Response); 60 | expect(response.ok).toBe(false); 61 | expect(response.status).toBe(401); 62 | expect(await response.text()).toBe("Invalid hmac"); 63 | }); 64 | 65 | test("error on missing headers", async () => { 66 | const request = new Request("http://localhost", { 67 | body: "123", 68 | headers: { 69 | "X-Shopify-Hmac-Sha256": "tKI9km9Efxo6gfUjbUBCo3XJ0CmqMLgb4xNzNhpQhK0=", 70 | }, 71 | method: "POST", 72 | }); 73 | const response = await action({ context, request } as Route.ActionArgs); 74 | 75 | expect(response).toBeInstanceOf(Response); 76 | expect(response.ok).toBe(false); 77 | expect(response.status).toBe(400); 78 | expect(await response.text()).toBe("Webhook headers are missing"); 79 | }); 80 | 81 | test("success", async () => { 82 | const request = new Request("http://localhost", { 83 | body: "123", 84 | headers: { 85 | "X-Shopify-API-Version": API_VERSION, 86 | "X-Shopify-Shop-Domain": "test.myshopify.com", 87 | "X-Shopify-Hmac-Sha256": await getHmac("123"), 88 | "X-Shopify-Topic": "app/uninstalled", 89 | "X-Shopify-Webhook-Id": "test", 90 | }, 91 | method: "POST", 92 | }); 93 | const response = await action({ context, request } as Route.ActionArgs); 94 | 95 | expect(response).toBeInstanceOf(Response); 96 | expect(response.ok).toBe(true); 97 | expect(response.status).toBe(204); 98 | expect(response.body).toBe(null); 99 | }); 100 | }); 101 | 102 | async function getHmac(body: string) { 103 | const encoder = new TextEncoder(); 104 | const key = await crypto.subtle.importKey( 105 | "raw", 106 | encoder.encode(context.cloudflare.env.SHOPIFY_API_SECRET_KEY), 107 | { 108 | name: "HMAC", 109 | hash: "SHA-256", 110 | }, 111 | true, 112 | ["sign"], 113 | ); 114 | const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); 115 | const hmac = btoa(String.fromCharCode(...new Uint8Array(signature))); // base64 116 | return hmac; 117 | } 118 | -------------------------------------------------------------------------------- /app/routes/shopify.webhooks.tsx: -------------------------------------------------------------------------------- 1 | import { createShopify } from "~/shopify.server"; 2 | import type { Route } from "./+types/shopify.webhooks"; 3 | 4 | export async function action({ context, request }: Route.ActionArgs) { 5 | try { 6 | const shopify = createShopify(context); 7 | shopify.utils.log.debug("shopify.webhooks"); 8 | 9 | const webhook = await shopify.webhook(request); 10 | shopify.utils.log.debug("shopify.webhooks", { ...webhook }); 11 | 12 | const session = await shopify.session.get(webhook.domain); 13 | const payload = await request.json(); 14 | 15 | switch (webhook.topic) { 16 | case "APP_UNINSTALLED": 17 | if (session) { 18 | await shopify.session.delete(session.id); 19 | } 20 | break; 21 | 22 | case "APP_SCOPES_UPDATE": 23 | if (session) { 24 | await shopify.session.set({ 25 | ...session, 26 | scope: (payload as { current: string[] }).current.toString(), 27 | }); 28 | } 29 | break; 30 | } 31 | 32 | await context.cloudflare.env.WEBHOOK_QUEUE?.send( 33 | { 34 | payload, 35 | webhook, 36 | }, 37 | { contentType: "json" }, 38 | ); 39 | 40 | return new Response(undefined, { status: 204 }); 41 | // biome-ignore lint/suspicious/noExplicitAny: catch(err) 42 | } catch (error: any) { 43 | return new Response(error.message, { 44 | status: error.status, 45 | statusText: "Unauthorized", 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/shopify.server.test.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import type { AppLoadContext } from "react-router"; 3 | import { describe, expect, test } from "vitest"; 4 | 5 | import { createShopify } from "./shopify.server"; 6 | 7 | const context = { cloudflare: { env } } as unknown as AppLoadContext; 8 | 9 | test("createShopify", () => { 10 | const shopify = createShopify(context); 11 | expect(shopify.admin).toBeDefined(); 12 | }); 13 | 14 | describe("utils", () => { 15 | const { utils } = createShopify(context); 16 | 17 | test("allowedDomains", () => { 18 | expect(utils.allowedDomains).toBe( 19 | "myshopify\\.com|myshopify\\.io|shop\\.dev|shopify\\.com", 20 | ); 21 | }); 22 | 23 | test("encode", async () => { 24 | const encoder = new TextEncoder(); 25 | const data = encoder.encode("test"); 26 | 27 | expect(utils.encode(data, "base64")).toBe("dGVzdA=="); 28 | expect(utils.encode(data, "hex")).toBe("74657374"); 29 | }); 30 | 31 | test("legacyUrlToShopAdminUrl", () => { 32 | expect(utils.legacyUrlToShopAdminUrl("test.myshopify.com")).toBe( 33 | "admin.shopify.com/store/test", 34 | ); 35 | expect(utils.legacyUrlToShopAdminUrl("test.example.com")).toBe(null); 36 | }); 37 | 38 | test("sanitizeHost", () => { 39 | const host = btoa("test.myshopify.com"); 40 | expect(utils.sanitizeHost(host)).toBe(host); 41 | expect(utils.sanitizeHost(btoa("test.example.com"))).toBe(null); 42 | }); 43 | 44 | test("sanitizeShop", () => { 45 | const shop = "test.myshopify.com"; 46 | expect(utils.sanitizeShop("admin.shopify.com/store/test")).toBe(shop); 47 | expect(utils.sanitizeShop(shop)).toBe(shop); 48 | expect(utils.sanitizeShop("test.example.com")).toBe(null); 49 | }); 50 | 51 | test("validateHmac", async () => { 52 | const data = "123"; 53 | const hmac = "tKI9km9Efxo6gfUjbUBCo3XJ0CmqMLgb4xNzNhpQhK0="; 54 | const encoding = "base64"; 55 | 56 | expect.assertions(2); 57 | expect(await utils.validateHmac(data, hmac, encoding)).toBeUndefined(); 58 | await expect(utils.validateHmac("124", hmac, encoding)).rejects.toThrow( 59 | "Invalid hmac", 60 | ); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /app/shopify.server.ts: -------------------------------------------------------------------------------- 1 | import { createGraphQLClient } from "@shopify/graphql-client"; 2 | import { type JWTPayload, jwtVerify } from "jose"; 3 | import { type AppLoadContext, redirect as routerRedirect } from "react-router"; 4 | import * as v from "valibot"; 5 | 6 | import { API_VERSION, APP_BRIDGE_URL } from "./const"; 7 | 8 | export function createShopify(context: AppLoadContext) { 9 | const env = v.parse(schema, context.cloudflare.env); 10 | const config = { 11 | apiKey: env.SHOPIFY_API_KEY, 12 | apiSecretKey: env.SHOPIFY_API_SECRET_KEY, 13 | apiVersion: API_VERSION, 14 | appHandle: env.SHOPIFY_APP_HANDLE, 15 | appUrl: env.SHOPIFY_APP_URL, 16 | appLogLevel: env.SHOPIFY_APP_LOG_LEVEL, 17 | appTest: env.SHOPIFY_APP_TEST === "1", 18 | }; 19 | 20 | async function admin(request: Request) { 21 | const url = new URL(request.url); 22 | 23 | if (request.method === "OPTIONS") { 24 | const response = new Response(null, { 25 | headers: new Headers({ 26 | "Access-Control-Max-Age": "7200", 27 | }), 28 | status: 204, 29 | }); 30 | utils.addCorsHeaders(request, response.headers); 31 | throw response; 32 | } 33 | 34 | let encodedSessionToken = null; 35 | let decodedSessionToken = null; 36 | try { 37 | encodedSessionToken = 38 | request.headers.get("Authorization")?.replace("Bearer ", "") || 39 | url.searchParams.get("id_token") || 40 | ""; 41 | 42 | const key = config.apiSecretKey; 43 | const hmacKey = new Uint8Array(key.length); 44 | for (let i = 0, keyLen = key.length; i < keyLen; i++) { 45 | hmacKey[i] = key.charCodeAt(i); 46 | } 47 | 48 | const { payload } = await jwtVerify( 49 | encodedSessionToken, 50 | hmacKey, 51 | { 52 | algorithms: ["HS256"], 53 | clockTolerance: 10, 54 | }, 55 | ); 56 | 57 | // The exp and nbf fields are validated by the JWT library 58 | if (payload.aud !== config.apiKey) { 59 | throw new ShopifyException("Session token had invalid API key", { 60 | status: 401, 61 | type: "JWT", 62 | }); 63 | } 64 | decodedSessionToken = payload; 65 | } catch (error) { 66 | utils.log.debug("admin.jwt", { 67 | error, 68 | headers: Object.fromEntries(request.headers), 69 | url, 70 | }); 71 | 72 | const isDocumentRequest = !request.headers.has("Authorization"); 73 | if (isDocumentRequest) { 74 | // Remove `id_token` from the query string to prevent an invalid session token sent to the redirect path. 75 | url.searchParams.delete("id_token"); 76 | 77 | // Using shopify-reload path to redirect the bounce automatically. 78 | url.searchParams.append( 79 | "shopify-reload", 80 | `${config.appUrl}${url.pathname}?${url.searchParams.toString()}`, 81 | ); 82 | throw routerRedirect( 83 | `/shopify/auth/session-token-bounce?${url.searchParams.toString()}`, 84 | ); 85 | } 86 | 87 | const response = new Response(undefined, { 88 | headers: new Headers({ 89 | "X-Shopify-Retry-Invalid-Session-Request": "1", 90 | }), 91 | status: 401, 92 | statusText: "Unauthorized", 93 | }); 94 | utils.addCorsHeaders(request, response.headers); 95 | throw response; 96 | } 97 | 98 | const shop = utils.sanitizeShop(new URL(decodedSessionToken.dest).hostname); 99 | if (!shop) { 100 | throw new ShopifyException("Received invalid shop argument", { 101 | status: 400, 102 | type: "SHOP", 103 | }); 104 | } 105 | 106 | const body = { 107 | client_id: config.apiKey, 108 | client_secret: config.apiSecretKey, 109 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", 110 | subject_token: encodedSessionToken, 111 | subject_token_type: "urn:ietf:params:oauth:token-type:id_token", 112 | requested_token_type: 113 | "urn:shopify:params:oauth:token-type:offline-access-token", 114 | }; 115 | 116 | const response = await fetch(`https://${shop}/admin/oauth/access_token`, { 117 | method: "POST", 118 | body: JSON.stringify(body), 119 | headers: { 120 | "Content-Type": "application/json", 121 | Accept: "application/json", 122 | }, 123 | signal: AbortSignal.timeout(1_000), 124 | }); 125 | if (!response.ok) { 126 | // biome-ignore lint/suspicious/noExplicitAny: upstream 127 | const body: any = await response.json(); 128 | if (typeof response === "undefined") { 129 | const message = body?.errors?.message ?? ""; 130 | throw new ShopifyException( 131 | `Http request error, no response available: ${message}`, 132 | { 133 | status: 400, 134 | type: "REQUEST", 135 | }, 136 | ); 137 | } 138 | 139 | if (response.status === 200 && body.errors.graphQLErrors) { 140 | throw new ShopifyException( 141 | body.errors.graphQLErrors?.[0].message ?? "GraphQL operation failed", 142 | { 143 | status: 400, 144 | type: "GRAPHQL", 145 | }, 146 | ); 147 | } 148 | 149 | const errorMessages: string[] = []; 150 | if (body.errors) { 151 | errorMessages.push(JSON.stringify(body.errors, null, 2)); 152 | } 153 | const xRequestId = response.headers.get("x-request-id"); 154 | if (xRequestId) { 155 | errorMessages.push( 156 | `If you report this error, please include this id: ${xRequestId}`, 157 | ); 158 | } 159 | 160 | const errorMessage = errorMessages.length 161 | ? `:\n${errorMessages.join("\n")}` 162 | : ""; 163 | 164 | switch (true) { 165 | case response.status === 429: { 166 | throw new ShopifyException( 167 | `Shopify is throttling requests ${errorMessage}`, 168 | { 169 | status: response.status, 170 | type: "THROTTLING", 171 | // retryAfter: response.headers.has("Retry-After") ? parseFloat(response.headers.get("Retry-After")) : undefined, 172 | }, 173 | ); 174 | } 175 | case response.status >= 500: 176 | throw new ShopifyException(`Shopify internal error${errorMessage}`, { 177 | status: response.status, 178 | type: "SERVER", 179 | }); 180 | default: 181 | throw new ShopifyException( 182 | `Received an error response (${response.status} ${response.statusText}) from Shopify${errorMessage}`, 183 | { 184 | status: response.status, 185 | type: "RESPONSE", 186 | }, 187 | ); 188 | } 189 | } 190 | 191 | const accessTokenResponse = await response.json<{ 192 | access_token: string; 193 | expires_in?: number; 194 | scope: string; 195 | }>(); 196 | await session.set({ 197 | id: shop, 198 | shop, 199 | scope: accessTokenResponse.scope, 200 | expires: accessTokenResponse.expires_in 201 | ? new Date(Date.now() + accessTokenResponse.expires_in * 1000) 202 | : undefined, 203 | accessToken: accessTokenResponse.access_token, 204 | }); 205 | 206 | const client = createShopifyClient({ 207 | headers: { "X-Shopify-Access-Token": accessTokenResponse.access_token }, 208 | shop, 209 | }); 210 | return client; 211 | } 212 | 213 | async function proxy(request: Request) { 214 | const url = new URL(request.url); 215 | 216 | const param = url.searchParams.get("signature"); 217 | if (param === null) { 218 | throw new ShopifyException("Proxy param is missing", { 219 | status: 400, 220 | type: "REQUEST", 221 | }); 222 | } 223 | 224 | const timestamp = Number(url.searchParams.get("timestamp")); 225 | if ( 226 | Math.abs(Math.trunc(Date.now() / 1000) - timestamp) > 90 // HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC 227 | ) { 228 | throw new ShopifyException("Proxy timestamp is expired", { 229 | status: 400, 230 | type: "REQUEST", 231 | }); 232 | } 233 | 234 | // NOTE: https://shopify.dev/docs/apps/build/online-store/display-dynamic-data#calculate-a-digital-signature 235 | const params = Object.entries(Object.fromEntries(url.searchParams)) 236 | .filter(([key]) => key !== "signature") 237 | .map( 238 | ([key, value]) => 239 | `${key}=${Array.isArray(value) ? value.join(",") : value}`, 240 | ) 241 | .sort((a, b) => a.localeCompare(b)) 242 | .join(""); 243 | 244 | await utils.validateHmac(params, param, "hex"); 245 | 246 | const shop = utils.sanitizeShop(url.searchParams.get("shop")!)!; // shop is value due to hmac validation 247 | const shopify = await session.get(shop); 248 | if (!shopify?.accessToken) { 249 | throw new ShopifyException("No session access token", { 250 | status: 401, 251 | type: "SESSION", 252 | }); 253 | } 254 | 255 | const client = createShopifyClient({ 256 | headers: { "X-Shopify-Access-Token": shopify.accessToken }, 257 | shop, 258 | }); 259 | 260 | return client; 261 | } 262 | 263 | function redirect( 264 | request: Request, 265 | url: string, 266 | { 267 | shop, 268 | target, 269 | ...init 270 | }: ResponseInit & { 271 | shop: string; 272 | target?: "_self" | "_parent" | "_top" | "_blank"; 273 | }, 274 | ) { 275 | const headers = new Headers({ 276 | "content-type": "text/html;charset=utf-8", 277 | ...init.headers, 278 | }); 279 | 280 | let windowTarget = target ?? "_self"; 281 | let windowUrl = new URL(url, config.appUrl); 282 | 283 | const isSameOrigin = config.appUrl === windowUrl.origin; 284 | const isRelativePath = url.startsWith("/"); 285 | if (isSameOrigin || isRelativePath) { 286 | for (const [key, value] of new URL(request.url).searchParams.entries()) { 287 | if (!windowUrl.searchParams.has(key)) { 288 | windowUrl.searchParams.set(key, value); 289 | } 290 | } 291 | } 292 | 293 | const adminLinkRegExp = /^shopify:\/*admin\//i; 294 | const isAdminLink = adminLinkRegExp.test(url); 295 | if (isAdminLink) { 296 | const shopHandle = shop.replace(".myshopify.com", ""); 297 | const adminUri = url.replace(adminLinkRegExp, "/"); 298 | windowUrl = new URL( 299 | `https://admin.shopify.com/store/${shopHandle}${adminUri}`, 300 | ); 301 | 302 | const remove = [ 303 | "appLoadId", // sent when clicking rel="home" nav item 304 | "hmac", 305 | "host", 306 | "embedded", 307 | "id_token", 308 | "locale", 309 | "protocol", 310 | "session", 311 | "shop", 312 | "timestamp", 313 | ]; 314 | for (const param of remove) { 315 | if (windowUrl.searchParams.has(param)) { 316 | windowUrl.searchParams.delete(param); 317 | } 318 | } 319 | 320 | if (!target) { 321 | windowTarget = "_parent"; 322 | } 323 | } 324 | 325 | switch (true) { 326 | case target === "_self" && isBounce(request): 327 | case target !== "_self" && isEmbedded(request): { 328 | const response = new Response( 329 | /* html */ ` 330 | 331 | 332 | 338 | `, 339 | { 340 | ...init, 341 | headers, 342 | }, 343 | ); 344 | utils.addCorsHeaders(request, response.headers); 345 | throw response; 346 | } 347 | 348 | case isData(request): { 349 | const response = new Response(undefined, { 350 | headers: new Headers({ 351 | "X-Shopify-API-Request-Failure-Reauthorize-Url": 352 | windowUrl.toString(), 353 | }), 354 | status: 401, 355 | statusText: "Unauthorized", 356 | }); 357 | utils.addCorsHeaders(request, response.headers); 358 | throw response; 359 | } 360 | 361 | default: { 362 | throw routerRedirect(url, init); 363 | } 364 | } 365 | 366 | function authorizationHeader(request: Request) { 367 | return request.headers.get("authorization")?.replace(/Bearer\s?/, ""); 368 | } 369 | 370 | function isBounce(request: Request) { 371 | return ( 372 | !!authorizationHeader(request) && 373 | request.headers.has("X-Shopify-Bounce") 374 | ); 375 | } 376 | 377 | function isData(request: Request) { 378 | return ( 379 | !!authorizationHeader(request) && 380 | !isBounce(request) && 381 | (!isEmbedded(request) || request.method !== "GET") 382 | ); 383 | } 384 | 385 | function isEmbedded(request: Request) { 386 | return new URL(request.url).searchParams.get("embedded") === "1"; 387 | } 388 | } 389 | 390 | const session = new ShopifySession(context.cloudflare.env.SESSION_STORAGE); 391 | 392 | const utils = { 393 | addCorsHeaders(request: Request, responseHeaders: Headers) { 394 | const origin = request.headers.get("Origin"); 395 | if (origin && origin !== config.appUrl) { 396 | if (!responseHeaders.has("Access-Control-Allow-Headers")) { 397 | responseHeaders.set("Access-Control-Allow-Headers", "Authorization"); 398 | } 399 | if (!responseHeaders.has("Access-Control-Allow-Origin")) { 400 | responseHeaders.set("Access-Control-Allow-Origin", origin); 401 | } 402 | if (responseHeaders.get("Access-Control-Allow-Origin") !== "*") { 403 | responseHeaders.set("Vary", "Origin"); 404 | } 405 | if (!responseHeaders.has("Access-Control-Expose-Headers")) { 406 | responseHeaders.set( 407 | "Access-Control-Expose-Headers", 408 | "X-Shopify-API-Request-Failure-Reauthorize-Url", 409 | ); 410 | } 411 | } 412 | }, 413 | 414 | addHeaders(request: Request, responseHeaders: Headers) { 415 | const url = new URL(request.url); 416 | const shop = utils.sanitizeShop(url.searchParams.get("shop")!); 417 | if (shop && !url.pathname.startsWith("/apps")) { 418 | responseHeaders.set( 419 | "Link", 420 | `<${APP_BRIDGE_URL}>; rel="preload"; as="script";`, 421 | ); 422 | } 423 | }, 424 | 425 | allowedDomains: ["myshopify.com", "myshopify.io", "shop.dev", "shopify.com"] 426 | .map((v) => v.replace(/\./g, "\\.")) // escape 427 | .join("|"), 428 | 429 | encode(value: ArrayBuffer, encoding: "base64" | "hex") { 430 | switch (encoding) { 431 | case "base64": 432 | return btoa(String.fromCharCode(...new Uint8Array(value))); 433 | 434 | case "hex": 435 | return [...new Uint8Array(value)].reduce( 436 | (a, b) => a + b.toString(16).padStart(2, "0"), 437 | "", 438 | ); 439 | } 440 | }, 441 | 442 | legacyUrlToShopAdminUrl(shop: string) { 443 | const shopUrl = shop.replace(/^https?:\/\//, "").replace(/\/$/, ""); 444 | const regExp = /(.+)\.myshopify\.com$/; 445 | 446 | const matches = shopUrl.match(regExp); 447 | if (matches && matches.length === 2) { 448 | const shopName = matches[1]; 449 | return `admin.shopify.com/store/${shopName}`; 450 | } 451 | return null; 452 | }, 453 | 454 | log: createLogger(config.appLogLevel), 455 | 456 | sanitizeHost(host: string) { 457 | const base64RegExp = /^[0-9a-z+/]+={0,2}$/i; 458 | let sanitizedHost = base64RegExp.test(host) ? host : null; 459 | if (sanitizedHost) { 460 | const { hostname } = new URL(`https://${atob(sanitizedHost)}`); 461 | 462 | const hostRegExp = new RegExp(`\\.(${utils.allowedDomains})$`); 463 | if (!hostRegExp.test(hostname)) { 464 | sanitizedHost = null; 465 | } 466 | } 467 | return sanitizedHost; 468 | }, 469 | 470 | sanitizeShop(shop: string) { 471 | let sanitizedShop = shop; 472 | 473 | const shopAdminRegExp = new RegExp( 474 | `^admin\\.(${utils.allowedDomains})/store/([a-zA-Z0-9][a-zA-Z0-9-_]*)$`, 475 | ); 476 | if (shopAdminRegExp.test(shop)) { 477 | sanitizedShop = shop.replace(/^https?:\/\//, "").replace(/\/$/, ""); 478 | if (sanitizedShop.split(".").at(0) !== "admin") { 479 | return null; 480 | } 481 | 482 | const regex = /admin\..+\/store\/([^\/]+)/; 483 | const matches = sanitizedShop.match(regex); 484 | if (matches && matches.length === 2) { 485 | sanitizedShop = `${matches.at(1)}.myshopify.com`; 486 | } else { 487 | return null; 488 | } 489 | } 490 | 491 | const shopRegExp = new RegExp( 492 | `^[a-zA-Z0-9][a-zA-Z0-9-_]*\\.(${utils.allowedDomains})[/]*$`, 493 | ); 494 | if (!shopRegExp.test(sanitizedShop)) return null; 495 | 496 | return sanitizedShop; 497 | }, 498 | 499 | async validateHmac(data: string, hmac: string, encoding: "hex" | "base64") { 500 | const encoder = new TextEncoder(); 501 | const key = await crypto.subtle.importKey( 502 | "raw", 503 | encoder.encode(env.SHOPIFY_API_SECRET_KEY), 504 | { 505 | name: "HMAC", 506 | hash: "SHA-256", 507 | }, 508 | false, 509 | ["sign"], 510 | ); 511 | const signature = await crypto.subtle.sign( 512 | "HMAC", 513 | key, 514 | encoder.encode(data), 515 | ); 516 | 517 | const computed = utils.encode(signature, encoding); 518 | const bufA = encoder.encode(computed); 519 | const bufB = encoder.encode(hmac); 520 | if (bufA.byteLength !== bufB.byteLength) { 521 | throw new ShopifyException("Encoded byte length mismatch", { 522 | status: 401, 523 | type: "HMAC", 524 | }); 525 | } 526 | 527 | // biome-ignore lint/suspicious/noExplicitAny: lib: [DOM] overrides worker-configuration.d.ts 528 | const valid = (crypto.subtle as any).timingSafeEqual(bufA, bufB); 529 | utils.log.debug("validateHmac", { 530 | hmac, 531 | computed, 532 | valid, 533 | }); 534 | if (!valid) { 535 | throw new ShopifyException("Invalid hmac", { 536 | status: 401, 537 | type: "HMAC", 538 | }); 539 | } 540 | }, 541 | }; 542 | 543 | async function webhook(request: Request) { 544 | // validate.body 545 | const body = await request.clone().text(); 546 | if (body.length === 0) { 547 | throw new ShopifyException("Webhook body is missing", { 548 | status: 400, 549 | type: "REQUEST", 550 | }); 551 | } 552 | 553 | // validate.hmac 554 | const header = request.headers.get("X-Shopify-Hmac-Sha256"); 555 | if (header === null) { 556 | throw new ShopifyException("Webhook header is missing", { 557 | status: 400, 558 | type: "REQUEST", 559 | }); 560 | } 561 | 562 | await utils.validateHmac(body, header, "base64"); 563 | 564 | // validate.headers 565 | const requiredHeaders = { 566 | apiVersion: "X-Shopify-API-Version", 567 | domain: "X-Shopify-Shop-Domain", 568 | hmac: "X-Shopify-Hmac-Sha256", 569 | topic: "X-Shopify-Topic", 570 | webhookId: "X-Shopify-Webhook-Id", 571 | }; 572 | if ( 573 | !Object.values(requiredHeaders).every((header) => 574 | request.headers.has(header), 575 | ) 576 | ) { 577 | throw new ShopifyException("Webhook headers are missing", { 578 | status: 400, 579 | type: "REQUEST", 580 | }); 581 | } 582 | const optionalHeaders = { subTopic: "X-Shopify-Sub-Topic" }; 583 | const headers = { ...requiredHeaders, ...optionalHeaders }; 584 | const webhook = Object.entries(headers).reduce( 585 | (headers, [key, value]) => ({ 586 | // biome-ignore lint/performance/noAccumulatingSpread: upstream 587 | ...headers, 588 | [key]: request.headers.get(value), 589 | }), 590 | {} as typeof headers, 591 | ); 592 | return webhook; 593 | } 594 | 595 | return { 596 | admin, 597 | config, 598 | proxy, 599 | redirect, 600 | session, 601 | utils, 602 | webhook, 603 | }; 604 | } 605 | 606 | export function createShopifyClient({ 607 | apiVersion = API_VERSION, 608 | headers, 609 | shop, 610 | }: { 611 | apiVersion?: string; 612 | headers: Record; 613 | shop: string; 614 | }) { 615 | const admin = "X-Shopify-Access-Token"; 616 | const storefront = "X-Shopify-Storefront-Access-Token"; 617 | if (!headers[admin] && !headers[storefront]) { 618 | throw new ShopifyException( 619 | `Missing auth header [${admin}, ${storefront}]`, 620 | { 621 | status: 401, 622 | type: "REQUEST", 623 | }, 624 | ); 625 | } 626 | 627 | const url = headers[storefront] 628 | ? `https://${shop}/api/${apiVersion}/graphql.json` 629 | : `https://${shop}/admin/api/${apiVersion}/graphql.json`; 630 | const client = createGraphQLClient({ 631 | customFetchApi: fetch, 632 | headers: { 633 | "Content-Type": "application/json", 634 | ...headers, 635 | }, 636 | url, 637 | }); 638 | return client; 639 | } 640 | 641 | const Log = { 642 | error: 0, 643 | info: 1, 644 | debug: 2, 645 | }; 646 | type LogLevel = keyof typeof Log; 647 | 648 | function createLogger(level: LogLevel) { 649 | function noop() {} 650 | 651 | return { 652 | debug(...args: unknown[]) { 653 | if (Log[level] >= Log.debug) { 654 | return console.debug("log.debug", ...args); 655 | } 656 | return noop; 657 | }, 658 | 659 | info(...args: unknown[]) { 660 | if (Log[level] >= Log.info) { 661 | return console.info("log.info", ...args); 662 | } 663 | return noop; 664 | }, 665 | 666 | error(...args: unknown[]) { 667 | if (Log[level] >= Log.error) { 668 | return console.error("log.error", ...args); 669 | } 670 | return noop; 671 | }, 672 | }; 673 | } 674 | 675 | const schema = v.object({ 676 | SHOPIFY_API_KEY: v.pipe(v.string(), v.minLength(32)), 677 | SHOPIFY_API_SECRET_KEY: v.pipe(v.string(), v.minLength(32)), 678 | SHOPIFY_APP_HANDLE: v.string(), 679 | SHOPIFY_APP_LOG_LEVEL: v.optional( 680 | v.picklist(["debug", "info", "error"]), 681 | "error", 682 | ), 683 | SHOPIFY_APP_TEST: v.optional(v.picklist(["0", "1"]), "0"), 684 | SHOPIFY_APP_URL: v.pipe(v.string(), v.url()), 685 | }); 686 | 687 | export class ShopifyException extends Error { 688 | errors?: unknown[]; 689 | status = 500; 690 | type: 691 | | "GRAPHQL" 692 | | "HMAC" 693 | | "JWT" 694 | | "REQUEST" 695 | | "RESPONSE" 696 | | "SESSION" 697 | | "SERVER" 698 | | "SHOP" 699 | | "THROTTLING" = "SERVER"; 700 | 701 | constructor( 702 | message: string, 703 | options: ErrorOptions & { 704 | errors?: unknown[]; 705 | status: number; 706 | type: string; 707 | }, 708 | ) { 709 | super(message); 710 | 711 | Object.setPrototypeOf(this, new.target.prototype); 712 | Object.assign(this, { 713 | name: this.constructor.name, 714 | errors: [], 715 | ...(options ?? {}), 716 | }); 717 | } 718 | } 719 | 720 | interface ShopifyJWTPayload extends Required { 721 | dest: string; 722 | } 723 | 724 | export class ShopifySession { 725 | #namespace: KVNamespace; 726 | #properties = ["accessToken", "expires", "id", "scope", "shop"]; 727 | 728 | constructor(namespace: KVNamespace) { 729 | this.#namespace = namespace; 730 | } 731 | 732 | async clear(shop: string) { 733 | const shops = await this.#namespace.list({ prefix: shop }); 734 | await Promise.all( 735 | shops.keys.map((key) => this.#namespace.delete(key.name)), 736 | ); 737 | } 738 | 739 | async delete(id: string | undefined) { 740 | if (!id) return false; 741 | 742 | const session = await this.get(id); 743 | if (!session) return false; 744 | 745 | await this.#namespace.delete(id); 746 | return true; 747 | } 748 | 749 | deserialize(data: ShopifySessionSerialized): ShopifySessionObject { 750 | const obj = Object.fromEntries( 751 | data 752 | .filter(([_key, value]) => value !== null && value !== undefined) 753 | .map(([key, value]) => { 754 | switch (key.toLowerCase()) { 755 | case "accesstoken": 756 | return ["accessToken", value]; 757 | default: 758 | return [key.toLowerCase(), value]; 759 | } 760 | }), 761 | ); 762 | 763 | return Object.entries(obj).reduce((session, [key, value]) => { 764 | switch (key) { 765 | case "scope": 766 | session[key] = value.toString(); 767 | break; 768 | case "expires": 769 | session[key] = value ? new Date(Number(value)) : undefined; 770 | break; 771 | default: 772 | // biome-ignore lint/suspicious/noExplicitAny: upstream 773 | (session as any)[key] = value; 774 | break; 775 | } 776 | return session; 777 | }, {} as ShopifySessionObject); 778 | } 779 | 780 | async get(id: string | undefined) { 781 | if (!id) return; 782 | 783 | const data = await this.#namespace.get<[string, string | number][]>( 784 | id, 785 | "json", 786 | ); 787 | return data ? this.deserialize(data) : undefined; 788 | } 789 | 790 | async set(session: ShopifySessionObject) { 791 | return this.#namespace.put( 792 | session.id, 793 | JSON.stringify(this.serialize(session)), 794 | { metadata: { shop: session.shop } }, 795 | ); 796 | } 797 | 798 | serialize(session: ShopifySessionObject): ShopifySessionSerialized { 799 | return Object.entries(session) 800 | .filter( 801 | ([key, value]) => 802 | this.#properties.includes(key) && 803 | value !== undefined && 804 | value !== null, 805 | ) 806 | .flatMap(([key, value]): [string, string | number | boolean][] => { 807 | switch (key) { 808 | case "expires": 809 | return [[key, value ? value.getTime() : undefined]]; 810 | default: 811 | return [[key, value]]; 812 | } 813 | }) 814 | .filter(([_key, value]) => value !== undefined); 815 | } 816 | } 817 | interface ShopifySessionObject { 818 | id: string; 819 | shop: string; 820 | scope: string; 821 | expires?: Date; 822 | accessToken: string; 823 | } 824 | type ShopifySessionSerialized = [string, string | number | boolean][]; 825 | -------------------------------------------------------------------------------- /app/types/admin.generated.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable eslint-comments/no-unlimited-disable */ 3 | /* eslint-disable */ 4 | import type * as AdminTypes from './admin.types'; 5 | 6 | export type ShopQueryVariables = AdminTypes.Exact<{ [key: string]: never; }>; 7 | 8 | 9 | export type ShopQuery = { shop: Pick }; 10 | 11 | interface GeneratedQueryTypes { 12 | "\n\t\t\t#graphql\n\t\t\tquery Shop {\n\t\t\t\tshop {\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t}\n\t\t": {return: ShopQuery, variables: ShopQueryVariables}, 13 | "\n\t\t\t\t\t#graphql\n\t\t\t\t\tquery Shop {\n\t\t\t\t\t\tshop {\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t": {return: ShopQuery, variables: ShopQueryVariables}, 14 | } 15 | 16 | interface GeneratedMutationTypes { 17 | } 18 | declare module '@shopify/admin-api-client' { 19 | type InputMaybe = AdminTypes.InputMaybe; 20 | interface AdminQueries extends GeneratedQueryTypes {} 21 | interface AdminMutations extends GeneratedMutationTypes {} 22 | } 23 | -------------------------------------------------------------------------------- /app/types/app.ts: -------------------------------------------------------------------------------- 1 | export interface WebhookQueueMessage { 2 | payload: unknown; 3 | webhook: { 4 | subTopic: string; 5 | apiVersion: string; 6 | domain: string; 7 | hmac: string; 8 | topic: string; 9 | webhookId: string; 10 | } 11 | } -------------------------------------------------------------------------------- /app/types/app.types.d.ts: -------------------------------------------------------------------------------- 1 | import type { FunctionComponent, SVGAttributes } from "react"; 2 | 3 | declare module "*.css" { 4 | const content: string; 5 | export default content; 6 | } 7 | 8 | declare module "*.json" { 9 | const content: string; 10 | export default content; 11 | } 12 | 13 | declare module "*.svg" { 14 | const content: FunctionComponent>; 15 | export default content; 16 | } 17 | -------------------------------------------------------------------------------- /bin.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | shopt -s nullglob 5 | 6 | source .env 7 | 8 | function addGitHook() { 9 | printf '%s\n' \ 10 | "#!/usr/bin/env sh" \ 11 | "set -eu" \ 12 | "" \ 13 | "npx @biomejs/biome check --staged --files-ignore-unknown=true --no-errors-on-unmatched" \ 14 | "npx tsc --noEmit" \ 15 | > .git/hooks/pre-commit 16 | chmod +x .git/hooks/pre-commit 17 | } 18 | 19 | function help() { 20 | echo "npx shopflare [addGitHook,triggerWebhook,triggerWorkflow,update,version]" 21 | } 22 | 23 | function triggerWebhook() { 24 | topic=${1:-'app/uninstalled'} 25 | npx shopify app webhook trigger \ 26 | --address=http://localhost:8080/shopify/webhooks \ 27 | --api-version=2025-04 \ 28 | --client-secret=${SHOPIFY_API_SECRET_KEY} \ 29 | --delivery-method=http \ 30 | --topic=${topic} 31 | } 32 | 33 | function triggerWorkflow() { 34 | workflow=${1:-github} 35 | act \ 36 | --action-offline-mode \ 37 | --container-architecture=linux/amd64 \ 38 | --eventpath=.github/act/event.${workflow}.json \ 39 | --remote-name=github \ 40 | --workflows=.github/workflows/${workflow}.yml 41 | } 42 | 43 | function update() { 44 | if [[ $(git status --porcelain) ]]; then 45 | echo "ERROR: Please commit or stash your changes first" 46 | exit 1 47 | fi 48 | 49 | curl \ 50 | --location \ 51 | --silent https://api.github.com/repos/chr33s/shopflare/tarball \ 52 | | tar \ 53 | --directory=. \ 54 | --exclude={.dev.vars,.github/act,.gitignore,extensions,public,LICENSE.md,package-lock.json,README.md,SECURITY.md} \ 55 | --extract \ 56 | --strip-components=1 \ 57 | --gzip 58 | 59 | npm install 60 | npm run typegen 61 | } 62 | 63 | function version() { 64 | echo ${npm_package_version} 65 | } 66 | 67 | ${@:-help} -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "css": { 4 | "linter": { 5 | "enabled": true 6 | } 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [ 11 | "**/+types/*", 12 | "**/build/*", 13 | "**/dist/*", 14 | "**/generated/*", 15 | "**/node_modules/*", 16 | "**/types/*", 17 | ".react-router/*", 18 | ".shopify/*", 19 | ".wrangler/*", 20 | "worker-configuration.d.ts" 21 | ] 22 | }, 23 | "formatter": { 24 | "enabled": true, 25 | "ignore": ["package.json"], 26 | "useEditorconfig": true, 27 | "formatWithErrors": false, 28 | "bracketSpacing": true 29 | }, 30 | "javascript": { 31 | "formatter": { 32 | "jsxQuoteStyle": "double", 33 | "quoteProperties": "asNeeded", 34 | "trailingCommas": "all", 35 | "semicolons": "always", 36 | "arrowParentheses": "always", 37 | "bracketSameLine": false, 38 | "quoteStyle": "double", 39 | "attributePosition": "auto", 40 | "bracketSpacing": true 41 | } 42 | }, 43 | "linter": { 44 | "enabled": true, 45 | "rules": { 46 | "recommended": true, 47 | "style": { 48 | "noNonNullAssertion": "off" 49 | } 50 | } 51 | }, 52 | "organizeImports": { 53 | "enabled": true 54 | }, 55 | "vcs": { 56 | "enabled": true, 57 | "clientKind": "git", 58 | "useIgnoreFile": true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | "**/types", 4 | "**/dist", 5 | "**/node_modules", 6 | "build", 7 | "patches", 8 | "worker-configuration.d.ts" 9 | ], 10 | "ignoreWords": [ 11 | "accesstoken", 12 | "autoupdate", 13 | "biomejs", 14 | "cloudflared", 15 | "codegen", 16 | "evenodd", 17 | "eventpath", 18 | "HSBA", 19 | "isbot", 20 | "logpush", 21 | "miniflare", 22 | "myshopify", 23 | "noopen", 24 | "nosniff", 25 | "nullglob", 26 | "picklist", 27 | "shopflare", 28 | "supportedLngs", 29 | "savebar", 30 | "typegen", 31 | "upsteam", 32 | "valibot", 33 | "workerd", 34 | "xlink" 35 | ], 36 | "language": "en", 37 | "languageSettings": [ 38 | { 39 | "languageId": "bash,css,html,markdown,node,typescript", 40 | "locale": "en" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /extensions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/extensions/.gitkeep -------------------------------------------------------------------------------- /graphql.config.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { ApiType, shopifyApiProject } from "@shopify/api-codegen-preset"; 3 | import type { IGraphQLProject, IGraphQLProjects } from "graphql-config"; 4 | 5 | type Config = IGraphQLProject & IGraphQLProjects; 6 | 7 | import { API_VERSION } from "./app/const"; 8 | 9 | function getConfig() { 10 | const config: Config = { 11 | projects: { 12 | default: shopifyApiProject({ 13 | apiType: ApiType.Admin, 14 | apiVersion: API_VERSION, 15 | documents: ["./app/**/*.{ts,tsx}"], 16 | outputDir: "./app/types", 17 | }), 18 | }, 19 | schema: `https://shopify.dev/admin-graphql-direct-proxy/${API_VERSION}`, 20 | }; 21 | 22 | let extensions: string[] = []; 23 | try { 24 | extensions = fs.readdirSync("./extensions"); 25 | } catch { 26 | // ignore if no extensions 27 | } 28 | 29 | for (const entry of extensions) { 30 | const extensionPath = `./extensions/${entry}`; 31 | const schema = `${extensionPath}/schema.graphql`; 32 | if (!fs.existsSync(schema)) { 33 | continue; 34 | } 35 | config.projects[entry] = { 36 | schema, 37 | documents: [`${extensionPath}/**/*.graphql`], 38 | }; 39 | } 40 | 41 | return config; 42 | } 43 | 44 | export default getConfig(); 45 | -------------------------------------------------------------------------------- /i18n.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from "vite-plugin-i18next-loader"; 2 | 3 | export default { 4 | include: ["**/*.json"], 5 | logLevel: "warn", 6 | namespaceResolution: "basename", 7 | paths: ["./app/i18n"], 8 | } satisfies Options; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@chr33s/shopflare", 3 | "version": "2.9.7", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "shopify app build", 8 | "check": "concurrently 'npm:check:*'", 9 | "check:actions": "if command -v actionlint 2>&1 >/dev/null; then actionlint; fi", 10 | "check:code": "biome check --write", 11 | "check:spell": "if command -v cspell 2>&1 >/dev/null; then cspell --gitignore --quiet .; fi", 12 | "check:types": "tsc", 13 | "clean": "rm -rf .{react-router,shopify,wrangler} build node_modules/.{cache,mf,tmp,vite,vite-temp} && find . -type d -name __screenshots__ -exec rm -rf {} \\;", 14 | "deploy": "concurrently 'npm:deploy:*'", 15 | "deploy:cloudflare": "wrangler deploy", 16 | "deploy:shopify": "shopify app deploy --message=$(git rev-parse --abbrev-ref HEAD):$npm_package_version --version=$(git rev-parse HEAD)", 17 | "dev": "shopify app dev --localhost-port=8080 --use-localhost", 18 | "dev:tunnel": "source .env && shopify app dev --tunnel-url=${SHOPIFY_APP_URL}:${PORT:-8080}", 19 | "gen": "concurrently 'npm:gen:*'", 20 | "gen:code": "graphql-codegen --errors-only", 21 | "gen:types": "wrangler types && react-router typegen", 22 | "postinstall": "patch-package", 23 | "prepare": "[[ $NODE_ENV = 'production' ]] && exit 0; cp node_modules/@shopify/polaris/locales/en.json ./app/i18n/en/polaris.json && biome check --write ./app/i18n/en/polaris.json", 24 | "start": "wrangler dev", 25 | "test": "vitest --run", 26 | "test:e2e": "node --env-file=.env --env-file=.env.test $(npm root)/.bin/playwright test", 27 | "tunnel": "source .env && cloudflared tunnel --no-autoupdate run --token=${CLOUDFLARE_API_TOKEN}" 28 | }, 29 | "dependencies": { 30 | "@shopify/graphql-client": "1.3.2", 31 | "i18next": "25.2.1", 32 | "isbot": "5.1.28", 33 | "jose": "6.0.11", 34 | "patch-package": "8.0.0", 35 | "react": "19.1.0", 36 | "react-dom": "19.1.0", 37 | "react-i18next": "15.5.2", 38 | "react-router": "7.6.1", 39 | "valibot": "1.1.0" 40 | }, 41 | "devDependencies": { 42 | "@biomejs/biome": "1.9.4", 43 | "@cloudflare/vite-plugin": "1.3.1", 44 | "@cloudflare/vitest-pool-workers": "0.8.34", 45 | "@playwright/test": "1.52.0", 46 | "@react-router/dev": "7.6.1", 47 | "@shopify/api-codegen-preset": "1.1.7", 48 | "@shopify/app-bridge-react": "4.1.10", 49 | "@shopify/app-bridge-types": "0.0.18", 50 | "@shopify/polaris": "13.9.5", 51 | "@shopify/polaris-icons": "9.3.1", 52 | "@types/react": "19.1.6", 53 | "@types/react-dom": "19.1.5", 54 | "@vitest/browser": "3.1.4", 55 | "concurrently": "9.1.2", 56 | "happy-dom": "17.5.6", 57 | "playwright": "1.52.0", 58 | "typescript": "5.8.3", 59 | "vite": "6.3.5", 60 | "vite-plugin-i18next-loader": "3.1.2", 61 | "vite-tsconfig-paths": "5.1.4", 62 | "vitest": "3.1.4", 63 | "vitest-browser-react": "0.2.0", 64 | "wrangler": "4.18.0" 65 | }, 66 | "optionalDependencies": { 67 | "@shopify/cli": "3.80.7" 68 | }, 69 | "engines": { 70 | "node": "^22.14.0", 71 | "npm": ">=9.6.4" 72 | }, 73 | "bin": { 74 | "shopflare": "./bin.sh" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /patches/@react-router+dev+7.6.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@react-router/dev/dist/vite.js b/node_modules/@react-router/dev/dist/vite.js 2 | index c327ce5..8537591 100644 3 | --- a/node_modules/@react-router/dev/dist/vite.js 4 | +++ b/node_modules/@react-router/dev/dist/vite.js 5 | @@ -3319,7 +3319,7 @@ var reactRouterVitePlugin = () => { 6 | if (ctx.reactRouterConfig.future.unstable_viteEnvironmentApi) { 7 | viteDevServer.middlewares.use(async (req, res, next) => { 8 | let [reqPathname, reqSearch] = (req.url ?? "").split("?"); 9 | - if (reqPathname === `${ctx.publicPath}@react-router/critical.css`) { 10 | + if (reqPathname.endsWith("/@react-router/critical.css")) { 11 | let pathname = new URLSearchParams(reqSearch).get("pathname"); 12 | if (!pathname) { 13 | return next("No pathname provided"); 14 | -------------------------------------------------------------------------------- /patches/@shopify+polaris+13.9.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js b/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js 2 | index c4fbe9c..f77b8f6 100644 3 | --- a/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js 4 | +++ b/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js 5 | @@ -27,7 +27,6 @@ function ChoiceList({ 6 | const ControlComponent = allowMultiple ? Checkbox.Checkbox : RadioButton.RadioButton; 7 | const uniqName = React.useId(); 8 | const name = nameProp ?? uniqName; 9 | - const finalName = allowMultiple ? `${name}[]` : name; 10 | const titleMarkup = title ? /*#__PURE__*/React.createElement(Box.Box, { 11 | as: "legend", 12 | paddingBlockEnd: { 13 | @@ -71,7 +70,7 @@ function ChoiceList({ 14 | xs: '0' 15 | } 16 | }, /*#__PURE__*/React.createElement(ControlComponent, { 17 | - name: finalName, 18 | + name: name, 19 | value: value, 20 | id: id, 21 | label: label, 22 | @@ -83,7 +82,7 @@ function ChoiceList({ 23 | checked: choiceIsSelected(choice, selected), 24 | helpText: helpText, 25 | onChange: handleChange, 26 | - ariaDescribedBy: error && describedByError ? InlineError.errorTextID(finalName) : null, 27 | + ariaDescribedBy: error && describedByError ? InlineError.errorTextID(name) : null, 28 | tone: tone 29 | }), children)); 30 | }); 31 | @@ -95,7 +94,7 @@ function ChoiceList({ 32 | paddingBlockEnd: "200" 33 | }, /*#__PURE__*/React.createElement(InlineError.InlineError, { 34 | message: error, 35 | - fieldID: finalName 36 | + fieldID: name 37 | })); 38 | return /*#__PURE__*/React.createElement(BlockStack.BlockStack, { 39 | as: "fieldset", 40 | @@ -104,7 +103,7 @@ function ChoiceList({ 41 | md: '0' 42 | }, 43 | "aria-invalid": error != null, 44 | - id: finalName 45 | + id: name 46 | }, titleMarkup, /*#__PURE__*/React.createElement(BlockStack.BlockStack, { 47 | as: "ul", 48 | gap: { 49 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js b/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js 50 | index 690afda..62165b5 100644 51 | --- a/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js 52 | +++ b/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js 53 | @@ -29,6 +29,7 @@ const DropZone = function DropZone({ 54 | label, 55 | labelAction, 56 | labelHidden, 57 | + name, 58 | children, 59 | disabled = false, 60 | outline = true, 61 | @@ -120,7 +121,6 @@ const DropZone = function DropZone({ 62 | onDropAccepted && acceptedFiles.length && onDropAccepted(acceptedFiles); 63 | onDropRejected && rejectedFiles.length && onDropRejected(rejectedFiles); 64 | if (!(event.target && 'value' in event.target)) return; 65 | - event.target.value = ''; 66 | }, [disabled, getValidatedFiles, onDrop, onDropAccepted, onDropRejected]); 67 | const handleDragEnter = React.useCallback(event => { 68 | stopEvent(event); 69 | @@ -244,6 +244,7 @@ const DropZone = function DropZone({ 70 | accept: accept, 71 | disabled: disabled, 72 | multiple: allowMultiple, 73 | + name: name, 74 | onChange: handleDrop, 75 | onFocus: handleFocus, 76 | onBlur: handleBlur, 77 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js b/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js 78 | index 628281e..1ff050e 100644 79 | --- a/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js 80 | +++ b/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js 81 | @@ -11,6 +11,7 @@ function Link({ 82 | children, 83 | onClick, 84 | external, 85 | + rel, 86 | target, 87 | id, 88 | monochrome, 89 | @@ -25,6 +26,7 @@ function Link({ 90 | onClick: onClick, 91 | className: className, 92 | url: url, 93 | + rel: rel, 94 | external: external, 95 | target: target, 96 | id: id, 97 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js b/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js 98 | index 15c1002..331ba82 100644 99 | --- a/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js 100 | +++ b/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js 101 | @@ -32,12 +32,12 @@ const UnstyledLink = /*#__PURE__*/React.memo(/*#__PURE__*/React.forwardRef(funct 102 | } else { 103 | target = targetProp ?? undefined; 104 | } 105 | - const rel = target === '_blank' ? 'noopener noreferrer' : undefined; 106 | + const rel = props.ref ?? target === '_blank' ? 'noopener noreferrer' : undefined; 107 | return /*#__PURE__*/React.createElement("a", Object.assign({ 108 | - target: target 109 | - }, rest, { 110 | - href: url, 111 | + target: target, 112 | rel: rel 113 | + }, rest, { 114 | + href: url 115 | }, shared.unstyled.props, { 116 | ref: _ref 117 | })); 118 | diff --git a/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js b/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js 119 | index 2974ab6..b0e72bf 100644 120 | --- a/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js 121 | +++ b/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js 122 | @@ -25,7 +25,6 @@ function ChoiceList({ 123 | const ControlComponent = allowMultiple ? Checkbox : RadioButton; 124 | const uniqName = useId(); 125 | const name = nameProp ?? uniqName; 126 | - const finalName = allowMultiple ? `${name}[]` : name; 127 | const titleMarkup = title ? /*#__PURE__*/React.createElement(Box, { 128 | as: "legend", 129 | paddingBlockEnd: { 130 | @@ -69,7 +68,7 @@ function ChoiceList({ 131 | xs: '0' 132 | } 133 | }, /*#__PURE__*/React.createElement(ControlComponent, { 134 | - name: finalName, 135 | + name: name, 136 | value: value, 137 | id: id, 138 | label: label, 139 | @@ -81,7 +80,7 @@ function ChoiceList({ 140 | checked: choiceIsSelected(choice, selected), 141 | helpText: helpText, 142 | onChange: handleChange, 143 | - ariaDescribedBy: error && describedByError ? errorTextID(finalName) : null, 144 | + ariaDescribedBy: error && describedByError ? errorTextID(name) : null, 145 | tone: tone 146 | }), children)); 147 | }); 148 | @@ -93,7 +92,7 @@ function ChoiceList({ 149 | paddingBlockEnd: "200" 150 | }, /*#__PURE__*/React.createElement(InlineError, { 151 | message: error, 152 | - fieldID: finalName 153 | + fieldID: name 154 | })); 155 | return /*#__PURE__*/React.createElement(BlockStack, { 156 | as: "fieldset", 157 | @@ -102,7 +101,7 @@ function ChoiceList({ 158 | md: '0' 159 | }, 160 | "aria-invalid": error != null, 161 | - id: finalName 162 | + id: name 163 | }, titleMarkup, /*#__PURE__*/React.createElement(BlockStack, { 164 | as: "ul", 165 | gap: { 166 | diff --git a/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js b/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js 167 | index 2926c87..4e26217 100644 168 | --- a/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js 169 | +++ b/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js 170 | @@ -27,6 +27,7 @@ const DropZone = function DropZone({ 171 | label, 172 | labelAction, 173 | labelHidden, 174 | + name, 175 | children, 176 | disabled = false, 177 | outline = true, 178 | @@ -118,7 +119,6 @@ const DropZone = function DropZone({ 179 | onDropAccepted && acceptedFiles.length && onDropAccepted(acceptedFiles); 180 | onDropRejected && rejectedFiles.length && onDropRejected(rejectedFiles); 181 | if (!(event.target && 'value' in event.target)) return; 182 | - event.target.value = ''; 183 | }, [disabled, getValidatedFiles, onDrop, onDropAccepted, onDropRejected]); 184 | const handleDragEnter = useCallback(event => { 185 | stopEvent(event); 186 | @@ -242,6 +242,7 @@ const DropZone = function DropZone({ 187 | accept: accept, 188 | disabled: disabled, 189 | multiple: allowMultiple, 190 | + name: name, 191 | onChange: handleDrop, 192 | onFocus: handleFocus, 193 | onBlur: handleBlur, 194 | diff --git a/node_modules/@shopify/polaris/build/esm/components/Link/Link.js b/node_modules/@shopify/polaris/build/esm/components/Link/Link.js 195 | index d9e781a..cea39cb 100644 196 | --- a/node_modules/@shopify/polaris/build/esm/components/Link/Link.js 197 | +++ b/node_modules/@shopify/polaris/build/esm/components/Link/Link.js 198 | @@ -9,6 +9,7 @@ function Link({ 199 | children, 200 | onClick, 201 | external, 202 | + rel, 203 | target, 204 | id, 205 | monochrome, 206 | @@ -23,6 +24,7 @@ function Link({ 207 | onClick: onClick, 208 | className: className, 209 | url: url, 210 | + rel: rel, 211 | external: external, 212 | target: target, 213 | id: id, 214 | diff --git a/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js b/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js 215 | index ada2ee3..c0d5ce4 100644 216 | --- a/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js 217 | +++ b/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js 218 | @@ -30,12 +30,12 @@ const UnstyledLink = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Unstyled 219 | } else { 220 | target = targetProp ?? undefined; 221 | } 222 | - const rel = target === '_blank' ? 'noopener noreferrer' : undefined; 223 | + const rel = props.ref ?? target === '_blank' ? 'noopener noreferrer' : undefined; 224 | return /*#__PURE__*/React.createElement("a", Object.assign({ 225 | - target: target 226 | - }, rest, { 227 | - href: url, 228 | + target: target, 229 | rel: rel 230 | + }, rest, { 231 | + href: url 232 | }, unstyled.props, { 233 | ref: _ref 234 | })); 235 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext b/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext 236 | index 45fd642..4f68720 100644 237 | --- a/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext 238 | +++ b/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext 239 | @@ -25,7 +25,6 @@ function ChoiceList({ 240 | const ControlComponent = allowMultiple ? Checkbox : RadioButton; 241 | const uniqName = useId(); 242 | const name = nameProp ?? uniqName; 243 | - const finalName = allowMultiple ? `${name}[]` : name; 244 | const titleMarkup = title ? /*#__PURE__*/React.createElement(Box, { 245 | as: "legend", 246 | paddingBlockEnd: { 247 | @@ -69,7 +68,7 @@ function ChoiceList({ 248 | xs: '0' 249 | } 250 | }, /*#__PURE__*/React.createElement(ControlComponent, { 251 | - name: finalName, 252 | + name: name, 253 | value: value, 254 | id: id, 255 | label: label, 256 | @@ -81,7 +80,7 @@ function ChoiceList({ 257 | checked: choiceIsSelected(choice, selected), 258 | helpText: helpText, 259 | onChange: handleChange, 260 | - ariaDescribedBy: error && describedByError ? errorTextID(finalName) : null, 261 | + ariaDescribedBy: error && describedByError ? errorTextID(name) : null, 262 | tone: tone 263 | }), children)); 264 | }); 265 | @@ -93,7 +92,7 @@ function ChoiceList({ 266 | paddingBlockEnd: "200" 267 | }, /*#__PURE__*/React.createElement(InlineError, { 268 | message: error, 269 | - fieldID: finalName 270 | + fieldID: name 271 | })); 272 | return /*#__PURE__*/React.createElement(BlockStack, { 273 | as: "fieldset", 274 | @@ -102,7 +101,7 @@ function ChoiceList({ 275 | md: '0' 276 | }, 277 | "aria-invalid": error != null, 278 | - id: finalName 279 | + id: name 280 | }, titleMarkup, /*#__PURE__*/React.createElement(BlockStack, { 281 | as: "ul", 282 | gap: { 283 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext b/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext 284 | index bfd1ace..7d2b69a 100644 285 | --- a/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext 286 | +++ b/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext 287 | @@ -27,6 +27,7 @@ const DropZone = function DropZone({ 288 | label, 289 | labelAction, 290 | labelHidden, 291 | + name, 292 | children, 293 | disabled = false, 294 | outline = true, 295 | @@ -118,7 +119,6 @@ const DropZone = function DropZone({ 296 | onDropAccepted && acceptedFiles.length && onDropAccepted(acceptedFiles); 297 | onDropRejected && rejectedFiles.length && onDropRejected(rejectedFiles); 298 | if (!(event.target && 'value' in event.target)) return; 299 | - event.target.value = ''; 300 | }, [disabled, getValidatedFiles, onDrop, onDropAccepted, onDropRejected]); 301 | const handleDragEnter = useCallback(event => { 302 | stopEvent(event); 303 | @@ -242,6 +242,7 @@ const DropZone = function DropZone({ 304 | accept: accept, 305 | disabled: disabled, 306 | multiple: allowMultiple, 307 | + name: name, 308 | onChange: handleDrop, 309 | onFocus: handleFocus, 310 | onBlur: handleBlur, 311 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext b/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext 312 | index 3500f54..62f6d89 100644 313 | --- a/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext 314 | +++ b/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext 315 | @@ -9,6 +9,7 @@ function Link({ 316 | children, 317 | onClick, 318 | external, 319 | + rel, 320 | target, 321 | id, 322 | monochrome, 323 | @@ -23,6 +24,7 @@ function Link({ 324 | onClick: onClick, 325 | className: className, 326 | url: url, 327 | + rel: rel, 328 | external: external, 329 | target: target, 330 | id: id, 331 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext b/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext 332 | index 2661804..b6be48a 100644 333 | --- a/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext 334 | +++ b/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext 335 | @@ -30,12 +30,12 @@ const UnstyledLink = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Unstyled 336 | } else { 337 | target = targetProp ?? undefined; 338 | } 339 | - const rel = target === '_blank' ? 'noopener noreferrer' : undefined; 340 | + const rel = props.ref ?? target === '_blank' ? 'noopener noreferrer' : undefined; 341 | return /*#__PURE__*/React.createElement("a", Object.assign({ 342 | - target: target 343 | - }, rest, { 344 | - href: url, 345 | + target: target, 346 | rel: rel 347 | + }, rest, { 348 | + href: url 349 | }, unstyled.props, { 350 | ref: _ref 351 | })); 352 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts 353 | index e687b01..1a646dd 100644 354 | --- a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts 355 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts 356 | @@ -11,6 +11,8 @@ export interface DropZoneProps { 357 | labelHidden?: boolean; 358 | /** ID for file input */ 359 | id?: string; 360 | + /** name for file input */ 361 | + name?: string; 362 | /** Allowed file types */ 363 | accept?: string; 364 | /** 365 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map 366 | index 1cd0f19..69a3e6e 100644 367 | --- a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map 368 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map 369 | @@ -1 +1 @@ 370 | -{"version":3,"file":"DropZone.d.ts","sourceRoot":"","sources":["../../../../../src/components/DropZone/DropZone.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAUf,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,aAAa,CAAC;AAQ/C,OAAO,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AAWxC,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,kCAAkC;IAClC,WAAW,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACtC,8BAA8B;IAC9B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wBAAwB;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACpC,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yCAAyC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8BAA8B;IAC9B,eAAe,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC;IACtC,kCAAkC;IAClC,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC;IACrD,0CAA0C;IAC1C,MAAM,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC3E,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,gFAAgF;IAChF,UAAU,CAAC,IAAI,IAAI,CAAC;IACpB,sEAAsE;IACtE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,mEAAmE;IACnE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,0DAA0D;IAC1D,iBAAiB,CAAC,IAAI,IAAI,CAAC;CAC5B;AAOD,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC,aAAa,CAAC,GAAG;IAC9D,UAAU,EAAE,OAAO,UAAU,CAAC;CAmV/B,CAAC"} 371 | \ No newline at end of file 372 | +{"version":3,"file":"DropZone.d.ts","sourceRoot":"","sources":["../../../../../src/components/DropZone/DropZone.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAUf,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,aAAa,CAAC;AAQ/C,OAAO,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AAWxC,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,kCAAkC;IAClC,WAAW,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACtC,8BAA8B;IAC9B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wBAAwB;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,0BAA0B;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACpC,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yCAAyC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8BAA8B;IAC9B,eAAe,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC;IACtC,kCAAkC;IAClC,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC;IACrD,0CAA0C;IAC1C,MAAM,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC3E,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,gFAAgF;IAChF,UAAU,CAAC,IAAI,IAAI,CAAC;IACpB,sEAAsE;IACtE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,mEAAmE;IACnE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,0DAA0D;IAC1D,iBAAiB,CAAC,IAAI,IAAI,CAAC;CAC5B;AAOD,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC,aAAa,CAAC,GAAG;IAC9D,UAAU,EAAE,OAAO,UAAU,CAAC;CAqV/B,CAAC"} 373 | \ No newline at end of file 374 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts 375 | index 13dbd6f..eada1a4 100644 376 | --- a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts 377 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts 378 | @@ -11,6 +11,8 @@ export interface LinkProps { 379 | * @deprecated use `target` set to `_blank` instead 380 | */ 381 | external?: boolean; 382 | + /** The relationship of the linked URL as space-separated link types. */ 383 | + rel?: string; 384 | /** Where to display the url */ 385 | target?: Target; 386 | /** Makes the link color the same as the current text color and adds an underline */ 387 | @@ -24,5 +26,5 @@ export interface LinkProps { 388 | /** Indicates whether or not the link is the primary navigation link when rendered inside of an `IndexTable.Row` */ 389 | dataPrimaryLink?: boolean; 390 | } 391 | -export declare function Link({ url, children, onClick, external, target, id, monochrome, removeUnderline, accessibilityLabel, dataPrimaryLink, }: LinkProps): React.JSX.Element; 392 | +export declare function Link({ url, children, onClick, external, rel, target, id, monochrome, removeUnderline, accessibilityLabel, dataPrimaryLink, }: LinkProps): React.JSX.Element; 393 | //# sourceMappingURL=Link.d.ts.map 394 | \ No newline at end of file 395 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map 396 | index 046d207..15da0b4 100644 397 | --- a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map 398 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map 399 | @@ -1 +1 @@ 400 | -{"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../../../src/components/Link/Link.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAIxC,MAAM,WAAW,SAAS;IACxB,sBAAsB;IACtB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B;;OAEG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oDAAoD;IACpD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,sCAAsC;IACtC,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,mDAAmD;IACnD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,mHAAmH;IACnH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,IAAI,CAAC,EACnB,GAAG,EACH,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,MAAM,EACN,EAAE,EACF,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,eAAe,GAChB,EAAE,SAAS,qBAwCX"} 401 | \ No newline at end of file 402 | +{"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../../../src/components/Link/Link.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAIxC,MAAM,WAAW,SAAS;IACxB,sBAAsB;IACtB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B;;OAEG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wEAAwE;IACxE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oDAAoD;IACpD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,sCAAsC;IACtC,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,mDAAmD;IACnD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,mHAAmH;IACnH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,IAAI,CAAC,EACnB,GAAG,EACH,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,GAAG,EACH,MAAM,EACN,EAAE,EACF,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,eAAe,GAChB,EAAE,SAAS,qBAyCX"} 403 | \ No newline at end of file 404 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { defineConfig } from "@playwright/test"; 3 | 4 | const appUrl = env.HOST ?? env.SHOPIFY_APP_URL; 5 | 6 | export default defineConfig({ 7 | outputDir: "node_modules/.playwright", 8 | testDir: "./", 9 | testMatch: /.*\.e2e.test.ts/, 10 | use: { 11 | baseURL: appUrl, 12 | extraHTTPHeaders: { 13 | Accept: "application/json", 14 | // Authorization: `token ${env.SHOPIFY_STOREFRONT_ACCESS_TOKEN}`, 15 | }, 16 | locale: "en", 17 | serviceWorkers: "allow", 18 | }, 19 | webServer: { 20 | command: "npm run dev", 21 | reuseExistingServer: true, 22 | timeout: 10 * 1000, 23 | url: appUrl, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /public/.well-known/publickey.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExd0kyBRcG4CtF9piBXI+Mk9AUtOb 3 | ldJDq8VW2PWoefdL9nNNX+nheBowIMqzJtmYXa17MWVAtWc7S5Mds8QICQ== 4 | -----END PUBLIC KEY----- 5 | -------------------------------------------------------------------------------- /public/.well-known/security.txt: -------------------------------------------------------------------------------- 1 | Contact: chr33s@icloud.com 2 | Encryption: /.well-known/publickey.txt 3 | Preferred-Languages: en 4 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | logo 11 | 35 | 36 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | future: { 6 | unstable_optimizeDeps: true, 7 | unstable_splitRouteModules: true, 8 | unstable_viteEnvironmentApi: true, 9 | }, 10 | // Fixes hot-reload on proxy paths 11 | routeDiscovery: { mode: "initial" }, 12 | // Server-side render by default, to enable SPA mode set this to `false` 13 | ssr: true, 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /shopify.app.toml: -------------------------------------------------------------------------------- 1 | # Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration 2 | 3 | client_id = "17b048405a3e2ffe901be65f5783837d" 4 | application_url = "https://local.chr33s.dev" 5 | embedded = true 6 | name = "ShopFlare" 7 | handle = "shopflare" 8 | 9 | [access.admin] 10 | direct_api_mode = "online" 11 | embedded_app_direct_api_access = true 12 | 13 | [access_scopes] 14 | # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes 15 | scopes = "read_products, write_app_proxy" 16 | optional_scopes = [ "write_products" ] 17 | use_legacy_install_flow = false 18 | 19 | [app_proxy] 20 | url = "https://local.chr33s.dev/apps/shopflare" 21 | subpath = "shopflare" 22 | prefix = "apps" 23 | 24 | [auth] 25 | redirect_urls = [ "https://local.chr33s.dev/shopify/auth/callback" ] 26 | 27 | [build] 28 | automatically_update_urls_on_dev = false 29 | dev_store_url = "glue-dev-store.myshopify.com" 30 | include_config_on_deploy = true 31 | 32 | [webhooks] 33 | api_version = "2025-04" 34 | 35 | [[webhooks.subscriptions]] 36 | uri = "/shopify/webhooks" 37 | compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ] 38 | topics = [ "app/scopes_update", "app/uninstalled" ] 39 | 40 | [pos] 41 | embedded = false 42 | -------------------------------------------------------------------------------- /shopify.web.toml: -------------------------------------------------------------------------------- 1 | name = "app" 2 | webhooks_path = "/shopify/webhooks" 3 | 4 | [commands] 5 | build = "npx react-router build" 6 | dev = "npx react-router dev" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "checkJs": true, 5 | "composite": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 9 | "module": "ES2022", 10 | "moduleResolution": "bundler", 11 | "noEmit": true, 12 | "paths": { 13 | "~/*": ["./app/*"] 14 | }, 15 | "resolveJsonModule": true, 16 | "rootDirs": [".", "./.react-router/types"], 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "target": "ES2022", 20 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", 21 | "types": [ 22 | "@cloudflare/vitest-pool-workers", 23 | "@vitest/browser/matchers", 24 | "@vitest/browser/providers/playwright", 25 | "vite/client" 26 | ], 27 | "verbatimModuleSyntax": true 28 | }, 29 | "exclude": ["build", "**/dist"], 30 | "include": [ 31 | "**/*", 32 | "**/.server/**/*", 33 | "**/.client/**/*", 34 | ".react-router/types/**/*", 35 | "worker-configuration.d.ts", 36 | "node_modules/vite-plugin-i18next-loader/typings/*" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { cloudflare } from "@cloudflare/vite-plugin"; 2 | import { reactRouter } from "@react-router/dev/vite"; 3 | import { defineConfig, loadEnv } from "vite"; 4 | import i18nextLoader from "vite-plugin-i18next-loader"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | import i18nextLoaderOptions from "./i18n.config"; 8 | 9 | export default defineConfig(({ mode }) => { 10 | const env = loadEnv(mode, process.cwd(), ""); 11 | const app = new URL(env.HOST ?? env.SHOPIFY_APP_URL); 12 | 13 | return { 14 | base: app.href, 15 | clearScreen: false, 16 | plugins: [ 17 | i18nextLoader(i18nextLoaderOptions), 18 | cloudflare({ viteEnvironment: { name: "ssr" } }), 19 | reactRouter(), 20 | tsconfigPaths(), 21 | ], 22 | resolve: { 23 | mainFields: ["browser", "module", "main"], 24 | }, 25 | server: { 26 | allowedHosts: [app.hostname], 27 | cors: { 28 | origin: true, 29 | preflightContinue: true, 30 | }, 31 | origin: app.origin, 32 | port: Number(env.PORT || 8080), 33 | }, 34 | ssr: { 35 | resolve: { 36 | conditions: ["workerd", "worker", "browser"], 37 | }, 38 | }, 39 | }; 40 | }); 41 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv } from "vite"; 2 | import i18nextLoader from "vite-plugin-i18next-loader"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import { defineConfig } from "vitest/config"; 5 | 6 | import i18nextLoaderOptions from "./i18n.config"; 7 | 8 | export default defineConfig((config) => { 9 | const env = loadEnv(config.mode, process.cwd(), ""); 10 | 11 | return { 12 | optimizeDeps: { 13 | include: ["react/jsx-dev-runtime"], 14 | }, 15 | plugins: [i18nextLoader(i18nextLoaderOptions), tsconfigPaths()], 16 | test: { 17 | css: true, 18 | env, 19 | watch: false, 20 | }, 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { 3 | defineWorkersConfig, 4 | defineWorkersProject, 5 | } from "@cloudflare/vitest-pool-workers/config"; 6 | import { defineWorkspace, mergeConfig } from "vitest/config"; 7 | 8 | export default defineWorkspace([ 9 | { 10 | extends: "./vitest.config.ts", 11 | test: { 12 | browser: { 13 | headless: true, 14 | enabled: true, 15 | instances: [{ browser: "webkit" }], 16 | provider: "playwright", 17 | }, 18 | include: ["app/**/*.browser.test.tsx"], 19 | name: "browser", 20 | }, 21 | }, 22 | { 23 | extends: "./vitest.config.ts", 24 | test: { 25 | environment: "happy-dom", 26 | include: ["app/**/*.client.test.tsx"], 27 | name: "client", 28 | }, 29 | }, 30 | defineWorkersConfig( 31 | mergeConfig( 32 | { extends: "./vitest.config.ts" }, 33 | defineWorkersProject({ 34 | test: { 35 | alias: [ 36 | { 37 | find: "virtual:react-router/server-build", 38 | replacement: fileURLToPath( 39 | new URL("./build/server/index.js", import.meta.url), 40 | ), 41 | }, 42 | ], 43 | include: ["worker.test.ts", "app/**/*.server.test.ts"], 44 | name: "server", 45 | poolOptions: { 46 | workers: { 47 | main: "./build/server/index.js", 48 | miniflare: { 49 | compatibilityFlags: [ 50 | "nodejs_compat", 51 | "service_binding_extra_handlers", 52 | ], 53 | }, 54 | singleWorker: true, 55 | wrangler: { configPath: "./wrangler.json" }, 56 | }, 57 | }, 58 | }, 59 | }), 60 | ), 61 | ), 62 | ]); 63 | -------------------------------------------------------------------------------- /worker.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SELF, 3 | createExecutionContext, 4 | env, 5 | waitOnExecutionContext, 6 | } from "cloudflare:test"; 7 | import { afterEach, expect, test, vi } from "vitest"; 8 | 9 | import worker from "./worker"; 10 | 11 | afterEach(() => { 12 | vi.restoreAllMocks(); 13 | }); 14 | 15 | test("fetch", async () => { 16 | const response = await SELF.fetch("http://example.com"); 17 | expect(await response.text()).toContain("ShopFlare"); 18 | expect(response.status).toBe(200); 19 | }); 20 | 21 | // FIXME: upstream bundler issue 22 | test.skip("worker", async () => { 23 | const request = new Request("http://example.com"); 24 | const ctx = createExecutionContext(); 25 | // biome-ignore lint/suspicious/noExplicitAny: upstream 26 | const response = await worker.fetch(request as any, env as Env, ctx); 27 | await waitOnExecutionContext(ctx); 28 | expect(await response.text()).toContain("ShopFlare"); 29 | expect(response.status).toBe(200); 30 | }); 31 | -------------------------------------------------------------------------------- /worker.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "react-router"; 2 | 3 | import type { WebhookQueueMessage } from "~/types/app"; 4 | 5 | declare module "react-router" { 6 | export interface AppLoadContext { 7 | cloudflare: { 8 | ctx: ExecutionContext; 9 | env: Env; 10 | }; 11 | } 12 | } 13 | 14 | const requestHandler = createRequestHandler( 15 | () => import("virtual:react-router/server-build"), 16 | import.meta.env.MODE, 17 | ); 18 | 19 | export default { 20 | async fetch(request, env, ctx) { 21 | return requestHandler(request, { 22 | cloudflare: { env, ctx }, 23 | }); 24 | }, 25 | 26 | async queue(batch, _env, _ctx): Promise { 27 | console.log(`server.queue: ${JSON.stringify(batch.messages)}`); 28 | 29 | for (const message of batch.messages) { 30 | message.ack(); 31 | } 32 | }, 33 | } satisfies ExportedHandler; 34 | -------------------------------------------------------------------------------- /wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/wrangler/config-schema.json", 3 | "name": "shopflare", 4 | "compatibility_date": "2025-04-10", 5 | "main": "./worker.ts", 6 | "assets": { 7 | "binding": "ASSETS", 8 | "directory": "./build/client" 9 | }, 10 | "dev": { 11 | "ip": "0.0.0.0", 12 | "port": 8080 13 | }, 14 | "kv_namespaces": [ 15 | { 16 | "binding": "SESSION_STORAGE", 17 | "id": "?" 18 | } 19 | ], 20 | "logpush": true, 21 | "observability": { 22 | "enabled": true, 23 | "logs": { 24 | "invocation_logs": false 25 | } 26 | }, 27 | "placement": { 28 | "mode": "smart" 29 | }, 30 | "queues": { 31 | "consumers": [ 32 | { 33 | "queue": "shopflare" 34 | } 35 | ], 36 | "producers": [ 37 | { 38 | "queue": "shopflare", 39 | "binding": "WEBHOOK_QUEUE" 40 | } 41 | ] 42 | }, 43 | "upload_source_maps": true, 44 | "vars": { 45 | "NODE_VERSION": 22, 46 | "SHOPIFY_API_KEY": "", 47 | "SHOPIFY_APP_URL": "" 48 | } 49 | } 50 | --------------------------------------------------------------------------------