feat: Scaffold a new Shopify app with product review functionality and Prisma integration.
This commit is contained in:
commit
0cb3aa98d2
8
.cursor/mcp.json
Normal file
8
.cursor/mcp.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shopify-dev-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.cache
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# Markdown syntax specifies that trailing whitespaces can be meaningful,
|
||||||
|
# so let’s not trim those. e.g. 2 trailing spaces = linebreak (<br />)
|
||||||
|
# See https://daringfireball.net/projects/markdown/syntax#p
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
public/build
|
||||||
|
*/*.yml
|
||||||
|
.shopify
|
||||||
96
.eslintrc.cjs
Normal file
96
.eslintrc.cjs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* This is intended to be a basic starting point for linting in your app.
|
||||||
|
* It relies on recommended configs out of the box for simplicity, but you can
|
||||||
|
* and should modify this configuration to best suit your team's needs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config} */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
commonjs: true,
|
||||||
|
es6: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ["!**/.server", "!**/.client"],
|
||||||
|
|
||||||
|
// Base config
|
||||||
|
extends: ["eslint:recommended"],
|
||||||
|
|
||||||
|
overrides: [
|
||||||
|
// React
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||||
|
plugins: ["react", "jsx-a11y"],
|
||||||
|
extends: [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:jsx-a11y/recommended",
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
formComponents: ["Form"],
|
||||||
|
linkComponents: [
|
||||||
|
{ name: "Link", linkAttribute: "to" },
|
||||||
|
{ name: "NavLink", linkAttribute: "to" },
|
||||||
|
],
|
||||||
|
"import/resolver": {
|
||||||
|
typescript: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"react/no-unknown-property": ["error", { ignore: ["variant"] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
plugins: ["@typescript-eslint", "import"],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
settings: {
|
||||||
|
"import/internal-regex": "^~/",
|
||||||
|
"import/resolver": {
|
||||||
|
node: {
|
||||||
|
extensions: [".ts", ".tsx"],
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"plugin:import/typescript",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Node
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
".eslintrc.cjs",
|
||||||
|
"vite.config.{js,ts}",
|
||||||
|
".graphqlrc.{js,ts}",
|
||||||
|
"shopify.server.{js,ts}",
|
||||||
|
"**/*.server.{js,ts}",
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
globals: {
|
||||||
|
shopify: "readonly"
|
||||||
|
},
|
||||||
|
};
|
||||||
10
.gemini/extensions/shopify-dev-mcp/gemini-extension.json
Normal file
10
.gemini/extensions/shopify-dev-mcp/gemini-extension.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "shopify-dev-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mcpServers": {
|
||||||
|
"shopify-dev-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
/.cache
|
||||||
|
/build
|
||||||
|
/app/build
|
||||||
|
/public/build/
|
||||||
|
/public/_dev
|
||||||
|
/app/public/build
|
||||||
|
/prisma/dev.sqlite
|
||||||
|
/prisma/dev.sqlite-journal
|
||||||
|
database.sqlite
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/extensions/*/dist
|
||||||
|
|
||||||
|
# Ignore shopify files created during app dev
|
||||||
|
.shopify/*
|
||||||
|
.shopify.lock
|
||||||
|
|
||||||
|
# Hide files auto-generated by react router
|
||||||
|
.react-router/
|
||||||
38
.graphqlrc.js
Normal file
38
.graphqlrc.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import { ApiVersion } from "@shopify/shopify-app-react-router/server";
|
||||||
|
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
|
||||||
|
function getConfig() {
|
||||||
|
const config = {
|
||||||
|
projects: {
|
||||||
|
default: shopifyApiProject({
|
||||||
|
apiType: ApiType.Admin,
|
||||||
|
apiVersion: ApiVersion.October25,
|
||||||
|
documents: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./app/.server/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
outputDir: "./app/types",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let extensions = [];
|
||||||
|
try {
|
||||||
|
extensions = fs.readdirSync("./extensions");
|
||||||
|
} catch {
|
||||||
|
// ignore if no extensions
|
||||||
|
}
|
||||||
|
for (const entry of extensions) {
|
||||||
|
const extensionPath = `./extensions/${entry}`;
|
||||||
|
const schema = `${extensionPath}/schema.graphql`;
|
||||||
|
if (!fs.existsSync(schema)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
config.projects[entry] = {
|
||||||
|
schema,
|
||||||
|
documents: [`${extensionPath}/**/*.graphql`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
const config = getConfig();
|
||||||
|
export default config;
|
||||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shopify-dev-mcp": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package.json
|
||||||
|
.shadowenv.d
|
||||||
|
.vscode
|
||||||
|
node_modules
|
||||||
|
prisma
|
||||||
|
public
|
||||||
|
.shopify
|
||||||
125
CHANGELOG.md
Normal file
125
CHANGELOG.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# @shopify/shopify-app-template-react-router
|
||||||
|
|
||||||
|
## 2026.02.09
|
||||||
|
- Add declarative product metafield definition and demonstrate metafield usage in the product creation flow
|
||||||
|
- Add declarative metaobject definition and demonstrate metaobject upsert in the product creation flow
|
||||||
|
|
||||||
|
## 2026.01.08
|
||||||
|
- [#170](https://github.com/Shopify/shopify-app-template-react-router/pull/170) - Update React Router minimum version to v7.12.0
|
||||||
|
|
||||||
|
## 2025.12.11
|
||||||
|
|
||||||
|
- [#151](https://github.com/Shopify/shopify-app-template-react-router/pull/151) Update `@shopify/shopify-app-react-router` to v1.1.0 and `@shopify/shopify-app-session-storage-prisma` to v8.0.0, add refresh token fields (`refreshToken` and `refreshTokenExpires`) to Session model in Prisma schema, and adopt the `expiringOfflineAccessTokens` flag for enhanced security through token rotation. See [expiring vs non-expiring offline tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens#expiring-vs-non-expiring-offline-tokens) for more information.
|
||||||
|
|
||||||
|
## 2025.10.10
|
||||||
|
|
||||||
|
- [#95](https://github.com/Shopify/shopify-app-template-react-router/pull/95) Swap the product link for [admin intents](https://shopify.dev/docs/apps/build/admin/admin-intents).
|
||||||
|
|
||||||
|
## 2025.10.02
|
||||||
|
|
||||||
|
- [#81](https://github.com/Shopify/shopify-app-template-react-router/pull/81) Add shopify global to eslint for ui extensions
|
||||||
|
|
||||||
|
## 2025.10.01
|
||||||
|
|
||||||
|
- [#79](https://github.com/Shopify/shopify-app-template-react-router/pull/78) Update API version to 2025-10.
|
||||||
|
- [#77](https://github.com/Shopify/shopify-app-template-react-router/pull/77) Update `@shopify/shopify-app-react-router` to V1.
|
||||||
|
- [#73](https://github.com/Shopify/shopify-app-template-react-router/pull/73/files) Rename @shopify/app-bridge-ui-types to @shopify/polaris-types
|
||||||
|
|
||||||
|
## 2025.08.30
|
||||||
|
|
||||||
|
- [#70](https://github.com/Shopify/shopify-app-template-react-router/pull/70/files) Upgrade `@shopify/app-bridge-ui-types` from 0.2.1 to 0.3.1.
|
||||||
|
|
||||||
|
## 2025.08.17
|
||||||
|
|
||||||
|
- [#58](https://github.com/Shopify/shopify-app-template-react-router/pull/58) Update Shopify & React Router dependencies. Use Shopify React Router in graphqlrc, not shopify-api
|
||||||
|
- [#57](https://github.com/Shopify/shopify-app-template-react-router/pull/57) Update Webhook API version in `shopify.app.toml` to `2025-07`
|
||||||
|
- [#56](https://github.com/Shopify/shopify-app-template-react-router/pull/56) Remove local CLI from package.json in favor of global CLI installation
|
||||||
|
- [#53](https://github.com/Shopify/shopify-app-template-react-router/pull/53) Add the Shopify Dev MCP to the template
|
||||||
|
|
||||||
|
## 2025.08.16
|
||||||
|
|
||||||
|
- [#52](https://github.com/Shopify/shopify-app-template-react-router/pull/52) Use `ApiVersion.July25` rather than `LATEST_API_VERSION` in `.graphqlrc`.
|
||||||
|
|
||||||
|
## 2025.07.24
|
||||||
|
|
||||||
|
- [14](https://github.com/Shopify/shopify-app-template-react-router/pull/14/files) Add [App Bridge web components](https://shopify.dev/docs/api/app-home/app-bridge-web-components) to the template.
|
||||||
|
|
||||||
|
## July 2025
|
||||||
|
|
||||||
|
Forked the [shopify-app-template repo](https://github.com/Shopify/shopify-app-template-remix)
|
||||||
|
|
||||||
|
# @shopify/shopify-app-template-remix
|
||||||
|
|
||||||
|
## 2025.03.18
|
||||||
|
|
||||||
|
-[#998](https://github.com/Shopify/shopify-app-template-remix/pull/998) Update to Vite 6
|
||||||
|
|
||||||
|
## 2025.03.01
|
||||||
|
|
||||||
|
- [#982](https://github.com/Shopify/shopify-app-template-remix/pull/982) Add Shopify Dev Assistant extension to the VSCode extension recommendations
|
||||||
|
|
||||||
|
## 2025.01.31
|
||||||
|
|
||||||
|
- [#952](https://github.com/Shopify/shopify-app-template-remix/pull/952) Update to Shopify App API v2025-01
|
||||||
|
|
||||||
|
## 2025.01.23
|
||||||
|
|
||||||
|
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Update `@shopify/shopify-app-session-storage-prisma` to v6.0.0
|
||||||
|
|
||||||
|
## 2025.01.8
|
||||||
|
|
||||||
|
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Enable GraphQL autocomplete for Javascript
|
||||||
|
|
||||||
|
## 2024.12.19
|
||||||
|
|
||||||
|
- [#904](https://github.com/Shopify/shopify-app-template-remix/pull/904) bump `@shopify/app-bridge-react` to latest
|
||||||
|
-
|
||||||
|
## 2024.12.18
|
||||||
|
|
||||||
|
- [875](https://github.com/Shopify/shopify-app-template-remix/pull/875) Add Scopes Update Webhook
|
||||||
|
## 2024.12.05
|
||||||
|
|
||||||
|
- [#910](https://github.com/Shopify/shopify-app-template-remix/pull/910) Install `openssl` in Docker image to fix Prisma (see [#25817](https://github.com/prisma/prisma/issues/25817#issuecomment-2538544254))
|
||||||
|
- [#907](https://github.com/Shopify/shopify-app-template-remix/pull/907) Move `@remix-run/fs-routes` to `dependencies` to fix Docker image build
|
||||||
|
- [#899](https://github.com/Shopify/shopify-app-template-remix/pull/899) Disable v3_singleFetch flag
|
||||||
|
- [#898](https://github.com/Shopify/shopify-app-template-remix/pull/898) Enable the `removeRest` future flag so new apps aren't tempted to use the REST Admin API.
|
||||||
|
|
||||||
|
## 2024.12.04
|
||||||
|
|
||||||
|
- [#891](https://github.com/Shopify/shopify-app-template-remix/pull/891) Enable remix future flags.
|
||||||
|
|
||||||
|
## 2024.11.26
|
||||||
|
|
||||||
|
- [888](https://github.com/Shopify/shopify-app-template-remix/pull/888) Update restResources version to 2024-10
|
||||||
|
|
||||||
|
## 2024.11.06
|
||||||
|
|
||||||
|
- [881](https://github.com/Shopify/shopify-app-template-remix/pull/881) Update to the productCreate mutation to use the new ProductCreateInput type
|
||||||
|
|
||||||
|
## 2024.10.29
|
||||||
|
|
||||||
|
- [876](https://github.com/Shopify/shopify-app-template-remix/pull/876) Update shopify-app-remix to v3.4.0 and shopify-app-session-storage-prisma to v5.1.5
|
||||||
|
|
||||||
|
## 2024.10.02
|
||||||
|
|
||||||
|
- [863](https://github.com/Shopify/shopify-app-template-remix/pull/863) Update to Shopify App API v2024-10 and shopify-app-remix v3.3.2
|
||||||
|
|
||||||
|
## 2024.09.18
|
||||||
|
|
||||||
|
- [850](https://github.com/Shopify/shopify-app-template-remix/pull/850) Removed "~" import alias
|
||||||
|
|
||||||
|
## 2024.09.17
|
||||||
|
|
||||||
|
- [842](https://github.com/Shopify/shopify-app-template-remix/pull/842) Move webhook processing to individual routes
|
||||||
|
|
||||||
|
## 2024.08.19
|
||||||
|
|
||||||
|
Replaced deprecated `productVariantUpdate` with `productVariantsBulkUpdate`
|
||||||
|
|
||||||
|
## v2024.08.06
|
||||||
|
|
||||||
|
Allow `SHOP_REDACT` webhook to process without admin context
|
||||||
|
|
||||||
|
## v2024.07.16
|
||||||
|
|
||||||
|
Started tracking changes and releases using calver
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["npm", "run", "docker-start"]
|
||||||
237
README.md
Normal file
237
README.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Shopify App Template - React Router
|
||||||
|
|
||||||
|
This is a template for building a [Shopify app](https://shopify.dev/docs/apps/getting-started) using [React Router](https://reactrouter.com/). It was forked from the [Shopify Remix app template](https://github.com/Shopify/shopify-app-template-remix) and converted to React Router.
|
||||||
|
|
||||||
|
Rather than cloning this repo, follow the [Quick Start steps](https://github.com/Shopify/shopify-app-template-react-router#quick-start).
|
||||||
|
|
||||||
|
Visit the [`shopify.dev` documentation](https://shopify.dev/docs/api/shopify-app-react-router) for more details on the React Router app package.
|
||||||
|
|
||||||
|
## Upgrading from Remix
|
||||||
|
|
||||||
|
If you have an existing Remix app that you want to upgrade to React Router, please follow the [upgrade guide](https://github.com/Shopify/shopify-app-template-react-router/wiki/Upgrading-from-Remix). Otherwise, please follow the quick start guide below.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Before you begin, you'll need to [download and install the Shopify CLI](https://shopify.dev/docs/apps/tools/cli/getting-started) if you haven't already.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```shell
|
||||||
|
shopify app init --template=https://github.com/Shopify/shopify-app-template-react-router
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```shell
|
||||||
|
shopify app dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Press P to open the URL to your app. Once you click install, you can start development.
|
||||||
|
|
||||||
|
Local development is powered by [the Shopify CLI](https://shopify.dev/docs/apps/tools/cli). It logs into your account, connects to an app, provides environment variables, updates remote config, creates a tunnel and provides commands to generate extensions.
|
||||||
|
|
||||||
|
### Authenticating and querying data
|
||||||
|
|
||||||
|
To authenticate and query data you can use the `shopify` const that is exported from `/app/shopify.server.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function loader({ request }) {
|
||||||
|
const { admin } = await shopify.authenticate.admin(request);
|
||||||
|
|
||||||
|
const response = await admin.graphql(`
|
||||||
|
{
|
||||||
|
products(first: 25) {
|
||||||
|
nodes {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
products: { nodes },
|
||||||
|
},
|
||||||
|
} = await response.json();
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This template comes pre-configured with examples of:
|
||||||
|
|
||||||
|
1. Setting up your Shopify app in [/app/shopify.server.ts](https://github.com/Shopify/shopify-app-template-react-router/blob/main/app/shopify.server.ts)
|
||||||
|
2. Querying data using Graphql. Please see: [/app/routes/app.\_index.tsx](https://github.com/Shopify/shopify-app-template-react-router/blob/main/app/routes/app._index.tsx).
|
||||||
|
3. Responding to webhooks. Please see [/app/routes/webhooks.tsx](https://github.com/Shopify/shopify-app-template-react-router/blob/main/app/routes/webhooks.app.uninstalled.tsx).
|
||||||
|
4. Using metafields, metaobjects, and declarative custom data definitions. Please see [/app/routes/app.\_index.tsx](https://github.com/Shopify/shopify-app-template-react-router/blob/main/app/routes/app._index.tsx) and [shopify.app.toml](https://github.com/Shopify/shopify-app-template-react-router/blob/main/shopify.app.toml).
|
||||||
|
|
||||||
|
Please read the [documentation for @shopify/shopify-app-react-router](https://shopify.dev/docs/api/shopify-app-react-router) to see what other API's are available.
|
||||||
|
|
||||||
|
## Shopify Dev MCP
|
||||||
|
|
||||||
|
This template is configured with the Shopify Dev MCP. This instructs [Cursor](https://cursor.com/), [GitHub Copilot](https://github.com/features/copilot) and [Claude Code](https://claude.com/product/claude-code) and [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) to use the Shopify Dev MCP.
|
||||||
|
|
||||||
|
For more information on the Shopify Dev MCP please read [the documentation](https://shopify.dev/docs/apps/build/devmcp).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Application Storage
|
||||||
|
|
||||||
|
This template uses [Prisma](https://www.prisma.io/) to store session data, by default using an [SQLite](https://www.sqlite.org/index.html) database.
|
||||||
|
The database is defined as a Prisma schema in `prisma/schema.prisma`.
|
||||||
|
|
||||||
|
This use of SQLite works in production if your app runs as a single instance.
|
||||||
|
The database that works best for you depends on the data your app needs and how it is queried.
|
||||||
|
Here’s a short list of databases providers that provide a free tier to get started:
|
||||||
|
|
||||||
|
| Database | Type | Hosters |
|
||||||
|
| ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| MySQL | SQL | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-mysql), [Planet Scale](https://planetscale.com/), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/mysql) |
|
||||||
|
| PostgreSQL | SQL | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-postgresql), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/postgres) |
|
||||||
|
| Redis | Key-value | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-redis), [Amazon MemoryDB](https://aws.amazon.com/memorydb/) |
|
||||||
|
| MongoDB | NoSQL / Document | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-mongodb), [MongoDB Atlas](https://www.mongodb.com/atlas/database) |
|
||||||
|
|
||||||
|
To use one of these, you can use a different [datasource provider](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#datasource) in your `schema.prisma` file, or a different [SessionStorage adapter package](https://github.com/Shopify/shopify-api-js/blob/main/packages/shopify-api/docs/guides/session-storage.md).
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
Build the app by running the command below with the package manager of your choice:
|
||||||
|
|
||||||
|
Using yarn:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
Using npm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Using pnpm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hosting
|
||||||
|
|
||||||
|
When you're ready to set up your app in production, you can follow [our deployment documentation](https://shopify.dev/docs/apps/launch/deployment) to host it externally. From there, you have a few options:
|
||||||
|
|
||||||
|
- [Google Cloud Run](https://shopify.dev/docs/apps/launch/deployment/deploy-to-google-cloud-run): This tutorial is written specifically for this example repo, and is compatible with the extended steps included in the subsequent [**Build your app**](tutorial) in the **Getting started** docs. It is the most detailed tutorial for taking a React Router-based Shopify app and deploying it to production. It includes configuring permissions and secrets, setting up a production database, and even hosting your apps behind a load balancer across multiple regions.
|
||||||
|
- [Fly.io](https://fly.io/docs/js/shopify/): Leverages the Fly.io CLI to quickly launch Shopify apps to a single machine.
|
||||||
|
- [Render](https://render.com/docs/deploy-shopify-app): This tutorial guides you through using Docker to deploy and install apps on a Dev store.
|
||||||
|
- [Manual deployment guide](https://shopify.dev/docs/apps/launch/deployment/deploy-to-hosting-service): This resource provides general guidance on the requirements of deployment including environment variables, secrets, and persistent data.
|
||||||
|
|
||||||
|
When you reach the step for [setting up environment variables](https://shopify.dev/docs/apps/deployment/web#set-env-vars), you also need to set the variable `NODE_ENV=production`.
|
||||||
|
|
||||||
|
## Gotchas / Troubleshooting
|
||||||
|
|
||||||
|
### Database tables don't exist
|
||||||
|
|
||||||
|
If you get an error like:
|
||||||
|
|
||||||
|
```
|
||||||
|
The table `main.Session` does not exist in the current database.
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the database for Prisma. Run the `setup` script in `package.json` using `npm`, `yarn` or `pnpm`.
|
||||||
|
|
||||||
|
### Navigating/redirecting breaks an embedded app
|
||||||
|
|
||||||
|
Embedded apps must maintain the user session, which can be tricky inside an iFrame. To avoid issues:
|
||||||
|
|
||||||
|
1. Use `Link` from `react-router` or `@shopify/polaris`. Do not use `<a>`.
|
||||||
|
2. Use `redirect` returned from `authenticate.admin`. Do not use `redirect` from `react-router`
|
||||||
|
3. Use `useSubmit` from `react-router`.
|
||||||
|
|
||||||
|
This only applies if your app is embedded, which it will be by default.
|
||||||
|
|
||||||
|
### Webhooks: shop-specific webhook subscriptions aren't updated
|
||||||
|
|
||||||
|
If you are registering webhooks in the `afterAuth` hook, using `shopify.registerWebhooks`, you may find that your subscriptions aren't being updated.
|
||||||
|
|
||||||
|
Instead of using the `afterAuth` hook declare app-specific webhooks in the `shopify.app.toml` file. This approach is easier since Shopify will automatically sync changes every time you run `deploy` (e.g: `npm run deploy`). Please read these guides to understand more:
|
||||||
|
|
||||||
|
1. [app-specific vs shop-specific webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-subscriptions)
|
||||||
|
2. [Create a subscription tutorial](https://shopify.dev/docs/apps/build/webhooks/subscribe/get-started?deliveryMethod=https)
|
||||||
|
|
||||||
|
If you do need shop-specific webhooks, keep in mind that the package calls `afterAuth` in 2 scenarios:
|
||||||
|
|
||||||
|
- After installing the app
|
||||||
|
- When an access token expires
|
||||||
|
|
||||||
|
During normal development, the app won't need to re-authenticate most of the time, so shop-specific subscriptions aren't updated. To force your app to update the subscriptions, uninstall and reinstall the app. Revisiting the app will call the `afterAuth` hook.
|
||||||
|
|
||||||
|
### Webhooks: Admin created webhook failing HMAC validation
|
||||||
|
|
||||||
|
Webhooks subscriptions created in the [Shopify admin](https://help.shopify.com/en/manual/orders/notifications/webhooks) will fail HMAC validation. This is because the webhook payload is not signed with your app's secret key.
|
||||||
|
|
||||||
|
The recommended solution is to use [app-specific webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-subscriptions) defined in your toml file instead. Test your webhooks by triggering events manually in the Shopify admin(e.g. Updating the product title to trigger a `PRODUCTS_UPDATE`).
|
||||||
|
|
||||||
|
### Webhooks: Admin object undefined on webhook events triggered by the CLI
|
||||||
|
|
||||||
|
When you trigger a webhook event using the Shopify CLI, the `admin` object will be `undefined`. This is because the CLI triggers an event with a valid, but non-existent, shop. The `admin` object is only available when the webhook is triggered by a shop that has installed the app. This is expected.
|
||||||
|
|
||||||
|
Webhooks triggered by the CLI are intended for initial experimentation testing of your webhook configuration. For more information on how to test your webhooks, see the [Shopify CLI documentation](https://shopify.dev/docs/apps/tools/cli/commands#webhook-trigger).
|
||||||
|
|
||||||
|
### Incorrect GraphQL Hints
|
||||||
|
|
||||||
|
By default the [graphql.vscode-graphql](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql) extension for will assume that GraphQL queries or mutations are for the [Shopify Admin API](https://shopify.dev/docs/api/admin). This is a sensible default, but it may not be true if:
|
||||||
|
|
||||||
|
1. You use another Shopify API such as the storefront API.
|
||||||
|
2. You use a third party GraphQL API.
|
||||||
|
|
||||||
|
If so, please update [.graphqlrc.ts](https://github.com/Shopify/shopify-app-template-react-router/blob/main/.graphqlrc.ts).
|
||||||
|
|
||||||
|
### Using Defer & await for streaming responses
|
||||||
|
|
||||||
|
By default the CLI uses a cloudflare tunnel. Unfortunately cloudflare tunnels wait for the Response stream to finish, then sends one chunk. This will not affect production.
|
||||||
|
|
||||||
|
To test [streaming using await](https://reactrouter.com/api/components/Await#await) during local development we recommend [localhost based development](https://shopify.dev/docs/apps/build/cli-for-apps/networking-options#localhost-based-development).
|
||||||
|
|
||||||
|
### "nbf" claim timestamp check failed
|
||||||
|
|
||||||
|
This is because a JWT token is expired. If you are consistently getting this error, it could be that the clock on your machine is not in sync with the server. To fix this ensure you have enabled "Set time and date automatically" in the "Date and Time" settings on your computer.
|
||||||
|
|
||||||
|
### Using MongoDB and Prisma
|
||||||
|
|
||||||
|
If you choose to use MongoDB with Prisma, there are some gotchas in Prisma's MongoDB support to be aware of. Please see the [Prisma SessionStorage README](https://www.npmjs.com/package/@shopify/shopify-app-session-storage-prisma#mongodb).
|
||||||
|
|
||||||
|
### Unable to require(`C:\...\query_engine-windows.dll.node`).
|
||||||
|
|
||||||
|
Unable to require(`C:\...\query_engine-windows.dll.node`).
|
||||||
|
The Prisma engines do not seem to be compatible with your system.
|
||||||
|
|
||||||
|
query_engine-windows.dll.node is not a valid Win32 application.
|
||||||
|
|
||||||
|
**Fix:** Set the environment variable:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||||
|
```
|
||||||
|
|
||||||
|
This forces Prisma to use the binary engine mode, which runs the query engine as a separate process and can work via emulation on Windows ARM64.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
React Router:
|
||||||
|
|
||||||
|
- [React Router docs](https://reactrouter.com/home)
|
||||||
|
|
||||||
|
Shopify:
|
||||||
|
|
||||||
|
- [Intro to Shopify apps](https://shopify.dev/docs/apps/getting-started)
|
||||||
|
- [Shopify App React Router docs](https://shopify.dev/docs/api/shopify-app-react-router)
|
||||||
|
- [Shopify CLI](https://shopify.dev/docs/apps/tools/cli)
|
||||||
|
- [Shopify App Bridge](https://shopify.dev/docs/api/app-bridge-library).
|
||||||
|
- [Polaris Web Components](https://shopify.dev/docs/api/app-home/polaris-web-components).
|
||||||
|
- [App extensions](https://shopify.dev/docs/apps/app-extensions/list)
|
||||||
|
- [Shopify Functions](https://shopify.dev/docs/api/functions)
|
||||||
|
|
||||||
|
Internationalization:
|
||||||
|
|
||||||
|
- [Internationalizing your app](https://shopify.dev/docs/apps/best-practices/internationalization/getting-started)
|
||||||
11
app/db.server.js
Normal file
11
app/db.server.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
if (!global.prismaGlobal) {
|
||||||
|
global.prismaGlobal = new PrismaClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = global.prismaGlobal ?? new PrismaClient();
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
51
app/entry.server.jsx
Normal file
51
app/entry.server.jsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { PassThrough } from "stream";
|
||||||
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
import { ServerRouter } from "react-router";
|
||||||
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
|
import { isbot } from "isbot";
|
||||||
|
import { addDocumentResponseHeaders } from "./shopify.server";
|
||||||
|
|
||||||
|
export const streamTimeout = 5000;
|
||||||
|
|
||||||
|
export default async function handleRequest(
|
||||||
|
request,
|
||||||
|
responseStatusCode,
|
||||||
|
responseHeaders,
|
||||||
|
reactRouterContext,
|
||||||
|
) {
|
||||||
|
addDocumentResponseHeaders(request, responseHeaders);
|
||||||
|
const userAgent = request.headers.get("user-agent");
|
||||||
|
const callbackName = isbot(userAgent ?? "") ? "onAllReady" : "onShellReady";
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
|
<ServerRouter context={reactRouterContext} url={request.url} />,
|
||||||
|
{
|
||||||
|
[callbackName]: () => {
|
||||||
|
const body = new PassThrough();
|
||||||
|
const stream = createReadableStreamFromReadable(body);
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
resolve(
|
||||||
|
new Response(stream, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
responseStatusCode = 500;
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Automatically timeout the React renderer after 6 seconds, which ensures
|
||||||
|
// React has enough time to flush down the rejected boundary contents
|
||||||
|
setTimeout(abort, streamTimeout + 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
24
app/root.jsx
Normal file
24
app/root.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://cdn.shopify.com/" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
|
||||||
|
/>
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Outlet />
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
app/routes.js
Normal file
3
app/routes.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { flatRoutes } from "@react-router/fs-routes";
|
||||||
|
|
||||||
|
export default flatRoutes();
|
||||||
54
app/routes/_index/route.jsx
Normal file
54
app/routes/_index/route.jsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { redirect, Form, useLoaderData } from "react-router";
|
||||||
|
import { login } from "../../shopify.server";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.searchParams.get("shop")) {
|
||||||
|
throw redirect(`/app?${url.searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { showForm: Boolean(login) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { showForm } = useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.index}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.heading}>A short heading about [your app]</h1>
|
||||||
|
<p className={styles.text}>
|
||||||
|
A tagline about [your app] that describes your value proposition.
|
||||||
|
</p>
|
||||||
|
{showForm && (
|
||||||
|
<Form className={styles.form} method="post" action="/auth/login">
|
||||||
|
<label className={styles.label}>
|
||||||
|
<span>Shop domain</span>
|
||||||
|
<input className={styles.input} type="text" name="shop" />
|
||||||
|
<span>e.g: my-shop-domain.myshopify.com</span>
|
||||||
|
</label>
|
||||||
|
<button className={styles.button} type="submit">
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>Product feature</strong>. Some detail about your feature and
|
||||||
|
its benefit to your customer.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Product feature</strong>. Some detail about your feature and
|
||||||
|
its benefit to your customer.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Product feature</strong>. Some detail about your feature and
|
||||||
|
its benefit to your customer.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
app/routes/_index/styles.module.css
Normal file
73
app/routes/_index/styles.module.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
.index {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading,
|
||||||
|
.text {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: 3rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list > li {
|
||||||
|
max-width: 20rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 50rem) {
|
||||||
|
.list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list > li {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/routes/api.reviews.jsx
Normal file
45
app/routes/api.reviews.jsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import prisma from "../db.server";
|
||||||
|
|
||||||
|
// GET → fetch reviews
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const productId = url.searchParams.get("productId");
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
return new Response("Product ID missing", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviews = await prisma.review.findMany({
|
||||||
|
where: { productId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(reviews), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST → save review
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const name = formData.get("name");
|
||||||
|
const review = formData.get("review");
|
||||||
|
const productId = formData.get("productId");
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
return new Response("Product ID missing", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.review.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
name,
|
||||||
|
review,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
331
app/routes/app._index.jsx
Normal file
331
app/routes/app._index.jsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFetcher } from "react-router";
|
||||||
|
import { useAppBridge } from "@shopify/app-bridge-react";
|
||||||
|
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const color = ["Red", "Orange", "Yellow", "Green"][
|
||||||
|
Math.floor(Math.random() * 4)
|
||||||
|
];
|
||||||
|
const response = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation populateProduct($product: ProductCreateInput!) {
|
||||||
|
productCreate(product: $product) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
handle
|
||||||
|
status
|
||||||
|
variants(first: 10) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
price
|
||||||
|
barcode
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
demoInfo: metafield(namespace: "$app", key: "demo_info") {
|
||||||
|
jsonValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
product: {
|
||||||
|
title: `${color} Snowboard`,
|
||||||
|
metafields: [
|
||||||
|
{
|
||||||
|
namespace: "$app",
|
||||||
|
key: "demo_info",
|
||||||
|
value: "Created by React Router Template",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const responseJson = await response.json();
|
||||||
|
const product = responseJson.data.productCreate.product;
|
||||||
|
const variantId = product.variants.edges[0].node.id;
|
||||||
|
const variantResponse = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation shopifyReactRouterTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||||||
|
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||||||
|
productVariants {
|
||||||
|
id
|
||||||
|
price
|
||||||
|
barcode
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
productId: product.id,
|
||||||
|
variants: [{ id: variantId, price: "100.00" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const variantResponseJson = await variantResponse.json();
|
||||||
|
const metaobjectResponse = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation shopifyReactRouterTemplateUpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
|
||||||
|
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
|
||||||
|
metaobject {
|
||||||
|
id
|
||||||
|
handle
|
||||||
|
title: field(key: "title") {
|
||||||
|
jsonValue
|
||||||
|
}
|
||||||
|
description: field(key: "description") {
|
||||||
|
jsonValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
handle: {
|
||||||
|
type: "$app:example",
|
||||||
|
handle: "demo-entry",
|
||||||
|
},
|
||||||
|
metaobject: {
|
||||||
|
fields: [
|
||||||
|
{ key: "title", value: "Demo Entry" },
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
value:
|
||||||
|
"This metaobject was created by the Shopify app template to demonstrate the metaobject API.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const metaobjectResponseJson = await metaobjectResponse.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
product: responseJson.data.productCreate.product,
|
||||||
|
variant: variantResponseJson.data.productVariantsBulkUpdate.productVariants,
|
||||||
|
metaobject: metaobjectResponseJson.data.metaobjectUpsert.metaobject,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const shopify = useAppBridge();
|
||||||
|
const isLoading =
|
||||||
|
["loading", "submitting"].includes(fetcher.state) &&
|
||||||
|
fetcher.formMethod === "POST";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetcher.data?.product?.id) {
|
||||||
|
shopify.toast.show("Product created");
|
||||||
|
}
|
||||||
|
}, [fetcher.data?.product?.id, shopify]);
|
||||||
|
const generateProduct = () => fetcher.submit({}, { method: "POST" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<s-page heading="Shopify app template">
|
||||||
|
<s-button slot="primary-action" onClick={generateProduct}>
|
||||||
|
Generate a product
|
||||||
|
</s-button>
|
||||||
|
|
||||||
|
<s-section heading="Congrats on creating a new Shopify app 🎉">
|
||||||
|
<s-paragraph>
|
||||||
|
This embedded app template uses{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
App Bridge
|
||||||
|
</s-link>{" "}
|
||||||
|
interface examples like an{" "}
|
||||||
|
<s-link href="/app/additional">additional page in the app nav</s-link>
|
||||||
|
, as well as an{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/api/admin-graphql"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Admin GraphQL
|
||||||
|
</s-link>{" "}
|
||||||
|
mutation demo, to provide a starting point for app development.
|
||||||
|
</s-paragraph>
|
||||||
|
</s-section>
|
||||||
|
<s-section heading="Get started with products">
|
||||||
|
<s-paragraph>
|
||||||
|
Generate a product with GraphQL and get the JSON output for that
|
||||||
|
product. Learn more about the{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
productCreate
|
||||||
|
</s-link>{" "}
|
||||||
|
mutation in our API references. Includes a product{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/build/custom-data/metafields"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
metafield
|
||||||
|
</s-link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/build/custom-data/metaobjects"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
metaobject
|
||||||
|
</s-link>
|
||||||
|
.
|
||||||
|
</s-paragraph>
|
||||||
|
<s-stack direction="inline" gap="base">
|
||||||
|
<s-button
|
||||||
|
onClick={generateProduct}
|
||||||
|
{...(isLoading ? { loading: true } : {})}
|
||||||
|
>
|
||||||
|
Generate a product
|
||||||
|
</s-button>
|
||||||
|
{fetcher.data?.product && (
|
||||||
|
<s-button
|
||||||
|
onClick={() => {
|
||||||
|
shopify.intents.invoke?.("edit:shopify/Product", {
|
||||||
|
value: fetcher.data?.product?.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
target="_blank"
|
||||||
|
variant="tertiary"
|
||||||
|
>
|
||||||
|
Edit product
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
{fetcher.data?.product && (
|
||||||
|
<s-section heading="productCreate mutation">
|
||||||
|
<s-stack direction="block" gap="base">
|
||||||
|
<s-box
|
||||||
|
padding="base"
|
||||||
|
borderWidth="base"
|
||||||
|
borderRadius="base"
|
||||||
|
background="subdued"
|
||||||
|
>
|
||||||
|
<pre style={{ margin: 0 }}>
|
||||||
|
<code>{JSON.stringify(fetcher.data.product, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</s-box>
|
||||||
|
|
||||||
|
<s-heading>productVariantsBulkUpdate mutation</s-heading>
|
||||||
|
<s-box
|
||||||
|
padding="base"
|
||||||
|
borderWidth="base"
|
||||||
|
borderRadius="base"
|
||||||
|
background="subdued"
|
||||||
|
>
|
||||||
|
<pre style={{ margin: 0 }}>
|
||||||
|
<code>{JSON.stringify(fetcher.data.variant, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</s-box>
|
||||||
|
|
||||||
|
<s-heading>metaobjectUpsert mutation</s-heading>
|
||||||
|
<s-box
|
||||||
|
padding="base"
|
||||||
|
borderWidth="base"
|
||||||
|
borderRadius="base"
|
||||||
|
background="subdued"
|
||||||
|
>
|
||||||
|
<pre style={{ margin: 0 }}>
|
||||||
|
<code>
|
||||||
|
{JSON.stringify(fetcher.data.metaobject, null, 2)}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</s-box>
|
||||||
|
</s-stack>
|
||||||
|
</s-section>
|
||||||
|
)}
|
||||||
|
</s-section>
|
||||||
|
|
||||||
|
<s-section slot="aside" heading="App template specs">
|
||||||
|
<s-paragraph>
|
||||||
|
<s-text>Framework: </s-text>
|
||||||
|
<s-link href="https://reactrouter.com/" target="_blank">
|
||||||
|
React Router
|
||||||
|
</s-link>
|
||||||
|
</s-paragraph>
|
||||||
|
<s-paragraph>
|
||||||
|
<s-text>Interface: </s-text>
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/api/app-home/using-polaris-components"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Polaris web components
|
||||||
|
</s-link>
|
||||||
|
</s-paragraph>
|
||||||
|
<s-paragraph>
|
||||||
|
<s-text>API: </s-text>
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/api/admin-graphql"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
GraphQL
|
||||||
|
</s-link>
|
||||||
|
</s-paragraph>
|
||||||
|
<s-paragraph>
|
||||||
|
<s-text>Custom data: </s-text>
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/build/custom-data"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Metafields & metaobjects
|
||||||
|
</s-link>
|
||||||
|
</s-paragraph>
|
||||||
|
<s-paragraph>
|
||||||
|
<s-text>Database: </s-text>
|
||||||
|
<s-link href="https://www.prisma.io/" target="_blank">
|
||||||
|
Prisma
|
||||||
|
</s-link>
|
||||||
|
</s-paragraph>
|
||||||
|
</s-section>
|
||||||
|
|
||||||
|
<s-section slot="aside" heading="Next steps">
|
||||||
|
<s-unordered-list>
|
||||||
|
<s-list-item>
|
||||||
|
Build an{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/getting-started/build-app-example"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
example app
|
||||||
|
</s-link>
|
||||||
|
</s-list-item>
|
||||||
|
<s-list-item>
|
||||||
|
Explore Shopify's API with{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
GraphiQL
|
||||||
|
</s-link>
|
||||||
|
</s-list-item>
|
||||||
|
</s-unordered-list>
|
||||||
|
</s-section>
|
||||||
|
</s-page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const headers = (headersArgs) => {
|
||||||
|
return boundary.headers(headersArgs);
|
||||||
|
};
|
||||||
37
app/routes/app.additional.jsx
Normal file
37
app/routes/app.additional.jsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export default function AdditionalPage() {
|
||||||
|
return (
|
||||||
|
<s-page heading="Additional page">
|
||||||
|
<s-section heading="Multiple pages">
|
||||||
|
<s-paragraph>
|
||||||
|
The app template comes with an additional page which demonstrates how
|
||||||
|
to create multiple pages within app navigation using{" "}
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
App Bridge
|
||||||
|
</s-link>
|
||||||
|
.
|
||||||
|
</s-paragraph>
|
||||||
|
<s-paragraph>
|
||||||
|
To create your own page and have it show up in the app navigation, add
|
||||||
|
a page inside <code>app/routes</code>, and a link to it in the{" "}
|
||||||
|
<code><ui-nav-menu></code> component found in{" "}
|
||||||
|
<code>app/routes/app.jsx</code>.
|
||||||
|
</s-paragraph>
|
||||||
|
</s-section>
|
||||||
|
<s-section slot="aside" heading="Resources">
|
||||||
|
<s-unordered-list>
|
||||||
|
<s-list-item>
|
||||||
|
<s-link
|
||||||
|
href="https://shopify.dev/docs/apps/design-guidelines/navigation#app-nav"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
App nav best practices
|
||||||
|
</s-link>
|
||||||
|
</s-list-item>
|
||||||
|
</s-unordered-list>
|
||||||
|
</s-section>
|
||||||
|
</s-page>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/routes/app.jsx
Normal file
34
app/routes/app.jsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Outlet, useLoaderData, useRouteError } from "react-router";
|
||||||
|
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||||
|
import { AppProvider } from "@shopify/shopify-app-react-router/react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { apiKey } = useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider embedded apiKey={apiKey}>
|
||||||
|
<s-app-nav>
|
||||||
|
<s-link href="/app">Home</s-link>
|
||||||
|
<s-link href="/app/additional">Additional page</s-link>
|
||||||
|
</s-app-nav>
|
||||||
|
<Outlet />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopify needs React Router to catch some thrown responses, so that their headers are included in the response.
|
||||||
|
export function ErrorBoundary() {
|
||||||
|
return boundary.error(useRouteError());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const headers = (headersArgs) => {
|
||||||
|
return boundary.headers(headersArgs);
|
||||||
|
};
|
||||||
12
app/routes/auth.$.jsx
Normal file
12
app/routes/auth.$.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const headers = (headersArgs) => {
|
||||||
|
return boundary.headers(headersArgs);
|
||||||
|
};
|
||||||
11
app/routes/auth.login/error.server.jsx
Normal file
11
app/routes/auth.login/error.server.jsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { LoginErrorType } from "@shopify/shopify-app-react-router/server";
|
||||||
|
|
||||||
|
export function loginErrorMessage(loginErrors) {
|
||||||
|
if (loginErrors?.shop === LoginErrorType.MissingShop) {
|
||||||
|
return { shop: "Please enter your shop domain to log in" };
|
||||||
|
} else if (loginErrors?.shop === LoginErrorType.InvalidShop) {
|
||||||
|
return { shop: "Please enter a valid shop domain to log in" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
47
app/routes/auth.login/route.jsx
Normal file
47
app/routes/auth.login/route.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { AppProvider } from "@shopify/shopify-app-react-router/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Form, useActionData, useLoaderData } from "react-router";
|
||||||
|
import { login } from "../../shopify.server";
|
||||||
|
import { loginErrorMessage } from "./error.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const errors = loginErrorMessage(await login(request));
|
||||||
|
|
||||||
|
return { errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const errors = loginErrorMessage(await login(request));
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Auth() {
|
||||||
|
const loaderData = useLoaderData();
|
||||||
|
const actionData = useActionData();
|
||||||
|
const [shop, setShop] = useState("");
|
||||||
|
const { errors } = actionData || loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider embedded={false}>
|
||||||
|
<s-page>
|
||||||
|
<Form method="post">
|
||||||
|
<s-section heading="Log in">
|
||||||
|
<s-text-field
|
||||||
|
name="shop"
|
||||||
|
label="Shop domain"
|
||||||
|
details="example.myshopify.com"
|
||||||
|
value={shop}
|
||||||
|
onChange={(e) => setShop(e.currentTarget.value)}
|
||||||
|
autocomplete="on"
|
||||||
|
error={errors.shop}
|
||||||
|
></s-text-field>
|
||||||
|
<s-button type="submit">Log in</s-button>
|
||||||
|
</s-section>
|
||||||
|
</Form>
|
||||||
|
</s-page>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/routes/webhooks.app.scopes_update.jsx
Normal file
22
app/routes/webhooks.app.scopes_update.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { payload, session, topic, shop } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
const current = payload.current;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
await db.session.update({
|
||||||
|
where: {
|
||||||
|
id: session.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
scope: current.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
16
app/routes/webhooks.app.uninstalled.jsx
Normal file
16
app/routes/webhooks.app.uninstalled.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { shop, session, topic } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
|
// Webhook requests can trigger multiple times and after an app has already been uninstalled.
|
||||||
|
// If this webhook already ran, the session may have been deleted previously.
|
||||||
|
if (session) {
|
||||||
|
await db.session.deleteMany({ where: { shop } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
34
app/shopify.server.js
Normal file
34
app/shopify.server.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import "@shopify/shopify-app-react-router/adapters/node";
|
||||||
|
import {
|
||||||
|
ApiVersion,
|
||||||
|
AppDistribution,
|
||||||
|
shopifyApp,
|
||||||
|
} from "@shopify/shopify-app-react-router/server";
|
||||||
|
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||||
|
import prisma from "./db.server";
|
||||||
|
|
||||||
|
const shopify = shopifyApp({
|
||||||
|
apiKey: process.env.SHOPIFY_API_KEY,
|
||||||
|
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
||||||
|
apiVersion: ApiVersion.October25,
|
||||||
|
scopes: process.env.SCOPES?.split(","),
|
||||||
|
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||||
|
authPathPrefix: "/auth",
|
||||||
|
sessionStorage: new PrismaSessionStorage(prisma),
|
||||||
|
distribution: AppDistribution.AppStore,
|
||||||
|
future: {
|
||||||
|
expiringOfflineAccessTokens: true,
|
||||||
|
},
|
||||||
|
...(process.env.SHOP_CUSTOM_DOMAIN
|
||||||
|
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default shopify;
|
||||||
|
export const apiVersion = ApiVersion.October25;
|
||||||
|
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
|
||||||
|
export const authenticate = shopify.authenticate;
|
||||||
|
export const unauthenticated = shopify.unauthenticated;
|
||||||
|
export const login = shopify.login;
|
||||||
|
export const registerWebhooks = shopify.registerWebhooks;
|
||||||
|
export const sessionStorage = shopify.sessionStorage;
|
||||||
0
extensions/.gitkeep
Normal file
0
extensions/.gitkeep
Normal file
BIN
extensions/custom-product-review/assets/thumbs-up.png
Normal file
BIN
extensions/custom-product-review/assets/thumbs-up.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
59
extensions/custom-product-review/blocks/reviews.liquid
Normal file
59
extensions/custom-product-review/blocks/reviews.liquid
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<div class="review-box">
|
||||||
|
<h3>Customer Reviews</h3>
|
||||||
|
|
||||||
|
<form id="review-form">
|
||||||
|
<input type="hidden" name="productId" value="{{ product.id }}" />
|
||||||
|
|
||||||
|
<input type="text" name="name" placeholder="Your name" required />
|
||||||
|
<textarea name="review" placeholder="Write your review" required></textarea>
|
||||||
|
|
||||||
|
<button type="submit">Submit Review</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="reviews-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const productId = "{{ product.id }}";
|
||||||
|
|
||||||
|
async function loadReviews() {
|
||||||
|
const response = await fetch(`/apps/reviews?productId=${productId}`);
|
||||||
|
const reviews = await response.json();
|
||||||
|
|
||||||
|
const container = document.getElementById("reviews-list");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
reviews.forEach(r => {
|
||||||
|
container.innerHTML += `
|
||||||
|
<div style="border:1px solid #ddd; padding:10px; margin:10px 0;">
|
||||||
|
<strong>${r.name}</strong>
|
||||||
|
<p>${r.review}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("review-form").addEventListener("submit", async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
await fetch("/apps/reviews", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reset();
|
||||||
|
loadReviews();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadReviews();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% schema %}
|
||||||
|
{
|
||||||
|
"name": "Reviews",
|
||||||
|
"target": "section",
|
||||||
|
"settings": []
|
||||||
|
}
|
||||||
|
{% endschema %}
|
||||||
10
extensions/custom-product-review/locales/en.default.json
Normal file
10
extensions/custom-product-review/locales/en.default.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"ratings": {
|
||||||
|
"stars": {
|
||||||
|
"label": "Ratings"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"recommendationText": "Recommended Product!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
extensions/custom-product-review/shopify.extension.toml
Normal file
3
extensions/custom-product-review/shopify.extension.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
name = "custom-product-review"
|
||||||
|
type = "theme"
|
||||||
|
uid = "8484764d-5423-3d1b-d07c-77f5332fc21728b9e513"
|
||||||
10
extensions/custom-product-review/snippets/stars.liquid
Normal file
10
extensions/custom-product-review/snippets/stars.liquid
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{ 'ratings.stars.label' | t }}:
|
||||||
|
|
||||||
|
{%- for i in (1..rating) -%}
|
||||||
|
★
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- assign blank_stars = 5 | minus: rating -%}
|
||||||
|
{%- for i in (1..blank_stars) -%}
|
||||||
|
☆
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
12355
package-lock.json
generated
Normal file
12355
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "product-review",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "react-router build",
|
||||||
|
"dev": "shopify app dev",
|
||||||
|
"config:link": "shopify app config link",
|
||||||
|
"generate": "shopify app generate",
|
||||||
|
"deploy": "shopify app deploy",
|
||||||
|
"config:use": "shopify app config use",
|
||||||
|
"env": "shopify app env",
|
||||||
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
|
"docker-start": "npm run setup && npm run start",
|
||||||
|
"setup": "prisma generate && prisma migrate deploy",
|
||||||
|
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"shopify": "shopify",
|
||||||
|
"prisma": "prisma",
|
||||||
|
"graphql-codegen": "graphql-codegen",
|
||||||
|
"vite": "vite",
|
||||||
|
"typecheck": "react-router typegen && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19 <22 || >=22.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.16.3",
|
||||||
|
"@react-router/dev": "^7.12.0",
|
||||||
|
"@react-router/fs-routes": "^7.12.0",
|
||||||
|
"@react-router/node": "^7.12.0",
|
||||||
|
"@react-router/serve": "^7.12.0",
|
||||||
|
"@shopify/app-bridge-react": "^4.2.4",
|
||||||
|
"@shopify/shopify-app-react-router": "^1.1.0",
|
||||||
|
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
||||||
|
"isbot": "^5.1.31",
|
||||||
|
"mongoose": "^9.2.3",
|
||||||
|
"prisma": "^6.16.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router": "^7.12.0",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/api-codegen-preset": "^1.2.0",
|
||||||
|
"@shopify/polaris-types": "^1.0.1",
|
||||||
|
"@types/eslint": "^9.6.1",
|
||||||
|
"@types/node": "^22.18.8",
|
||||||
|
"@types/react": "^18.3.25",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-import-resolver-typescript": "^3.10.1",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"graphql-config": "^5.1.1",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.3.6"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"extensions/*"
|
||||||
|
],
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@shopify/plugin-cloudflare"
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"p-map": "^4.0.0"
|
||||||
|
},
|
||||||
|
"author": "divyapahuja"
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"shop" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"isOnline" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"scope" TEXT,
|
||||||
|
"expires" DATETIME,
|
||||||
|
"accessToken" TEXT NOT NULL,
|
||||||
|
"userId" BIGINT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"locale" TEXT,
|
||||||
|
"collaborator" BOOLEAN DEFAULT false,
|
||||||
|
"emailVerified" BOOLEAN DEFAULT false,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"refreshTokenExpires" DATETIME
|
||||||
|
);
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Review" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"review" TEXT NOT NULL,
|
||||||
|
"rating" INTEGER,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
43
prisma/schema.prisma
Normal file
43
prisma/schema.prisma
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that some adapters may set a maximum length for the String type by default, please ensure your strings are long
|
||||||
|
// enough when changing adapters.
|
||||||
|
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = "file:dev.sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id
|
||||||
|
shop String
|
||||||
|
state String
|
||||||
|
isOnline Boolean @default(false)
|
||||||
|
scope String?
|
||||||
|
expires DateTime?
|
||||||
|
accessToken String
|
||||||
|
userId BigInt?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
email String?
|
||||||
|
accountOwner Boolean @default(false)
|
||||||
|
locale String?
|
||||||
|
collaborator Boolean? @default(false)
|
||||||
|
emailVerified Boolean? @default(false)
|
||||||
|
refreshToken String?
|
||||||
|
refreshTokenExpires DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Review {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
productId String
|
||||||
|
name String
|
||||||
|
review String
|
||||||
|
rating Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
56
shopify.app.toml
Normal file
56
shopify.app.toml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||||
|
|
||||||
|
client_id = "d0e4b4fec718e48a645db5e0a60a518b"
|
||||||
|
name = "product-review"
|
||||||
|
application_url = "https://example.com"
|
||||||
|
embedded = true
|
||||||
|
|
||||||
|
[build]
|
||||||
|
automatically_update_urls_on_dev = true
|
||||||
|
|
||||||
|
[webhooks]
|
||||||
|
api_version = "2026-04"
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
topics = [ "app/uninstalled" ]
|
||||||
|
uri = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
topics = [ "app/scopes_update" ]
|
||||||
|
uri = "/webhooks/app/scopes_update"
|
||||||
|
|
||||||
|
[access_scopes]
|
||||||
|
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||||
|
scopes = "write_metaobject_definitions,write_metaobjects,write_products"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
redirect_urls = [ "https://example.com/api/auth" ]
|
||||||
|
|
||||||
|
[product.metafields.app.demo_info]
|
||||||
|
type = "single_line_text_field"
|
||||||
|
name = "Demo Source Info"
|
||||||
|
description = "Tracks products created by the Shopify app template for development"
|
||||||
|
|
||||||
|
[product.metafields.app.demo_info.access]
|
||||||
|
admin = "merchant_read_write"
|
||||||
|
|
||||||
|
[metaobjects.app.example]
|
||||||
|
name = "Example"
|
||||||
|
description = "An example metaobject definition created by this template"
|
||||||
|
|
||||||
|
[metaobjects.app.example.access]
|
||||||
|
admin = "merchant_read_write"
|
||||||
|
|
||||||
|
[metaobjects.app.example.fields.title]
|
||||||
|
name = "Title"
|
||||||
|
type = "single_line_text_field"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.example.fields.description]
|
||||||
|
name = "Description"
|
||||||
|
type = "multi_line_text_field"
|
||||||
|
|
||||||
|
[app_proxy]
|
||||||
|
url = "/api/reviews"
|
||||||
|
subpath = "reviews"
|
||||||
|
prefix = "apps"
|
||||||
7
shopify.web.toml
Normal file
7
shopify.web.toml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
name = "React Router"
|
||||||
|
roles = ["frontend", "backend"]
|
||||||
|
webhooks_path = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
|
[commands]
|
||||||
|
predev = "npx prisma generate"
|
||||||
|
dev = "npx prisma migrate deploy && npm exec react-router dev"
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": ["@react-router/node", "vite/client", "@shopify/polaris-types"],
|
||||||
|
"rootDirs": [".", "./.react-router/types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
vite.config.js
Normal file
62
vite.config.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176
|
||||||
|
// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the Vite server.
|
||||||
|
// The CLI will eventually stop passing in HOST,
|
||||||
|
// so we can remove this workaround after the next major release.
|
||||||
|
if (
|
||||||
|
process.env.HOST &&
|
||||||
|
(!process.env.SHOPIFY_APP_URL ||
|
||||||
|
process.env.SHOPIFY_APP_URL === process.env.HOST)
|
||||||
|
) {
|
||||||
|
process.env.SHOPIFY_APP_URL = process.env.HOST;
|
||||||
|
delete process.env.HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost")
|
||||||
|
.hostname;
|
||||||
|
let hmrConfig;
|
||||||
|
|
||||||
|
if (host === "localhost") {
|
||||||
|
hmrConfig = {
|
||||||
|
protocol: "ws",
|
||||||
|
host: "localhost",
|
||||||
|
port: 64999,
|
||||||
|
clientPort: 64999,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
hmrConfig = {
|
||||||
|
protocol: "wss",
|
||||||
|
host: host,
|
||||||
|
port: parseInt(process.env.FRONTEND_PORT) || 8002,
|
||||||
|
clientPort: 443,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
allowedHosts: [host],
|
||||||
|
cors: {
|
||||||
|
preflightContinue: true,
|
||||||
|
},
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
hmr: hmrConfig,
|
||||||
|
fs: {
|
||||||
|
// See https://vitejs.dev/config/server-options.html#server-fs-allow for more information
|
||||||
|
allow: ["app", "node_modules"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [reactRouter(), tsconfigPaths()],
|
||||||
|
build: {
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["@shopify/app-bridge-react"],
|
||||||
|
exclude: ["@prisma/client"],
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
external: ["@prisma/client"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user