Inital commit
This commit is contained in:
commit
6cb1d01b0c
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
|
||||||
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
public/build
|
||||||
|
shopify-app-remix
|
||||||
|
*/*.yml
|
||||||
|
.shopify
|
||||||
13
.eslintrc.cjs
Normal file
13
.eslintrc.cjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('@types/eslint').Linter.BaseConfig} */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: [
|
||||||
|
"@remix-run/eslint-config",
|
||||||
|
"@remix-run/eslint-config/node",
|
||||||
|
"@remix-run/eslint-config/jest-testing-library",
|
||||||
|
"prettier",
|
||||||
|
],
|
||||||
|
globals: {
|
||||||
|
shopify: "readonly"
|
||||||
|
},
|
||||||
|
};
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
node_modules
|
||||||
|
.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
|
||||||
38
.graphqlrc.js
Normal file
38
.graphqlrc.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import { LATEST_API_VERSION } from "@shopify/shopify-api";
|
||||||
|
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
|
||||||
|
function getConfig() {
|
||||||
|
const config = {
|
||||||
|
projects: {
|
||||||
|
default: shopifyApiProject({
|
||||||
|
apiType: ApiType.Admin,
|
||||||
|
apiVersion: LATEST_API_VERSION,
|
||||||
|
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;
|
||||||
4
.npmrc
Normal file
4
.npmrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
engine-strict=true
|
||||||
|
auto-install-peers=true
|
||||||
|
shamefully-hoist=true
|
||||||
|
enable-pre-post-scripts=true
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package.json
|
||||||
|
.shadowenv.d
|
||||||
|
.vscode
|
||||||
|
node_modules
|
||||||
|
prisma
|
||||||
|
public
|
||||||
|
.shopify
|
||||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"graphql.vscode-graphql",
|
||||||
|
"shopify.polaris-for-vscode",
|
||||||
|
]
|
||||||
|
}
|
||||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"shopify-dev-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
CHANGELOG.md
Normal file
76
CHANGELOG.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# @shopify/shopify-app-template-remix
|
||||||
|
|
||||||
|
## 2025.06.12
|
||||||
|
- [#1075](https://github.com/Shopify/shopify-app-template-remix/pull/1075) Add Shopify MCP to [VSCode configs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_enable-mcp-support-in-vs-code)
|
||||||
|
|
||||||
|
## 2025.06.12
|
||||||
|
-[#1082](https://github.com/Shopify/shopify-app-template-remix/pull/1082) Remove local Shopify CLI from the template. Developers should use the Shopify CLI [installed globally](https://shopify.dev/docs/api/shopify-cli#installation).
|
||||||
|
## 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
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
# Remove CLI packages since we don't need them in production by default.
|
||||||
|
# Remove this line if you want to run CLI commands in your container.
|
||||||
|
RUN npm remove @shopify/cli
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["npm", "run", "docker-start"]
|
||||||
371
README.md
Normal file
371
README.md
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
# Shopify App Template - Remix
|
||||||
|
|
||||||
|
This is a template for building a [Shopify app](https://shopify.dev/docs/apps/getting-started) using the [Remix](https://remix.run) framework.
|
||||||
|
|
||||||
|
Rather than cloning this repo, you can use your preferred package manager and the Shopify CLI with [these steps](https://shopify.dev/docs/apps/getting-started/create).
|
||||||
|
|
||||||
|
Visit the [`shopify.dev` documentation](https://shopify.dev/docs/api/shopify-app-remix) for more details on the Remix app package.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Before you begin, you'll need the following:
|
||||||
|
|
||||||
|
1. **Node.js**: [Download and install](https://nodejs.org/en/download/) it if you haven't already.
|
||||||
|
2. **Shopify Partner Account**: [Create an account](https://partners.shopify.com/signup) if you don't have one.
|
||||||
|
3. **Test Store**: Set up either a [development store](https://help.shopify.com/en/partners/dashboard/development-stores#create-a-development-store) or a [Shopify Plus sandbox store](https://help.shopify.com/en/partners/dashboard/managing-stores/plus-sandbox-store) for testing your app.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
If you used the CLI to create the template, you can skip this section.
|
||||||
|
|
||||||
|
Using yarn:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
Using npm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Using pnpm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
Using yarn:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Using npm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Using pnpm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pnpm run 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 partners 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 preconfigured with examples of:
|
||||||
|
|
||||||
|
1. Setting up your Shopify app in [/app/shopify.server.ts](https://github.com/Shopify/shopify-app-template-remix/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-remix/blob/main/app/routes/app._index.tsx).
|
||||||
|
3. Responding to webhooks in individual files such as [/app/routes/webhooks.app.uninstalled.tsx](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/routes/webhooks.app.uninstalled.tsx) and [/app/routes/webhooks.app.scopes_update.tsx](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/routes/webhooks.app.scopes_update.tsx)
|
||||||
|
|
||||||
|
Please read the [documentation for @shopify/shopify-app-remix](https://www.npmjs.com/package/@shopify/shopify-app-remix#authenticating-admin-requests) to understand what other API's are available.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
You can run your database of choice on a server yourself or host it with a SaaS company.
|
||||||
|
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
|
||||||
|
|
||||||
|
Remix handles building the app for you, 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/deployment/web) to host your app on a cloud provider like [Heroku](https://www.heroku.com/) or [Fly.io](https://fly.io/).
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### Hosting on Vercel
|
||||||
|
|
||||||
|
Using the Vercel Preset is recommended when hosting your Shopify Remix app on Vercel. You'll also want to ensure imports that would normally come from `@remix-run/node` are imported from `@vercel/remix` instead. Learn more about hosting Remix apps on Vercel [here](https://vercel.com/docs/frameworks/remix).
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// vite.config.ts
|
||||||
|
import { vitePlugin as remix } from "@remix-run/dev";
|
||||||
|
import { defineConfig, type UserConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
+ import { vercelPreset } from '@vercel/remix/vite';
|
||||||
|
|
||||||
|
installGlobals();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
remix({
|
||||||
|
ignoredRouteFiles: ["**/.*"],
|
||||||
|
+ presets: [vercelPreset()],
|
||||||
|
}),
|
||||||
|
tsconfigPaths(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database tables don't exist
|
||||||
|
|
||||||
|
If you get this error:
|
||||||
|
|
||||||
|
```
|
||||||
|
The table `main.Session` does not exist in the current database.
|
||||||
|
```
|
||||||
|
|
||||||
|
You need to create the database for Prisma. Run the `setup` script in `package.json` using your preferred package manager.
|
||||||
|
|
||||||
|
### Navigating/redirecting breaks an embedded app
|
||||||
|
|
||||||
|
Embedded Shopify apps must maintain the user session, which can be tricky inside an iFrame. To avoid issues:
|
||||||
|
|
||||||
|
1. Use `Link` from `@remix-run/react` or `@shopify/polaris`. Do not use `<a>`.
|
||||||
|
2. Use the `redirect` helper returned from `authenticate.admin`. Do not use `redirect` from `@remix-run/node`
|
||||||
|
3. Use `useSubmit` or `<Form/>` from `@remix-run/react`. Do not use a lowercase `<form/>`.
|
||||||
|
|
||||||
|
This only applies if your app is embedded, which it will be by default.
|
||||||
|
|
||||||
|
### Non Embedded
|
||||||
|
|
||||||
|
Shopify apps are best when they are embedded in the Shopify Admin, which is how this template is configured. If you have a reason to not embed your app please make the following changes:
|
||||||
|
|
||||||
|
1. Ensure `embedded = false` is set in [shopify.app.toml`](./shopify.app.toml). [Docs here](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration#global).
|
||||||
|
2. Pass `isEmbeddedApp: false` to `shopifyApp()` in `./app/shopify.server.js|ts`.
|
||||||
|
3. Change the `isEmbeddedApp` prop to `isEmbeddedApp={false}` for the `AppProvider` in `/app/routes/app.jsx|tsx`.
|
||||||
|
4. Remove the `@shopify/app-bridge-react` dependency from [package.json](./package.json) and `vite.config.ts|js`.
|
||||||
|
5. Remove anything imported from `@shopify/app-bridge-react`. For example: `NavMenu`, `TitleBar` and `useAppBridge`.
|
||||||
|
|
||||||
|
### OAuth goes into a loop when I change my app's scopes
|
||||||
|
|
||||||
|
If you change your app's scopes and authentication goes into a loop and fails with a message from Shopify that it tried too many times, you might have forgotten to update your scopes with Shopify.
|
||||||
|
To do that, you can run the `deploy` CLI command.
|
||||||
|
|
||||||
|
Using yarn:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Using npm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Using pnpm:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pnpm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### My 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, the recommended approach is to declare app-specific webhooks in the `shopify.app.toml` file. This approach is easier since Shopify will automatically update changes to webhook subscriptions 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?framework=remix&deliveryMethod=https)
|
||||||
|
|
||||||
|
If you do need shop-specific webhooks, please 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, you can uninstall and reinstall it in your development store. That will force the OAuth process and call the `afterAuth` hook.
|
||||||
|
|
||||||
|
### 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. There are 2 solutions:
|
||||||
|
|
||||||
|
1. Use [app-specific webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-subscriptions) defined in your toml file instead (recommended)
|
||||||
|
2. Create [webhook subscriptions](https://shopify.dev/docs/api/shopify-app-remix/v1/guide-webhooks) using the `shopifyApp` object.
|
||||||
|
|
||||||
|
Test your webhooks with the [Shopify CLI](https://shopify.dev/docs/apps/tools/cli/commands#webhook-trigger) or by triggering events manually in the Shopify admin(e.g. Updating the product title to trigger a `PRODUCTS_UPDATE`).
|
||||||
|
|
||||||
|
### Incorrect GraphQL Hints
|
||||||
|
|
||||||
|
By default the [graphql.vscode-graphql](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql) extension for VS Code 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.
|
||||||
|
|
||||||
|
in this situation, please update the [.graphqlrc.ts](https://github.com/Shopify/shopify-app-template-remix/blob/main/.graphqlrc.ts) config.
|
||||||
|
|
||||||
|
### First parameter has member 'readable' that is not a ReadableStream.
|
||||||
|
|
||||||
|
See [hosting on Vercel](#hosting-on-vercel).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Using Defer & await for streaming responses
|
||||||
|
|
||||||
|
To test [streaming using defer/await](https://remix.run/docs/en/main/guides/streaming) during local development you'll need to use the Shopify CLI slightly differently:
|
||||||
|
|
||||||
|
1. First setup ngrok: https://ngrok.com/product/secure-tunnels
|
||||||
|
2. Create an ngrok tunnel on port 8080: `ngrok http 8080`.
|
||||||
|
3. Copy the forwarding address. This should be something like: `https://f355-2607-fea8-bb5c-8700-7972-d2b5-3f2b-94ab.ngrok-free.app`
|
||||||
|
4. In a separate terminal run `yarn shopify app dev --tunnel-url=TUNNEL_URL:8080` replacing `TUNNEL_URL` for the address you copied in step 3.
|
||||||
|
|
||||||
|
By default the CLI uses a cloudflare tunnel. Unfortunately it cloudflare tunnels wait for the Response stream to finish, then sends one chunk.
|
||||||
|
|
||||||
|
This will not affect production, since tunnels are only for local development.
|
||||||
|
|
||||||
|
### Using MongoDB and Prisma
|
||||||
|
|
||||||
|
By default this template uses SQLlite as the database. It is recommended to move to a persisted database for production. If you choose to use MongoDB, you will need to make some modifications to the schema and prisma configuration. For more information please see the [Prisma MongoDB documentation](https://www.prisma.io/docs/orm/overview/databases/mongodb).
|
||||||
|
|
||||||
|
Alternatively you can use a MongDB database directly with the [MongoDB session storage adapter](https://github.com/Shopify/shopify-app-js/tree/main/packages/apps/session-storage/shopify-app-session-storage-mongodb).
|
||||||
|
|
||||||
|
#### Mapping the id field
|
||||||
|
|
||||||
|
In MongoDB, an ID must be a single field that defines an @id attribute and a @map("\_id") attribute.
|
||||||
|
The prisma adapter expects the ID field to be the ID of the session, and not the \_id field of the document.
|
||||||
|
|
||||||
|
To make this work you can add a new field to the schema that maps the \_id field to the id field. For more information see the [Prisma documentation](https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-an-id-field)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Session {
|
||||||
|
session_id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
id String @unique
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error: The "mongodb" provider is not supported with this command
|
||||||
|
|
||||||
|
MongoDB does not support the [prisma migrate](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/overview) command. Instead, you can use the [prisma db push](https://www.prisma.io/docs/orm/reference/prisma-cli-reference#db-push) command and update the `shopify.web.toml` file with the following commands. If you are using MongoDB please see the [Prisma documentation](https://www.prisma.io/docs/orm/overview/databases/mongodb) for more information.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[commands]
|
||||||
|
predev = "npx prisma generate && npx prisma db push"
|
||||||
|
dev = "npm exec remix vite:dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Prisma needs to perform transactions, which requires your mongodb server to be run as a replica set
|
||||||
|
|
||||||
|
See the [Prisma documentation](https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/mongodb/connect-your-database-node-mongodb) for connecting to a MongoDB database.
|
||||||
|
|
||||||
|
### I want to use Polaris v13.0.0 or higher
|
||||||
|
|
||||||
|
Currently, this template is set up to work on node v18.20 or higher. However, `@shopify/polaris` is limited to v12 because v13 can only run on node v20+.
|
||||||
|
|
||||||
|
You don't have to make any changes to the code in order to be able to upgrade Polaris to v13, but you'll need to do the following:
|
||||||
|
|
||||||
|
- Upgrade your node version to v20.10 or higher.
|
||||||
|
- Update your `Dockerfile` to pull `FROM node:20-alpine` instead of `node:18-alpine`
|
||||||
|
|
||||||
|
### "nbf" claim timestamp check failed
|
||||||
|
|
||||||
|
This error will occur of the `nbf` claim timestamp check failed. This is because the 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.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
Shopify apps are built on a variety of Shopify tools to create a great merchant experience.
|
||||||
|
|
||||||
|
<!-- TODO: Uncomment this after we've updated the docs -->
|
||||||
|
<!-- The [create an app](https://shopify.dev/docs/apps/getting-started/create) tutorial in our developer documentation will guide you through creating a Shopify app using this template. -->
|
||||||
|
|
||||||
|
The Remix app template comes with the following out-of-the-box functionality:
|
||||||
|
|
||||||
|
- [OAuth](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#authenticating-admin-requests): Installing the app and granting permissions
|
||||||
|
- [GraphQL Admin API](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#using-the-shopify-admin-graphql-api): Querying or mutating Shopify admin data
|
||||||
|
- [Webhooks](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#authenticating-webhook-requests): Callbacks sent by Shopify when certain events occur
|
||||||
|
- [AppBridge](https://shopify.dev/docs/api/app-bridge): This template uses the next generation of the Shopify App Bridge library which works in unison with previous versions.
|
||||||
|
- [Polaris](https://polaris.shopify.com/): Design system that enables apps to create Shopify-like experiences
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
This template uses [Remix](https://remix.run). The following Shopify tools are also included to ease app development:
|
||||||
|
|
||||||
|
- [Shopify App Remix](https://shopify.dev/docs/api/shopify-app-remix) provides authentication and methods for interacting with Shopify APIs.
|
||||||
|
- [Shopify App Bridge](https://shopify.dev/docs/apps/tools/app-bridge) allows your app to seamlessly integrate your app within Shopify's Admin.
|
||||||
|
- [Polaris React](https://polaris.shopify.com/) is a powerful design system and component library that helps developers build high quality, consistent experiences for Shopify merchants.
|
||||||
|
- [Webhooks](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#authenticating-webhook-requests): Callbacks sent by Shopify when certain events occur
|
||||||
|
- [Polaris](https://polaris.shopify.com/): Design system that enables apps to create Shopify-like experiences
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Remix Docs](https://remix.run/docs/en/v1)
|
||||||
|
- [Shopify App Remix](https://shopify.dev/docs/api/shopify-app-remix)
|
||||||
|
- [Introduction to Shopify apps](https://shopify.dev/docs/apps/getting-started)
|
||||||
|
- [App authentication](https://shopify.dev/docs/apps/auth)
|
||||||
|
- [Shopify CLI](https://shopify.dev/docs/apps/tools/cli)
|
||||||
|
- [App extensions](https://shopify.dev/docs/apps/app-extensions/list)
|
||||||
|
- [Shopify Functions](https://shopify.dev/docs/api/functions)
|
||||||
|
- [Getting started with internationalizing your app](https://shopify.dev/docs/apps/best-practices/internationalization/getting-started)
|
||||||
459
app.managebrand1.jsx
Normal file
459
app.managebrand1.jsx
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Thumbnail,
|
||||||
|
TextContainer,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Banner,
|
||||||
|
InlineError,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// Load selected brands and access token from Shopify metafield
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const { getTurn14AccessTokenFromMetafield } = await import(
|
||||||
|
"../utils/turn14Token.server"
|
||||||
|
);
|
||||||
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
|
const res = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
metafield(namespace: "turn14", key: "selected_brands") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const data = await res.json();
|
||||||
|
const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
let brands = [];
|
||||||
|
try {
|
||||||
|
brands = JSON.parse(rawValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to parse metafield value:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ brands, accessToken });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle adding products for a specific brand
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const brandId = formData.get("brandId");
|
||||||
|
const rawCount = formData.get("productCount");
|
||||||
|
const productCount = parseInt(rawCount, 10) || 10;
|
||||||
|
|
||||||
|
const { getTurn14AccessTokenFromMetafield } = await import(
|
||||||
|
"../utils/turn14Token.server"
|
||||||
|
);
|
||||||
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
|
// Fetch items from Turn14 API
|
||||||
|
const itemsRes = await fetch(
|
||||||
|
`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const itemsData = await itemsRes.json();
|
||||||
|
|
||||||
|
function slugify(str) {
|
||||||
|
return str
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items1 = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const item of items1) {
|
||||||
|
const attrs = item.attributes;
|
||||||
|
const title = attrs.product_name;
|
||||||
|
const descriptionHtml = attrs.part_description;
|
||||||
|
const vendor = attrs.brand;
|
||||||
|
const productType = attrs.category;
|
||||||
|
const handle = slugify(attrs.part_number || title);
|
||||||
|
const tags = [attrs.category, attrs.subcategory, attrs.brand]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((t) => t.trim());
|
||||||
|
const price = attrs.price?.toString() || "1000";
|
||||||
|
const compare_price = attrs.compare_price?.toString() || "2000";
|
||||||
|
const costPerItem = attrs.purchase_cost?.toString() || "100";
|
||||||
|
const sku = attrs.part_number || '';
|
||||||
|
const collectionId = "gid://shopify/Collection/447659409624";
|
||||||
|
|
||||||
|
// 🔥 Build a CreateMediaInput[] from every file in attrs.files
|
||||||
|
const mediaInputs = (attrs.files || []).map((file) => ({
|
||||||
|
originalSource: file.url,
|
||||||
|
mediaContentType: "IMAGE",
|
||||||
|
alt: `${title} – ${file.media_content}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1️⃣ Create product + images + join collection
|
||||||
|
const createRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation CreateFullProduct(
|
||||||
|
$product: ProductCreateInput!
|
||||||
|
$media: [CreateMediaInput!]
|
||||||
|
) {
|
||||||
|
productCreate(product: $product, media: $media) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
variants(first: 1) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
inventoryItem {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
product: {
|
||||||
|
title,
|
||||||
|
descriptionHtml,
|
||||||
|
vendor,
|
||||||
|
productType,
|
||||||
|
handle,
|
||||||
|
tags,
|
||||||
|
collectionsToJoin: [collectionId],
|
||||||
|
status: "ACTIVE",
|
||||||
|
published: true, // Ensures product is published to the Online Store
|
||||||
|
},
|
||||||
|
media: mediaInputs, // ← now includes all your images
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const createJson = await createRes.json();
|
||||||
|
const createErrs = createJson.data.productCreate.userErrors;
|
||||||
|
if (createErrs.length) {
|
||||||
|
const handleTaken = createErrs.some((e) =>
|
||||||
|
/already in use/i.test(e.message)
|
||||||
|
);
|
||||||
|
if (handleTaken) {
|
||||||
|
results.push({ skippedHandle: handle, reason: "handle already in use" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Create errors: ${createErrs.map((e) => e.message).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProduct = createJson.data.productCreate.product;
|
||||||
|
const {
|
||||||
|
id: variantId,
|
||||||
|
inventoryItem: { id: inventoryItemId },
|
||||||
|
} = newProduct.variants.nodes[0];
|
||||||
|
|
||||||
|
// 2️⃣ Bulk-update variant price + compareAtPrice
|
||||||
|
const variantInputs = [
|
||||||
|
{
|
||||||
|
id: variantId,
|
||||||
|
price: parseFloat(price),
|
||||||
|
...(compare_price && { compareAtPrice: parseFloat(compare_price) }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const priceRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation UpdatePrice(
|
||||||
|
$productId: ID!
|
||||||
|
$variants: [ProductVariantsBulkInput!]!
|
||||||
|
) {
|
||||||
|
productVariantsBulkUpdate(
|
||||||
|
productId: $productId
|
||||||
|
variants: $variants
|
||||||
|
) {
|
||||||
|
productVariants {
|
||||||
|
id
|
||||||
|
price
|
||||||
|
compareAtPrice
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
productId: newProduct.id,
|
||||||
|
variants: variantInputs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const priceJson = await priceRes.json();
|
||||||
|
const priceErrs = priceJson.data.productVariantsBulkUpdate.userErrors;
|
||||||
|
if (priceErrs.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Price update errors: ${priceErrs.map((e) => e.message).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedVariant =
|
||||||
|
priceJson.data.productVariantsBulkUpdate.productVariants[0];
|
||||||
|
|
||||||
|
// 3️⃣ Update inventory item to set SKU & cost
|
||||||
|
const skuRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation SetSKUAndCost(
|
||||||
|
$id: ID!
|
||||||
|
$input: InventoryItemInput!
|
||||||
|
) {
|
||||||
|
inventoryItemUpdate(id: $id, input: $input) {
|
||||||
|
inventoryItem {
|
||||||
|
id
|
||||||
|
sku
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: inventoryItemId,
|
||||||
|
input: {
|
||||||
|
sku,
|
||||||
|
cost: parseFloat(costPerItem),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const skuJson = await skuRes.json();
|
||||||
|
const skuErrs = skuJson.data.inventoryItemUpdate.userErrors;
|
||||||
|
if (skuErrs.length) {
|
||||||
|
throw new Error(
|
||||||
|
`SKU update errors: ${skuErrs.map((e) => e.message).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedItem = skuJson.data.inventoryItemUpdate.inventoryItem;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const publicationsRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
query {
|
||||||
|
publications(first: 5) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const publications = await publicationsRes.json();
|
||||||
|
const onlineStorePublication = publications?.data?.publications?.nodes.find(
|
||||||
|
(pub) => pub.name === "Online Store"
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicationId = onlineStorePublication?.id;
|
||||||
|
console.log("1234567", publicationId)
|
||||||
|
if (publicationId && newProduct.id) {
|
||||||
|
const publishRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation PublishProduct($productId: ID!, $publicationId: ID!) {
|
||||||
|
publishablePublish(
|
||||||
|
id: $productId,
|
||||||
|
input: [{ publicationId: $publicationId }]
|
||||||
|
) {
|
||||||
|
publishable {
|
||||||
|
publishedOnPublication(publicationId: $publicationId)
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
productId: newProduct.id, // Product ID from the productCreate mutation
|
||||||
|
publicationId: publicationId, // Online Store Publication ID
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const publishJson = await publishRes.json();
|
||||||
|
const publishErrs = publishJson.data.publishablePublish.userErrors;
|
||||||
|
|
||||||
|
if (publishErrs.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Publish errors: ${publishErrs.map((e) => e.message).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
productId: newProduct.id,
|
||||||
|
variant: {
|
||||||
|
id: updatedVariant.id,
|
||||||
|
price: updatedVariant.price,
|
||||||
|
compareAtPrice: updatedVariant.compareAtPrice,
|
||||||
|
sku: updatedItem.sku,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return json({ success: true, results });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main React component for managing brand products
|
||||||
|
export default function ManageBrandProducts() {
|
||||||
|
const actionData = useActionData();
|
||||||
|
const { brands, accessToken } = useLoaderData();
|
||||||
|
const [expandedBrand, setExpandedBrand] = useState(null);
|
||||||
|
const [itemsMap, setItemsMap] = useState({});
|
||||||
|
const [loadingMap, setLoadingMap] = useState({});
|
||||||
|
const [productCount, setProductCount] = useState("10");
|
||||||
|
|
||||||
|
const toggleBrandItems = async (brandId) => {
|
||||||
|
const isExpanded = expandedBrand === brandId;
|
||||||
|
if (isExpanded) {
|
||||||
|
setExpandedBrand(null);
|
||||||
|
} else {
|
||||||
|
setExpandedBrand(brandId);
|
||||||
|
if (!itemsMap[brandId]) {
|
||||||
|
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://turn14.data4autos.com/v1/items/brand/${brandId}?page=1`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setItemsMap((prev) => ({ ...prev, [brandId]: data }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching items:", err);
|
||||||
|
}
|
||||||
|
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Manage Brand Products">
|
||||||
|
<Layout>
|
||||||
|
{brands.length === 0 && (
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned>
|
||||||
|
<p>No brands selected yet.</p>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brands.map((brand) => (
|
||||||
|
<React.Fragment key={brand.id}>
|
||||||
|
<Layout.Section oneThird>
|
||||||
|
<Card title={brand.name} sectioned>
|
||||||
|
<Thumbnail
|
||||||
|
source={
|
||||||
|
brand.logo ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
|
alt={brand.name}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>ID:</strong> {brand.id}</p>
|
||||||
|
</TextContainer>
|
||||||
|
<Button fullWidth onClick={() => toggleBrandItems(brand.id)}>
|
||||||
|
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
|
||||||
|
{expandedBrand === brand.id && (
|
||||||
|
<Layout.Section fullWidth>
|
||||||
|
<Card sectioned>
|
||||||
|
{actionData?.success && (
|
||||||
|
<Banner title="✅ Product created!" status="success">
|
||||||
|
<p>
|
||||||
|
{actionData.results.map((r) => (
|
||||||
|
<span key={r.variant.id}>
|
||||||
|
Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="brandId" value={brand.id} />
|
||||||
|
<TextField
|
||||||
|
label="Number of products to add"
|
||||||
|
type="number"
|
||||||
|
name="productCount"
|
||||||
|
value={productCount}
|
||||||
|
onChange={setProductCount}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Button submit primary style={{ marginTop: '1rem' }}>
|
||||||
|
Add First {productCount} Products to Store
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={`Items from ${brand.name}`} sectioned>
|
||||||
|
{loadingMap[brand.id] ? (
|
||||||
|
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||||
|
) : (
|
||||||
|
<div style={{ paddingTop: "1rem" }}>
|
||||||
|
{(itemsMap[brand.id] || []).map((item) => (
|
||||||
|
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section oneThird>
|
||||||
|
<Thumbnail
|
||||||
|
source={
|
||||||
|
item.attributes.thumbnail ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
|
alt={item.attributes.product_name}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Layout.Section>
|
||||||
|
<Layout.Section>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>Part Number:</strong> {item.attributes.part_number}</p>
|
||||||
|
<p><strong>Category:</strong> {item.attributes.category} > {item.attributes.subcategory}</p>
|
||||||
|
</TextContainer>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 { RemixServer } from "@remix-run/react";
|
||||||
|
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||||
|
import { isbot } from "isbot";
|
||||||
|
import { addDocumentResponseHeaders } from "./shopify.server";
|
||||||
|
|
||||||
|
export const streamTimeout = 5000;
|
||||||
|
|
||||||
|
export default async function handleRequest(
|
||||||
|
request,
|
||||||
|
responseStatusCode,
|
||||||
|
responseHeaders,
|
||||||
|
remixContext,
|
||||||
|
) {
|
||||||
|
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(
|
||||||
|
<RemixServer context={remixContext} 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
30
app/root.jsx
Normal file
30
app/root.jsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Links,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<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 "@remix-run/fs-routes";
|
||||||
|
|
||||||
|
export default flatRoutes();
|
||||||
55
app/routes/_index/route.jsx
Normal file
55
app/routes/_index/route.jsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { redirect } from "@remix-run/node";
|
||||||
|
import { Form, useLoaderData } from "@remix-run/react";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/routes/api/turn14/items.jsx
Normal file
24
app/routes/api/turn14/items.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { getTurn14AccessTokenFromMetafield } from "../../../utils/turn14Token.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const brand = url.searchParams.get("brand");
|
||||||
|
const page = url.searchParams.get("page") || "1";
|
||||||
|
|
||||||
|
// Get the Turn14 token (server‐only)
|
||||||
|
const token = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
|
// Fetch Turn14 items
|
||||||
|
const res = await fetch(
|
||||||
|
`https://turn14.data4autos.com/v1/items/brand/${brand}?page=${page}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
return json({ error: "Failed to fetch Turn14 items" }, { status: res.status });
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return json(data);
|
||||||
|
};
|
||||||
334
app/routes/app._index.jsx
Normal file
334
app/routes/app._index.jsx
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Text,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
BlockStack,
|
||||||
|
Box,
|
||||||
|
List,
|
||||||
|
Link,
|
||||||
|
InlineStack,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
product: {
|
||||||
|
title: `${color} Snowboard`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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 shopifyRemixTemplateUpdateVariant($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();
|
||||||
|
|
||||||
|
return {
|
||||||
|
product: responseJson.data.productCreate.product,
|
||||||
|
variant: variantResponseJson.data.productVariantsBulkUpdate.productVariants,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const shopify = useAppBridge();
|
||||||
|
const isLoading =
|
||||||
|
["loading", "submitting"].includes(fetcher.state) &&
|
||||||
|
fetcher.formMethod === "POST";
|
||||||
|
const productId = fetcher.data?.product?.id.replace(
|
||||||
|
"gid://shopify/Product/",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (productId) {
|
||||||
|
shopify.toast.show("Product created");
|
||||||
|
}
|
||||||
|
}, [productId, shopify]);
|
||||||
|
const generateProduct = () => fetcher.submit({}, { method: "POST" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Remix app template">
|
||||||
|
<button variant="primary" onClick={generateProduct}>
|
||||||
|
Generate a product
|
||||||
|
</button>
|
||||||
|
</TitleBar>
|
||||||
|
|
||||||
|
|
||||||
|
<BlockStack gap="500">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="500">
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Link url="/app/app/settings" removeUnderline>
|
||||||
|
Go to Settings Page
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
Congrats on creating a new Shopify app 🎉
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodyMd" as="p">
|
||||||
|
This embedded app template uses{" "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
App Bridge
|
||||||
|
</Link>{" "}
|
||||||
|
interface examples like an{" "}
|
||||||
|
<Link url="/app/additional" removeUnderline>
|
||||||
|
additional page in the app nav
|
||||||
|
</Link>
|
||||||
|
, as well as an{" "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/api/admin-graphql"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
Admin GraphQL
|
||||||
|
</Link>{" "}
|
||||||
|
mutation demo, to provide a starting point for app
|
||||||
|
development.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text as="h3" variant="headingMd">
|
||||||
|
Get started with products
|
||||||
|
</Text>
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
Generate a product with GraphQL and get the JSON output for
|
||||||
|
that product. Learn more about the{" "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
productCreate
|
||||||
|
</Link>{" "}
|
||||||
|
mutation in our API references.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
<InlineStack gap="300">
|
||||||
|
<Button loading={isLoading} onClick={generateProduct}>
|
||||||
|
Generate a product
|
||||||
|
</Button>
|
||||||
|
{fetcher.data?.product && (
|
||||||
|
<Button
|
||||||
|
url={`shopify:admin/products/${productId}`}
|
||||||
|
target="_blank"
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
View product
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</InlineStack>
|
||||||
|
{fetcher.data?.product && (
|
||||||
|
<>
|
||||||
|
<Text as="h3" variant="headingMd">
|
||||||
|
{" "}
|
||||||
|
productCreate mutation
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
padding="400"
|
||||||
|
background="bg-surface-active"
|
||||||
|
borderWidth="025"
|
||||||
|
borderRadius="200"
|
||||||
|
borderColor="border"
|
||||||
|
overflowX="scroll"
|
||||||
|
>
|
||||||
|
<pre style={{ margin: 0 }}>
|
||||||
|
<code>
|
||||||
|
{JSON.stringify(fetcher.data.product, null, 2)}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</Box>
|
||||||
|
<Text as="h3" variant="headingMd">
|
||||||
|
{" "}
|
||||||
|
productVariantsBulkUpdate mutation
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
padding="400"
|
||||||
|
background="bg-surface-active"
|
||||||
|
borderWidth="025"
|
||||||
|
borderRadius="200"
|
||||||
|
borderColor="border"
|
||||||
|
overflowX="scroll"
|
||||||
|
>
|
||||||
|
<pre style={{ margin: 0 }}>
|
||||||
|
<code>
|
||||||
|
{JSON.stringify(fetcher.data.variant, null, 2)}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
<Layout.Section variant="oneThird">
|
||||||
|
<BlockStack gap="500">
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
App template specs
|
||||||
|
</Text>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<InlineStack align="space-between">
|
||||||
|
<Text as="span" variant="bodyMd">
|
||||||
|
Framework
|
||||||
|
</Text>
|
||||||
|
<Link
|
||||||
|
url="https://remix.run"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
Remix
|
||||||
|
</Link>
|
||||||
|
</InlineStack>
|
||||||
|
<InlineStack align="space-between">
|
||||||
|
<Text as="span" variant="bodyMd">
|
||||||
|
Database
|
||||||
|
</Text>
|
||||||
|
<Link
|
||||||
|
url="https://www.prisma.io/"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
Prisma
|
||||||
|
</Link>
|
||||||
|
</InlineStack>
|
||||||
|
<InlineStack align="space-between">
|
||||||
|
<Text as="span" variant="bodyMd">
|
||||||
|
Interface
|
||||||
|
</Text>
|
||||||
|
<span>
|
||||||
|
<Link
|
||||||
|
url="https://polaris.shopify.com"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
Polaris
|
||||||
|
</Link>
|
||||||
|
{", "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
App Bridge
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</InlineStack>
|
||||||
|
<InlineStack align="space-between">
|
||||||
|
<Text as="span" variant="bodyMd">
|
||||||
|
API
|
||||||
|
</Text>
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/api/admin-graphql"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
GraphQL API
|
||||||
|
</Link>
|
||||||
|
</InlineStack>
|
||||||
|
</BlockStack>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
Next steps
|
||||||
|
</Text>
|
||||||
|
<List>
|
||||||
|
<List.Item>
|
||||||
|
Build an{" "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/apps/getting-started/build-app-example"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
example app
|
||||||
|
</Link>{" "}
|
||||||
|
to get started
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
Explore Shopify’s API with{" "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
GraphiQL
|
||||||
|
</Link>
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</BlockStack>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</BlockStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
app/routes/app.add.jsx
Normal file
173
app/routes/app.add.jsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// app/routes/test-add-sample-product.jsx
|
||||||
|
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
InlineError,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// Fetch the first manual collection in the store
|
||||||
|
const resp = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 1, query: "collection_type:manual") {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const result = await resp.json();
|
||||||
|
const collection = result.data.collections.nodes[0] || null;
|
||||||
|
|
||||||
|
return json({ collection });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const collectionId = formData.get("collectionId");
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// 1️⃣ Create a dummy product
|
||||||
|
const createRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation CreateDummyProduct($product: ProductCreateInput!) {
|
||||||
|
productCreate(product: $product) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
product: {
|
||||||
|
title: "Dummy Test Product",
|
||||||
|
descriptionHtml: "<p>This is a dummy product for testing.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const createJson = await createRes.json();
|
||||||
|
const createErrors = createJson.data.productCreate.userErrors;
|
||||||
|
if (createErrors.length) {
|
||||||
|
return json(
|
||||||
|
{ errors: createErrors.map((e) => e.message) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const newProduct = createJson.data.productCreate.product;
|
||||||
|
|
||||||
|
// 2️⃣ Add the new product to the selected collection
|
||||||
|
const addRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation AddToCollection($id: ID!, $productIds: [ID!]!) {
|
||||||
|
collectionAddProducts(id: $id, productIds: $productIds) {
|
||||||
|
collection {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: collectionId,
|
||||||
|
productIds: [newProduct.id],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const addJson = await addRes.json();
|
||||||
|
const addErrors = addJson.data.collectionAddProducts.userErrors;
|
||||||
|
if (addErrors.length) {
|
||||||
|
return json(
|
||||||
|
{ errors: addErrors.map((e) => e.message) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedCollection = addJson.data.collectionAddProducts.collection;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
product: newProduct,
|
||||||
|
collection: updatedCollection,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TestAddSampleProduct() {
|
||||||
|
const { collection } = useLoaderData();
|
||||||
|
const actionData = useActionData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Test: Add Sample Product">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned title="Create & Add Dummy Product">
|
||||||
|
{!collection && (
|
||||||
|
<Banner title="No manual collections found" status="warning">
|
||||||
|
<p>
|
||||||
|
Please create at least one manual (non-smart) collection in
|
||||||
|
your store first.
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.success && (
|
||||||
|
<Banner title="✅ Success!" status="success">
|
||||||
|
<p>
|
||||||
|
Product “{actionData.product.title}” (
|
||||||
|
{actionData.product.id}) was created and added to “
|
||||||
|
{actionData.collection.title}” (
|
||||||
|
{actionData.collection.id}).
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.errors && (
|
||||||
|
<Banner title="🚨 Error" status="critical">
|
||||||
|
<ul>
|
||||||
|
{actionData.errors.map((msg, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<InlineError message={msg} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collection && !actionData?.success && (
|
||||||
|
<Form method="post">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="collectionId"
|
||||||
|
value={collection.id}
|
||||||
|
/>
|
||||||
|
<Button submit primary>
|
||||||
|
Create Dummy Product in “{collection.title}”
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
313
app/routes/app.add1.jsx
Normal file
313
app/routes/app.add1.jsx
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
// app/routes/add-full-product.jsx
|
||||||
|
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Banner,
|
||||||
|
InlineError,
|
||||||
|
Text,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const resp = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 10, query: "collection_type:manual") {
|
||||||
|
nodes { id title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const { data } = await resp.json();
|
||||||
|
return json({ collections: data.collections.nodes });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
descriptionHtml,
|
||||||
|
vendor,
|
||||||
|
productType,
|
||||||
|
handle,
|
||||||
|
tags,
|
||||||
|
price,
|
||||||
|
sku,
|
||||||
|
costPerItem, // new
|
||||||
|
collectionId,
|
||||||
|
imageUrl,
|
||||||
|
} = Object.fromEntries(form);
|
||||||
|
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// 1️⃣ Create product + attach image + join collection
|
||||||
|
const createRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation CreateFullProduct($product: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||||||
|
productCreate(product: $product, media: $media) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
variants(first: 1) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
inventoryItem { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
product: {
|
||||||
|
title,
|
||||||
|
descriptionHtml,
|
||||||
|
vendor,
|
||||||
|
productType,
|
||||||
|
handle,
|
||||||
|
tags: tags.split(",").map((t) => t.trim()),
|
||||||
|
collectionsToJoin: [collectionId],
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
originalSource: imageUrl,
|
||||||
|
mediaContentType: "IMAGE",
|
||||||
|
alt: `${title} image`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const createJson = await createRes.json();
|
||||||
|
if (createJson.data.productCreate.userErrors.length) {
|
||||||
|
return json(
|
||||||
|
{ errors: createJson.data.productCreate.userErrors.map((e) => e.message) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const newProduct = createJson.data.productCreate.product;
|
||||||
|
const variantNode = newProduct.variants.nodes[0];
|
||||||
|
const variantId = variantNode.id;
|
||||||
|
const inventoryItemId = variantNode.inventoryItem.id;
|
||||||
|
|
||||||
|
// 2️⃣ Set price via bulk update
|
||||||
|
const priceRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation UpdatePrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||||||
|
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||||||
|
productVariants { id price }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
productId: newProduct.id,
|
||||||
|
variants: [{ id: variantId, price: parseFloat(price) }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const priceJson = await priceRes.json();
|
||||||
|
if (priceJson.data.productVariantsBulkUpdate.userErrors.length) {
|
||||||
|
return json(
|
||||||
|
{ errors: priceJson.data.productVariantsBulkUpdate.userErrors.map((e) => e.message) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedVariant = priceJson.data.productVariantsBulkUpdate.productVariants[0];
|
||||||
|
|
||||||
|
// 3️⃣ Update inventory item: set SKU + cost per item
|
||||||
|
const skuCostRes = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation SetSKUAndCost($id: ID!, $input: InventoryItemInput!) {
|
||||||
|
inventoryItemUpdate(id: $id, input: $input) {
|
||||||
|
inventoryItem { id sku }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: inventoryItemId,
|
||||||
|
input: {
|
||||||
|
sku,
|
||||||
|
cost: parseFloat(costPerItem), // unit cost in shop’s default currency :contentReference[oaicite:1]{index=1}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const skuCostJson = await skuCostRes.json();
|
||||||
|
if (skuCostJson.data.inventoryItemUpdate.userErrors.length) {
|
||||||
|
return json(
|
||||||
|
{ errors: skuCostJson.data.inventoryItemUpdate.userErrors.map((e) => e.message) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const finalSKU = skuCostJson.data.inventoryItemUpdate.inventoryItem.sku;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
product: newProduct,
|
||||||
|
variant: {
|
||||||
|
id: updatedVariant.id,
|
||||||
|
price: updatedVariant.price,
|
||||||
|
sku: finalSKU,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AddFullProductPage() {
|
||||||
|
const { collections } = useLoaderData();
|
||||||
|
const actionData = useActionData();
|
||||||
|
|
||||||
|
// Controlled inputs
|
||||||
|
const [title, setTitle] = useState("Dummy Test Product");
|
||||||
|
const [descriptionHtml, setDescriptionHtml] = useState(
|
||||||
|
"<p>This is a dummy product for testing.</p>"
|
||||||
|
);
|
||||||
|
const [vendor, setVendor] = useState("Test Vendor");
|
||||||
|
const [productType, setProductType] = useState("Test Type");
|
||||||
|
const [handle, setHandle] = useState("dummy-test-product");
|
||||||
|
const [tags, setTags] = useState("test, dummy, sample");
|
||||||
|
const [price, setPrice] = useState("9.99");
|
||||||
|
const [costPerItem, setCostPerItem] = useState("5.00"); // new default
|
||||||
|
const [sku, setSku] = useState("DUMMY-9");
|
||||||
|
const [imageUrl, setImageUrl] = useState(
|
||||||
|
"https://via.placeholder.com/300x300.png?text=Dummy+Image"
|
||||||
|
);
|
||||||
|
const [collectionId, setCollectionId] = useState(
|
||||||
|
collections[0]?.id || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collectionId && collections.length) {
|
||||||
|
setCollectionId(collections[0].id);
|
||||||
|
}
|
||||||
|
}, [collections, collectionId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Add Full Product (Price + Cost)">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned>
|
||||||
|
{actionData?.success && (
|
||||||
|
<Banner title="✅ Product created!" status="success">
|
||||||
|
<p>
|
||||||
|
{actionData.product.id} – Variant {actionData.variant.id} @ $
|
||||||
|
{actionData.variant.price} (SKU: {actionData.variant.sku})
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{actionData?.errors && (
|
||||||
|
<Banner title="🚨 Errors" status="critical">
|
||||||
|
<ul>
|
||||||
|
{actionData.errors.map((msg, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<InlineError message={msg} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form method="post">
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
name="title"
|
||||||
|
value={title}
|
||||||
|
onChange={setTitle}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Description (HTML)"
|
||||||
|
name="descriptionHtml"
|
||||||
|
multiline={4}
|
||||||
|
value={descriptionHtml}
|
||||||
|
onChange={setDescriptionHtml}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Vendor"
|
||||||
|
name="vendor"
|
||||||
|
value={vendor}
|
||||||
|
onChange={setVendor}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Product Type"
|
||||||
|
name="productType"
|
||||||
|
value={productType}
|
||||||
|
onChange={setProductType}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Handle (URL suffix)"
|
||||||
|
name="handle"
|
||||||
|
value={handle}
|
||||||
|
onChange={setHandle}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Tags (comma-separated)"
|
||||||
|
name="tags"
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
label="Price (USD)"
|
||||||
|
name="price"
|
||||||
|
value={price}
|
||||||
|
onChange={setPrice}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
label="Cost per Item"
|
||||||
|
name="costPerItem"
|
||||||
|
value={costPerItem}
|
||||||
|
onChange={setCostPerItem}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="SKU"
|
||||||
|
name="sku"
|
||||||
|
value={sku}
|
||||||
|
onChange={setSku}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Image URL"
|
||||||
|
name="imageUrl"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={setImageUrl}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Add to Collection"
|
||||||
|
name="collectionId"
|
||||||
|
options={collections.map((c) => ({
|
||||||
|
label: c.title,
|
||||||
|
value: c.id,
|
||||||
|
}))}
|
||||||
|
value={collectionId}
|
||||||
|
onChange={setCollectionId}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button submit primary style={{ marginTop: "1rem" }}>
|
||||||
|
Create Product
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
app/routes/app.additional.jsx
Normal file
83
app/routes/app.additional.jsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Layout,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
BlockStack,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
|
||||||
|
export default function AdditionalPage() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Additional page" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="300">
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
The app template comes with an additional page which
|
||||||
|
demonstrates how to create multiple pages within app navigation
|
||||||
|
using{" "}
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
App Bridge
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
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><NavMenu></Code> component found
|
||||||
|
in <Code>app/routes/app.jsx</Code>.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
<Layout.Section variant="oneThird">
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
Resources
|
||||||
|
</Text>
|
||||||
|
<List>
|
||||||
|
<List.Item>
|
||||||
|
<Link
|
||||||
|
url="https://shopify.dev/docs/apps/design-guidelines/navigation#app-nav"
|
||||||
|
target="_blank"
|
||||||
|
removeUnderline
|
||||||
|
>
|
||||||
|
App nav best practices
|
||||||
|
</Link>
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
padding="025"
|
||||||
|
paddingInlineStart="100"
|
||||||
|
paddingInlineEnd="100"
|
||||||
|
background="bg-surface-active"
|
||||||
|
borderWidth="025"
|
||||||
|
borderColor="border"
|
||||||
|
borderRadius="100"
|
||||||
|
>
|
||||||
|
<code>{children}</code>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
app/routes/app.brands.jsx
Normal file
270
app/routes/app.brands.jsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
|
Thumbnail,
|
||||||
|
Spinner,
|
||||||
|
Toast,
|
||||||
|
Frame,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// Get brands
|
||||||
|
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const brandJson = await brandRes.json();
|
||||||
|
if (!brandRes.ok) {
|
||||||
|
return json({ error: brandJson.error || "Failed to fetch brands" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get collections
|
||||||
|
const gqlRaw = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const gql = await gqlRaw.json();
|
||||||
|
const collections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||||||
|
|
||||||
|
return json({
|
||||||
|
brands: brandJson.data,
|
||||||
|
collections,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
||||||
|
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// Get current collections
|
||||||
|
const gqlRaw = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const gql = await gqlRaw.json();
|
||||||
|
const existingCollections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||||||
|
|
||||||
|
const selectedTitles = selectedBrands.map((b) => b.name.toLowerCase());
|
||||||
|
const logoMap = Object.fromEntries(selectedBrands.map(b => [b.name.toLowerCase(), b.logo]));
|
||||||
|
|
||||||
|
// Delete unselected
|
||||||
|
for (const col of existingCollections) {
|
||||||
|
if (!selectedTitles.includes(col.title.toLowerCase())) {
|
||||||
|
await admin.graphql(`
|
||||||
|
mutation {
|
||||||
|
collectionDelete(input: { id: "${col.id}" }) {
|
||||||
|
deletedCollectionId
|
||||||
|
userErrors { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create new
|
||||||
|
for (const brand of selectedBrands) {
|
||||||
|
const exists = existingCollections.find(
|
||||||
|
(c) => c.title.toLowerCase() === brand.name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
const escapedName = brand.name.replace(/"/g, '\\"');
|
||||||
|
const logo = brand.logo || "";
|
||||||
|
|
||||||
|
await admin.graphql(`
|
||||||
|
mutation {
|
||||||
|
collectionCreate(input: {
|
||||||
|
title: "${escapedName}",
|
||||||
|
descriptionHtml: "Products from brand ${escapedName}",
|
||||||
|
image: {
|
||||||
|
altText: "${escapedName} Logo",
|
||||||
|
src: "${logo}"
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
collection { id }
|
||||||
|
userErrors { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shopDataRaw = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const shopRes = await admin.graphql(`{ shop { id } }`);
|
||||||
|
const shopJson = await shopRes.json();
|
||||||
|
const shopId = shopJson?.data?.shop?.id;
|
||||||
|
|
||||||
|
await admin.graphql(`
|
||||||
|
mutation {
|
||||||
|
metafieldsSet(metafields: [{
|
||||||
|
namespace: "turn14",
|
||||||
|
key: "selected_brands",
|
||||||
|
type: "json",
|
||||||
|
ownerId: "${shopId}",
|
||||||
|
value: ${JSON.stringify(JSON.stringify(selectedBrands))}
|
||||||
|
}]) {
|
||||||
|
metafields {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BrandsPage() {
|
||||||
|
const { brands, collections } = useLoaderData();
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const isSubmitting = fetcher.state === "submitting";
|
||||||
|
const [toastActive, setToastActive] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const collectionTitles = new Set(collections.map((c) => c.title.toLowerCase()));
|
||||||
|
const defaultSelected = brands
|
||||||
|
.filter((b) => collectionTitles.has(b.name.toLowerCase()))
|
||||||
|
.map((b) => b.id);
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState(defaultSelected);
|
||||||
|
const [filteredBrands, setFilteredBrands] = useState(brands);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = search.toLowerCase();
|
||||||
|
setFilteredBrands(brands.filter((b) => b.name.toLowerCase().includes(term)));
|
||||||
|
}, [search, brands]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetcher.data?.success) {
|
||||||
|
setToastActive(true);
|
||||||
|
}
|
||||||
|
}, [fetcher.data]);
|
||||||
|
|
||||||
|
const toggleSelect = (id) => {
|
||||||
|
setSelectedIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastMarkup = toastActive ? (
|
||||||
|
<Toast
|
||||||
|
content="Collections updated successfully!"
|
||||||
|
onDismiss={() => setToastActive(false)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<Page title="Brands List">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<TextField
|
||||||
|
label="Search brands"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Type brand name..."
|
||||||
|
/>
|
||||||
|
</Layout.Section>
|
||||||
|
|
||||||
|
<Layout.Section>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
|
gap: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredBrands.map((brand) => (
|
||||||
|
<Card key={brand.id} sectioned>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||||
|
<Checkbox
|
||||||
|
label=""
|
||||||
|
checked={selectedIds.includes(brand.id)}
|
||||||
|
onChange={() => toggleSelect(brand.id)}
|
||||||
|
/>
|
||||||
|
<Thumbnail
|
||||||
|
source={
|
||||||
|
brand.logo ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
|
alt={brand.name}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong>{brand.name}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Layout.Section>
|
||||||
|
|
||||||
|
<Layout.Section>
|
||||||
|
<fetcher.Form method="post">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="selectedBrands"
|
||||||
|
value={JSON.stringify(selectedBrands)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
submit
|
||||||
|
disabled={selectedIds.length === 0 || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
|
||||||
|
</Button>
|
||||||
|
</fetcher.Form>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
{toastMarkup}
|
||||||
|
</Page>
|
||||||
|
</Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/routes/app.jsx
Normal file
39
app/routes/app.jsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
|
||||||
|
import { boundary } from "@shopify/shopify-app-remix/server";
|
||||||
|
import { AppProvider } from "@shopify/shopify-app-remix/react";
|
||||||
|
import { NavMenu } from "@shopify/app-bridge-react";
|
||||||
|
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { apiKey } = useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider isEmbeddedApp apiKey={apiKey}>
|
||||||
|
<NavMenu>
|
||||||
|
<Link to="/app" rel="home">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link to="/app/additional">Additional page</Link>
|
||||||
|
</NavMenu>
|
||||||
|
<Outlet />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopify needs Remix 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);
|
||||||
|
};
|
||||||
297
app/routes/app.managebrand.jsx
Normal file
297
app/routes/app.managebrand.jsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Thumbnail,
|
||||||
|
TextContainer,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
TextField,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
|
||||||
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
|
const res = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
metafield(namespace: "turn14", key: "selected_brands") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
let brands = [];
|
||||||
|
try {
|
||||||
|
brands = JSON.parse(rawValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to parse metafield value:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ brands, accessToken });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ManageBrandProducts() {
|
||||||
|
const { brands, accessToken } = useLoaderData();
|
||||||
|
const [expandedBrand, setExpandedBrand] = useState(null);
|
||||||
|
const [itemsMap, setItemsMap] = useState({});
|
||||||
|
const [loadingMap, setLoadingMap] = useState({});
|
||||||
|
const [productCount, setProductCount] = useState("10");
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
const toggleBrandItems = async (brandId) => {
|
||||||
|
const isExpanded = expandedBrand === brandId;
|
||||||
|
if (isExpanded) {
|
||||||
|
setExpandedBrand(null);
|
||||||
|
} else {
|
||||||
|
setExpandedBrand(brandId);
|
||||||
|
if (!itemsMap[brandId]) {
|
||||||
|
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://turn14.data4autos.com/v1/items/brand/${brandId}?page=1`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setItemsMap((prev) => ({ ...prev, [brandId]: data }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching items:", err);
|
||||||
|
}
|
||||||
|
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProducts = async (brandId) => {
|
||||||
|
const count = parseInt(productCount || "10");
|
||||||
|
const items = (itemsMap[brandId] || []).slice(0, count);
|
||||||
|
if (!items.length) return alert("No products to add.");
|
||||||
|
setAdding(true);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const attr = item.attributes;
|
||||||
|
|
||||||
|
// Step 1: Create Product (only allowed fields)
|
||||||
|
const productInput = {
|
||||||
|
title: attr.product_name,
|
||||||
|
descriptionHtml: `<p>${attr.part_description}</p>`,
|
||||||
|
vendor: attr.brand,
|
||||||
|
productType: attr.category,
|
||||||
|
tags: [attr.subcategory, attr.brand].filter(Boolean).join(", "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProductRes = await fetch("/admin/api/2023-04/graphql.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation productCreate($input: ProductInput!) {
|
||||||
|
productCreate(input: $input) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: { input: productInput },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProductResult = await createProductRes.json();
|
||||||
|
const product = createProductResult?.data?.productCreate?.product;
|
||||||
|
const productErrors = createProductResult?.data?.productCreate?.userErrors;
|
||||||
|
|
||||||
|
if (productErrors?.length || !product?.id) {
|
||||||
|
console.error("❌ Product create error:", productErrors);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = product.id;
|
||||||
|
|
||||||
|
// Step 2: Create Variant
|
||||||
|
const variantInput = {
|
||||||
|
productId,
|
||||||
|
sku: attr.part_number,
|
||||||
|
barcode: attr.barcode || undefined,
|
||||||
|
price: "0.00",
|
||||||
|
weight: attr.dimensions?.[0]?.weight || 0,
|
||||||
|
weightUnit: "KILOGRAMS",
|
||||||
|
inventoryManagement: "SHOPIFY",
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch("/admin/api/2023-04/graphql.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation productVariantCreate($input: ProductVariantInput!) {
|
||||||
|
productVariantCreate(input: $input) {
|
||||||
|
productVariant {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: { input: variantInput },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Add Image
|
||||||
|
if (attr.thumbnail) {
|
||||||
|
await fetch("/admin/api/2023-04/graphql.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation productImageCreate($productId: ID!, $image: ImageInput!) {
|
||||||
|
productImageCreate(productId: $productId, image: $image) {
|
||||||
|
image {
|
||||||
|
id
|
||||||
|
src
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: {
|
||||||
|
productId,
|
||||||
|
image: {
|
||||||
|
src: attr.thumbnail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Added:", attr.product_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdding(false);
|
||||||
|
alert(`${items.length} products added.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Manage Brand Products">
|
||||||
|
<Layout>
|
||||||
|
{brands.length === 0 && (
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned>
|
||||||
|
<p>No brands selected yet.</p>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brands.map((brand) => (
|
||||||
|
<div key={brand.id}>
|
||||||
|
<Layout.Section oneThird>
|
||||||
|
<Card title={brand.name} sectioned>
|
||||||
|
<Thumbnail
|
||||||
|
source={brand.logo || "https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"}
|
||||||
|
alt={brand.name}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>Brand:</strong> {brand.name}</p>
|
||||||
|
<p><strong>ID:</strong> {brand.id}</p>
|
||||||
|
</TextContainer>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<Button onClick={() => toggleBrandItems(brand.id)} fullWidth>
|
||||||
|
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
|
||||||
|
{expandedBrand === brand.id && (
|
||||||
|
<Layout.Section fullWidth>
|
||||||
|
<Card sectioned>
|
||||||
|
<TextField
|
||||||
|
label="Number of products to add"
|
||||||
|
type="number"
|
||||||
|
value={productCount}
|
||||||
|
onChange={setProductCount}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddProducts(brand.id)}
|
||||||
|
loading={adding}
|
||||||
|
disabled={adding}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
Add First {productCount} Products to Store
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={`Items from ${brand.name}`} sectioned>
|
||||||
|
{loadingMap[brand.id] ? (
|
||||||
|
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||||
|
) : (
|
||||||
|
<div style={{ paddingTop: "1rem" }}>
|
||||||
|
{(itemsMap[brand.id] || []).map(item => (
|
||||||
|
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section oneThird>
|
||||||
|
<Thumbnail
|
||||||
|
source={
|
||||||
|
item.attributes.thumbnail ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
|
alt={item.attributes.product_name}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Layout.Section>
|
||||||
|
<Layout.Section>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>Part Number:</strong> {item.attributes.part_number}</p>
|
||||||
|
<p><strong>Brand:</strong> {item.attributes.brand}</p>
|
||||||
|
<p><strong>Category:</strong> {item.attributes.category} > {item.attributes.subcategory}</p>
|
||||||
|
<p><strong>Dimensions:</strong> {item.attributes.dimensions?.[0]?.length} x {item.attributes.dimensions?.[0]?.width} x {item.attributes.dimensions?.[0]?.height} in</p>
|
||||||
|
</TextContainer>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
429
app/routes/app.managebrand1.jsx
Normal file
429
app/routes/app.managebrand1.jsx
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Thumbnail,
|
||||||
|
TextContainer,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Banner,
|
||||||
|
InlineError,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// Load selected brands and access token from Shopify metafield
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const { getTurn14AccessTokenFromMetafield } = await import(
|
||||||
|
"../utils/turn14Token.server"
|
||||||
|
);
|
||||||
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
|
const res = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
metafield(namespace: "turn14", key: "selected_brands") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const data = await res.json();
|
||||||
|
const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
let brands = [];
|
||||||
|
try {
|
||||||
|
brands = JSON.parse(rawValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to parse metafield value:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ brands, accessToken });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle adding products for a specific brand
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const brandId = formData.get("brandId");
|
||||||
|
const rawCount = formData.get("productCount");
|
||||||
|
const productCount = parseInt(rawCount, 10) || 10;
|
||||||
|
|
||||||
|
const { getTurn14AccessTokenFromMetafield } = await import(
|
||||||
|
"../utils/turn14Token.server"
|
||||||
|
);
|
||||||
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
|
// Fetch items from Turn14 API
|
||||||
|
const itemsRes = await fetch(
|
||||||
|
`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const itemsData = await itemsRes.json();
|
||||||
|
|
||||||
|
function slugify(str) {
|
||||||
|
return str
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items1 = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||||||
|
const results = [];
|
||||||
|
for (const item of items1) {
|
||||||
|
const attrs = item.attributes;
|
||||||
|
|
||||||
|
// 0️⃣ Build and normalize collection titles
|
||||||
|
const category = attrs.category;
|
||||||
|
const subcategory = attrs.subcategory || "";
|
||||||
|
const brand = attrs.brand;
|
||||||
|
const subcats = subcategory
|
||||||
|
.split(/[,\/]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const collectionTitles = Array.from(
|
||||||
|
new Set([category, ...subcats, brand].filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1️⃣ Find or create collections, collect their IDs
|
||||||
|
const collectionIds = [];
|
||||||
|
for (const title of collectionTitles) {
|
||||||
|
// lookup
|
||||||
|
const lookupRes = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 1, query: "title:\\"${title}\\" AND collection_type:manual") {
|
||||||
|
nodes { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const lookupJson = await lookupRes.json();
|
||||||
|
const existing = lookupJson.data.collections.nodes;
|
||||||
|
if (existing.length) {
|
||||||
|
collectionIds.push(existing[0].id);
|
||||||
|
} else {
|
||||||
|
// create
|
||||||
|
const createColRes = await admin.graphql(`
|
||||||
|
mutation($input: CollectionInput!) {
|
||||||
|
collectionCreate(input: $input) {
|
||||||
|
collection { id }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { variables: { input: { title } } });
|
||||||
|
const createColJson = await createColRes.json();
|
||||||
|
const errs = createColJson.data.collectionCreate.userErrors;
|
||||||
|
if (errs.length) {
|
||||||
|
throw new Error(`Could not create collection "${title}": ${errs.map(e => e.message).join(", ")}`);
|
||||||
|
}
|
||||||
|
collectionIds.push(createColJson.data.collectionCreate.collection.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Build tags
|
||||||
|
const tags = [
|
||||||
|
attrs.category,
|
||||||
|
...subcats,
|
||||||
|
attrs.brand,
|
||||||
|
attrs.part_number,
|
||||||
|
attrs.mfr_part_number,
|
||||||
|
attrs.price_group,
|
||||||
|
attrs.units_per_sku && `${attrs.units_per_sku} per SKU`,
|
||||||
|
attrs.barcode
|
||||||
|
].filter(Boolean).map((t) => t.trim());
|
||||||
|
|
||||||
|
// 3️⃣ Prepare media inputs
|
||||||
|
const mediaInputs = (attrs.files || [])
|
||||||
|
.filter((f) => f.type === "Image" && f.url)
|
||||||
|
.map((file) => ({
|
||||||
|
originalSource: file.url,
|
||||||
|
mediaContentType: "IMAGE",
|
||||||
|
alt: `${attrs.product_name} — ${file.media_content}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
// 2️⃣ Pick the longest “Market Description” or fallback to part_description
|
||||||
|
const marketDescs = (attrs.descriptions || [])
|
||||||
|
.filter((d) => d.type === "Market Description")
|
||||||
|
.map((d) => d.description);
|
||||||
|
const descriptionHtml = marketDescs.length
|
||||||
|
? marketDescs.reduce((a, b) => (b.length > a.length ? b : a))
|
||||||
|
: attrs.part_description;
|
||||||
|
|
||||||
|
// 4️⃣ Create product + attach to collections + add media
|
||||||
|
const createProdRes = await admin.graphql(`
|
||||||
|
mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||||||
|
productCreate(product: $prod, media: $media) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
variants(first: 1) {
|
||||||
|
nodes { id inventoryItem { id } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
variables: {
|
||||||
|
prod: {
|
||||||
|
title: attrs.product_name,
|
||||||
|
descriptionHtml: descriptionHtml,
|
||||||
|
vendor: attrs.brand,
|
||||||
|
productType: attrs.category,
|
||||||
|
handle: slugify(attrs.part_number || attrs.product_name),
|
||||||
|
tags,
|
||||||
|
collectionsToJoin: collectionIds,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
media: mediaInputs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const createProdJson = await createProdRes.json();
|
||||||
|
const prodErrs = createProdJson.data.productCreate.userErrors;
|
||||||
|
if (prodErrs.length) {
|
||||||
|
const taken = prodErrs.some((e) => /already in use/i.test(e.message));
|
||||||
|
if (taken) {
|
||||||
|
results.push({ skippedHandle: attrs.part_number, reason: "handle in use" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`ProductCreate errors: ${prodErrs.map(e => e.message).join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = createProdJson.data.productCreate.product;
|
||||||
|
const variantNode = product.variants.nodes[0];
|
||||||
|
const variantId = variantNode.id;
|
||||||
|
const inventoryItemId = variantNode.inventoryItem.id;
|
||||||
|
|
||||||
|
// 5️⃣ Bulk-update variant (price, compare-at, barcode)
|
||||||
|
const price = parseFloat(attrs.price) || 1000;
|
||||||
|
const comparePrice = parseFloat(attrs.compare_price) || null;
|
||||||
|
const barcode = attrs.barcode || "";
|
||||||
|
|
||||||
|
const bulkRes = await admin.graphql(`
|
||||||
|
mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||||||
|
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||||||
|
productVariants { id price compareAtPrice barcode }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
variables: {
|
||||||
|
productId: product.id,
|
||||||
|
variants: [{
|
||||||
|
id: variantId,
|
||||||
|
price,
|
||||||
|
...(comparePrice !== null && { compareAtPrice: comparePrice }),
|
||||||
|
...(barcode && { barcode }),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bulkJson = await bulkRes.json();
|
||||||
|
const bulkErrs = bulkJson.data.productVariantsBulkUpdate.userErrors;
|
||||||
|
if (bulkErrs.length) {
|
||||||
|
throw new Error(`Bulk update errors: ${bulkErrs.map(e => e.message).join(", ")}`);
|
||||||
|
}
|
||||||
|
const updatedVariant = bulkJson.data.productVariantsBulkUpdate.productVariants[0];
|
||||||
|
|
||||||
|
// 6️⃣ Update inventory item (SKU, cost & weight)
|
||||||
|
const costPerItem = parseFloat(attrs.purchase_cost) || 0;
|
||||||
|
const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0;
|
||||||
|
|
||||||
|
const invRes = await admin.graphql(`
|
||||||
|
mutation($id: ID!, $input: InventoryItemInput!) {
|
||||||
|
inventoryItemUpdate(id: $id, input: $input) {
|
||||||
|
inventoryItem {
|
||||||
|
id
|
||||||
|
sku
|
||||||
|
measurement {
|
||||||
|
weight { value unit }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
variables: {
|
||||||
|
id: inventoryItemId,
|
||||||
|
input: {
|
||||||
|
sku: attrs.part_number,
|
||||||
|
cost: costPerItem,
|
||||||
|
measurement: {
|
||||||
|
weight: { value: weightValue, unit: "POUNDS" }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const invJson = await invRes.json();
|
||||||
|
const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
||||||
|
if (invErrs.length) {
|
||||||
|
throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
||||||
|
}
|
||||||
|
const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
||||||
|
|
||||||
|
// 7️⃣ Collect results
|
||||||
|
results.push({
|
||||||
|
productId: product.id,
|
||||||
|
variant: {
|
||||||
|
id: updatedVariant.id,
|
||||||
|
price: updatedVariant.price,
|
||||||
|
compareAtPrice: updatedVariant.compareAtPrice,
|
||||||
|
sku: inventoryItem.sku,
|
||||||
|
barcode: updatedVariant.barcode,
|
||||||
|
weight: inventoryItem.measurement.weight.value,
|
||||||
|
weightUnit: inventoryItem.measurement.weight.unit,
|
||||||
|
},
|
||||||
|
collections: collectionTitles,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return json({ success: true, results });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main React component for managing brand products
|
||||||
|
export default function ManageBrandProducts() {
|
||||||
|
const actionData = useActionData();
|
||||||
|
const { brands, accessToken } = useLoaderData();
|
||||||
|
const [expandedBrand, setExpandedBrand] = useState(null);
|
||||||
|
const [itemsMap, setItemsMap] = useState({});
|
||||||
|
const [loadingMap, setLoadingMap] = useState({});
|
||||||
|
const [productCount, setProductCount] = useState("10");
|
||||||
|
|
||||||
|
const toggleBrandItems = async (brandId) => {
|
||||||
|
const isExpanded = expandedBrand === brandId;
|
||||||
|
if (isExpanded) {
|
||||||
|
setExpandedBrand(null);
|
||||||
|
} else {
|
||||||
|
setExpandedBrand(brandId);
|
||||||
|
if (!itemsMap[brandId]) {
|
||||||
|
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://turn14.data4autos.com/v1/items/brand/${brandId}?page=1`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setItemsMap((prev) => ({ ...prev, [brandId]: data }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching items:", err);
|
||||||
|
}
|
||||||
|
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Manage Brand Products">
|
||||||
|
<Layout>
|
||||||
|
{brands.length === 0 && (
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned>
|
||||||
|
<p>No brands selected yet.</p>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brands.map((brand) => (
|
||||||
|
<React.Fragment key={brand.id}>
|
||||||
|
<Layout.Section oneThird>
|
||||||
|
<Card title={brand.name} sectioned>
|
||||||
|
<Thumbnail
|
||||||
|
source={
|
||||||
|
brand.logo ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
|
alt={brand.name}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>ID:</strong> {brand.id}</p>
|
||||||
|
</TextContainer>
|
||||||
|
<Button fullWidth onClick={() => toggleBrandItems(brand.id)}>
|
||||||
|
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
|
||||||
|
{expandedBrand === brand.id && (
|
||||||
|
<Layout.Section fullWidth>
|
||||||
|
<Card sectioned>
|
||||||
|
{actionData?.success && (
|
||||||
|
<Banner title="✅ Product created!" status="success">
|
||||||
|
<p>
|
||||||
|
{actionData.results.map((r) => (
|
||||||
|
<span key={r.variant.id}>
|
||||||
|
Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="brandId" value={brand.id} />
|
||||||
|
<TextField
|
||||||
|
label="Number of products to add"
|
||||||
|
type="number"
|
||||||
|
name="productCount"
|
||||||
|
value={productCount}
|
||||||
|
onChange={setProductCount}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Button submit primary style={{ marginTop: '1rem' }}>
|
||||||
|
Add First {productCount} Products to Store
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={`Items from ${brand.name}`} sectioned>
|
||||||
|
{loadingMap[brand.id] ? (
|
||||||
|
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||||
|
) : (
|
||||||
|
<div style={{ paddingTop: "1rem" }}>
|
||||||
|
{(itemsMap[brand.id] || []).map((item) => (
|
||||||
|
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section oneThird>
|
||||||
|
<Thumbnail
|
||||||
|
source={
|
||||||
|
item.attributes.thumbnail ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
|
alt={item.attributes.product_name}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Layout.Section>
|
||||||
|
<Layout.Section>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>Part Number:</strong> {item.attributes.part_number}</p>
|
||||||
|
<p><strong>Category:</strong> {item.attributes.category} > {item.attributes.subcategory}</p>
|
||||||
|
</TextContainer>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
app/routes/app.settings.jsx
Normal file
208
app/routes/app.settings.jsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
TextContainer,
|
||||||
|
InlineError,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// Fetch shop info and stored credentials
|
||||||
|
const gqlResponse = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
metafield(namespace: "turn14", key: "credentials") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const shopData = await gqlResponse.json();
|
||||||
|
|
||||||
|
const shopName = shopData?.data?.shop?.name || "Unknown Shop";
|
||||||
|
const metafieldRaw = shopData?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
let creds = {};
|
||||||
|
if (metafieldRaw) {
|
||||||
|
try {
|
||||||
|
creds = JSON.parse(metafieldRaw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse stored credentials:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ shopName, creds });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const clientId = formData.get("client_id") || "";
|
||||||
|
const clientSecret = formData.get("client_secret") || "";
|
||||||
|
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// Fetch shop ID
|
||||||
|
const shopInfo = await admin.graphql(`{ shop { id } }`);
|
||||||
|
const shopId = (await shopInfo.json())?.data?.shop?.id;
|
||||||
|
|
||||||
|
// Get Turn14 token
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch("https://turn14.data4autos.com/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenRes.json();
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
return json({
|
||||||
|
success: false,
|
||||||
|
error: tokenData.error || "Failed to fetch access token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = tokenData.access_token;
|
||||||
|
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessToken,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert as metafield in Shopify
|
||||||
|
const mutation = `
|
||||||
|
mutation {
|
||||||
|
metafieldsSet(metafields: [
|
||||||
|
{
|
||||||
|
ownerId: "${shopId}"
|
||||||
|
namespace: "turn14"
|
||||||
|
key: "credentials"
|
||||||
|
type: "json"
|
||||||
|
value: "${JSON.stringify(credentials).replace(/"/g, '\\"')}"
|
||||||
|
}
|
||||||
|
]) {
|
||||||
|
metafields {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const saveRes = await admin.graphql(mutation);
|
||||||
|
const result = await saveRes.json();
|
||||||
|
|
||||||
|
if (result?.data?.metafieldsSet?.userErrors?.length) {
|
||||||
|
return json({
|
||||||
|
success: false,
|
||||||
|
error: result.data.metafieldsSet.userErrors[0].message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Turn14 token fetch failed:", err);
|
||||||
|
return json({
|
||||||
|
success: false,
|
||||||
|
error: "Network or unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const loaderData = useLoaderData();
|
||||||
|
const actionData = useActionData();
|
||||||
|
|
||||||
|
const savedCreds = loaderData?.creds || {};
|
||||||
|
const shopName = loaderData?.shopName || "Shop";
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState(actionData?.clientId || savedCreds.clientId || "");
|
||||||
|
const [clientSecret, setClientSecret] = useState(actionData?.clientSecret || savedCreds.clientSecret || "");
|
||||||
|
const displayToken = actionData?.accessToken || savedCreds.accessToken;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Turn14 API Settings">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned>
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<p><strong>Connected Shop:</strong> {shopName}</p>
|
||||||
|
</TextContainer>
|
||||||
|
|
||||||
|
<Form method="post">
|
||||||
|
<TextField
|
||||||
|
label="Turn14 Client ID"
|
||||||
|
name="client_id"
|
||||||
|
value={clientId}
|
||||||
|
onChange={setClientId}
|
||||||
|
autoComplete="off"
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Turn14 Client Secret"
|
||||||
|
name="client_secret"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={setClientSecret}
|
||||||
|
autoComplete="off"
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<Button submit primary>
|
||||||
|
Generate Access Token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<TextContainer spacing="tight" style={{ marginTop: "1rem" }}>
|
||||||
|
<InlineError message={`❌ ${actionData.error}`} fieldID="client_id" />
|
||||||
|
</TextContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayToken && (
|
||||||
|
<TextContainer spacing="tight" style={{ marginTop: "1rem" }}>
|
||||||
|
<p style={{ color: "green" }}>✅ Access token:</p>
|
||||||
|
<code style={{
|
||||||
|
background: "#f4f4f4",
|
||||||
|
padding: "10px",
|
||||||
|
display: "block",
|
||||||
|
marginTop: "8px",
|
||||||
|
wordWrap: "break-word"
|
||||||
|
}}>
|
||||||
|
{displayToken}
|
||||||
|
</code>
|
||||||
|
</TextContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
app/routes/auth.$.jsx
Normal file
7
app/routes/auth.$.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
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-remix/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 {};
|
||||||
|
}
|
||||||
65
app/routes/auth.login/route.jsx
Normal file
65
app/routes/auth.login/route.jsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Form, useActionData, useLoaderData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
AppProvider as PolarisAppProvider,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FormLayout,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
TextField,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import polarisTranslations from "@shopify/polaris/locales/en.json";
|
||||||
|
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
|
||||||
|
import { login } from "../../shopify.server";
|
||||||
|
import { loginErrorMessage } from "./error.server";
|
||||||
|
|
||||||
|
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const errors = loginErrorMessage(await login(request));
|
||||||
|
|
||||||
|
return { errors, polarisTranslations };
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PolarisAppProvider i18n={loaderData.polarisTranslations}>
|
||||||
|
<Page>
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<FormLayout>
|
||||||
|
<Text variant="headingMd" as="h2">
|
||||||
|
Log in
|
||||||
|
</Text>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
name="shop"
|
||||||
|
label="Shop domain"
|
||||||
|
helpText="example.myshopify.com"
|
||||||
|
value={shop}
|
||||||
|
onChange={setShop}
|
||||||
|
autoComplete="on"
|
||||||
|
error={errors.shop}
|
||||||
|
/>
|
||||||
|
<Button submit>Log in</Button>
|
||||||
|
</FormLayout>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
</PolarisAppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
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();
|
||||||
|
};
|
||||||
35
app/shopify.server.js
Normal file
35
app/shopify.server.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import "@shopify/shopify-app-remix/adapters/node";
|
||||||
|
import {
|
||||||
|
ApiVersion,
|
||||||
|
AppDistribution,
|
||||||
|
shopifyApp,
|
||||||
|
} from "@shopify/shopify-app-remix/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.January25,
|
||||||
|
scopes: process.env.SCOPES?.split(","),
|
||||||
|
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||||
|
authPathPrefix: "/auth",
|
||||||
|
sessionStorage: new PrismaSessionStorage(prisma),
|
||||||
|
distribution: AppDistribution.AppStore,
|
||||||
|
future: {
|
||||||
|
unstable_newEmbeddedAuthStrategy: true,
|
||||||
|
removeRest: true,
|
||||||
|
},
|
||||||
|
...(process.env.SHOP_CUSTOM_DOMAIN
|
||||||
|
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default shopify;
|
||||||
|
export const apiVersion = ApiVersion.January25;
|
||||||
|
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;
|
||||||
95
app/utils/shopify-products.server.js
Normal file
95
app/utils/shopify-products.server.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// app/utils/shopify-products.server.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a Turn14 item object and a Shopify Admin client,
|
||||||
|
* creates the product + media + joins the specified collection +
|
||||||
|
* sets price + sets SKU.
|
||||||
|
*/
|
||||||
|
export async function createFullProductFromTurn14Item(admin, item, collectionId) {
|
||||||
|
// 1️⃣ Create product + media + join collection
|
||||||
|
const createRes = await admin.graphql(
|
||||||
|
`mutation CreateFullProduct($product: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||||||
|
productCreate(product: $product, media: $media) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
variants(first:1) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
inventoryItem { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
product: {
|
||||||
|
title: item.attributes.product_name,
|
||||||
|
descriptionHtml: item.attributes.part_description,
|
||||||
|
vendor: item.attributes.brand.name,
|
||||||
|
productType: item.attributes.category,
|
||||||
|
handle: item.attributes.part_number.toLowerCase(),
|
||||||
|
tags: [item.attributes.category, item.attributes.subcategory],
|
||||||
|
collectionsToJoin: collectionId ? [collectionId] : [],
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
media: [{
|
||||||
|
originalSource: item.attributes.images?.[0]?.url,
|
||||||
|
mediaContentType: "IMAGE",
|
||||||
|
alt: `${item.attributes.product_name} image`,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { productCreate } = await createRes.json();
|
||||||
|
if (productCreate.userErrors.length) {
|
||||||
|
throw new Error(productCreate.userErrors.map(e => e.message).join(", "));
|
||||||
|
}
|
||||||
|
const newProduct = productCreate.product;
|
||||||
|
const variantNode = newProduct.variants.nodes[0];
|
||||||
|
const variantId = variantNode.id;
|
||||||
|
const inventoryItemId = variantNode.inventoryItem.id;
|
||||||
|
|
||||||
|
// 2️⃣ Update price
|
||||||
|
const priceRes = await admin.graphql(
|
||||||
|
`mutation UpdatePrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||||||
|
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||||||
|
productVariants { id price }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
productId: newProduct.id,
|
||||||
|
variants: [{ id: variantId, price: parseFloat(item.attributes.price) || 0 }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { productVariantsBulkUpdate } = await priceRes.json();
|
||||||
|
if (productVariantsBulkUpdate.userErrors.length) {
|
||||||
|
throw new Error(productVariantsBulkUpdate.userErrors.map(e => e.message).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Set SKU
|
||||||
|
const skuRes = await admin.graphql(
|
||||||
|
`mutation SetSKU($id: ID!, $input: InventoryItemInput!) {
|
||||||
|
inventoryItemUpdate(id: $id, input: $input) {
|
||||||
|
inventoryItem { sku }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: inventoryItemId,
|
||||||
|
input: { sku: item.attributes.part_number },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { inventoryItemUpdate } = await skuRes.json();
|
||||||
|
if (inventoryItemUpdate.userErrors.length) {
|
||||||
|
throw new Error(inventoryItemUpdate.userErrors.map(e => e.message).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { productId: newProduct.id };
|
||||||
|
}
|
||||||
91
app/utils/turn14Token.server.js
Normal file
91
app/utils/turn14Token.server.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export async function getTurn14AccessTokenFromMetafield(request) {
|
||||||
|
const { admin, session } = await authenticate.admin(request);
|
||||||
|
const shop = session.shop;
|
||||||
|
|
||||||
|
// Step 1: Get credentials from metafield
|
||||||
|
const gqlRes = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
id
|
||||||
|
metafield(namespace: "turn14", key: "credentials") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const result = await gqlRes.json();
|
||||||
|
const shopId = result?.data?.shop?.id;
|
||||||
|
const raw = result?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("❌ No Turn14 credentials found in Shopify metafield.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let creds;
|
||||||
|
try {
|
||||||
|
creds = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to parse Turn14 metafield JSON:", err);
|
||||||
|
throw new Error("Malformed Turn14 credential metafield.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(creds.expiresAt);
|
||||||
|
const isExpired = now > expiresAt;
|
||||||
|
|
||||||
|
if (!isExpired && creds.accessToken) {
|
||||||
|
return creds.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⏰ Expired — refresh token from Turn14 API
|
||||||
|
const response = await fetch("https://turn14.data4autos.com/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: creds.clientId,
|
||||||
|
client_secret: creds.clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("❌ Failed to refresh Turn14 token:", data);
|
||||||
|
throw new Error(data.error || "Failed to refresh Turn14 token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = data.access_token;
|
||||||
|
const newExpiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||||
|
|
||||||
|
const newValue = JSON.stringify({
|
||||||
|
clientId: creds.clientId,
|
||||||
|
clientSecret: creds.clientSecret,
|
||||||
|
accessToken: newToken,
|
||||||
|
expiresAt: newExpiresAt,
|
||||||
|
}).replace(/"/g, '\\"');
|
||||||
|
|
||||||
|
// Step 3: Update metafield in Shopify
|
||||||
|
await admin.graphql(`
|
||||||
|
mutation {
|
||||||
|
metafieldsSet(metafields: [
|
||||||
|
{
|
||||||
|
ownerId: "${shopId}"
|
||||||
|
namespace: "turn14"
|
||||||
|
key: "credentials"
|
||||||
|
type: "json"
|
||||||
|
value: "${newValue}"
|
||||||
|
}
|
||||||
|
]) {
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return newToken;
|
||||||
|
}
|
||||||
2
env.d.ts
vendored
Normal file
2
env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="@remix-run/node" />
|
||||||
0
extensions/.gitkeep
Normal file
0
extensions/.gitkeep
Normal file
15442
package-lock.json
generated
Normal file
15442
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
package.json
Normal file
75
package.json
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "turn14-test",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "remix vite: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": "remix-serve ./build/server/index.js",
|
||||||
|
"docker-start": "npm run setup && npm run start",
|
||||||
|
"setup": "prisma generate && prisma migrate deploy",
|
||||||
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"shopify": "shopify",
|
||||||
|
"prisma": "prisma",
|
||||||
|
"graphql-codegen": "graphql-codegen",
|
||||||
|
"vite": "vite"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.20 || ^20.10 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.2.1",
|
||||||
|
"@remix-run/dev": "^2.16.1",
|
||||||
|
"@remix-run/fs-routes": "^2.16.1",
|
||||||
|
"@remix-run/node": "^2.16.1",
|
||||||
|
"@remix-run/react": "^2.16.1",
|
||||||
|
"@shopify/app-bridge-react": "^4.1.6",
|
||||||
|
"@shopify/polaris": "^12.27.0",
|
||||||
|
"@shopify/shopify-app-remix": "^3.7.0",
|
||||||
|
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
|
||||||
|
"isbot": "^5.1.0",
|
||||||
|
"prisma": "^6.2.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"vite-tsconfig-paths": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@remix-run/eslint-config": "^2.16.1",
|
||||||
|
"@remix-run/route-config": "^2.16.1",
|
||||||
|
"@remix-run/serve": "^2.16.8",
|
||||||
|
"@shopify/api-codegen-preset": "^1.1.1",
|
||||||
|
"@types/eslint": "^9.6.1",
|
||||||
|
"@types/node": "^22.2.0",
|
||||||
|
"@types/react": "^18.2.31",
|
||||||
|
"@types/react-dom": "^18.2.14",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"prettier": "^3.2.4",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"extensions/*"
|
||||||
|
],
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@shopify/plugin-cloudflare"
|
||||||
|
],
|
||||||
|
"resolutions": {
|
||||||
|
"@graphql-tools/url-loader": "8.0.16",
|
||||||
|
"@graphql-codegen/client-preset": "4.7.0",
|
||||||
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
||||||
|
"minimatch": "9.0.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@graphql-tools/url-loader": "8.0.16",
|
||||||
|
"@graphql-codegen/client-preset": "4.7.0",
|
||||||
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
||||||
|
"minimatch": "9.0.5"
|
||||||
|
},
|
||||||
|
"author": "mohan"
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
-- 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
|
||||||
|
);
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Turn14Credential" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"shop" TEXT NOT NULL,
|
||||||
|
"clientId" TEXT NOT NULL,
|
||||||
|
"clientSecret" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Turn14Credential_shop_key" ON "Turn14Credential"("shop");
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Turn14Credential {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shop String @unique
|
||||||
|
clientId String
|
||||||
|
clientSecret String
|
||||||
|
accessToken String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
20
remix.config.js
Normal file
20
remix.config.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// 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 remix 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('@remix-run/dev').AppConfig} */
|
||||||
|
module.exports = {
|
||||||
|
ignoredRouteFiles: ["**/.*"],
|
||||||
|
appDirectory: "app",
|
||||||
|
serverModuleFormat: "cjs",
|
||||||
|
dev: { port: process.env.HMR_SERVER_PORT || 8002 },
|
||||||
|
future: {},
|
||||||
|
};
|
||||||
20
server.js
Normal file
20
server.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// server.js
|
||||||
|
import http from "http";
|
||||||
|
import { createRequestHandler } from "@remix-run/node";
|
||||||
|
import * as build from "./build/server/index.js"; // <-- this is your SSR bundle
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3002;
|
||||||
|
const MODE = process.env.NODE_ENV || "production";
|
||||||
|
|
||||||
|
const handler = createRequestHandler({
|
||||||
|
build,
|
||||||
|
mode: MODE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
return handler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(3002, () => {
|
||||||
|
console.log(`🚀 Server listening on http://localhost:${3002}`);
|
||||||
|
});
|
||||||
32
shopify.app.toml
Normal file
32
shopify.app.toml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||||
|
|
||||||
|
client_id = "b7534c980967bad619cfdb9d3f837cfa"
|
||||||
|
name = "turn14-test"
|
||||||
|
handle = "turn14-test-1"
|
||||||
|
application_url = "https://manhattan-fifty-pays-detector.trycloudflare.com"
|
||||||
|
embedded = true
|
||||||
|
|
||||||
|
[build]
|
||||||
|
automatically_update_urls_on_dev = true
|
||||||
|
include_config_on_deploy = true
|
||||||
|
|
||||||
|
[webhooks]
|
||||||
|
api_version = "2025-04"
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
topics = [ "app/scopes_update" ]
|
||||||
|
uri = "/webhooks/app/scopes_update"
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
topics = [ "app/uninstalled" ]
|
||||||
|
uri = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
|
[access_scopes]
|
||||||
|
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||||
|
scopes = "read_inventory,read_products,write_inventory,write_products"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
redirect_urls = ["https://manhattan-fifty-pays-detector.trycloudflare.com/auth/callback", "https://manhattan-fifty-pays-detector.trycloudflare.com/auth/shopify/callback", "https://manhattan-fifty-pays-detector.trycloudflare.com/api/auth/callback"]
|
||||||
|
|
||||||
|
[pos]
|
||||||
|
embedded = false
|
||||||
7
shopify.web.toml
Normal file
7
shopify.web.toml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
name = "remix"
|
||||||
|
roles = ["frontend", "backend"]
|
||||||
|
webhooks_path = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
|
[commands]
|
||||||
|
predev = "npx prisma generate"
|
||||||
|
dev = "npx prisma migrate deploy && npm exec remix vite:dev"
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"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": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
73
vite.config.js
Normal file
73
vite.config.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { vitePlugin as remix } from "@remix-run/dev";
|
||||||
|
import { installGlobals } from "@remix-run/node";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
installGlobals({ nativeFetch: true });
|
||||||
|
|
||||||
|
// 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 remix 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 || 3002),
|
||||||
|
hmr: hmrConfig,
|
||||||
|
fs: {
|
||||||
|
// See https://vitejs.dev/config/server-options.html#server-fs-allow for more information
|
||||||
|
allow: ["app", "node_modules"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
remix({
|
||||||
|
ignoredRouteFiles: ["**/.*"],
|
||||||
|
future: {
|
||||||
|
v3_fetcherPersist: true,
|
||||||
|
v3_relativeSplatPath: true,
|
||||||
|
v3_throwAbortReason: true,
|
||||||
|
v3_lazyRouteDiscovery: true,
|
||||||
|
v3_singleFetch: false,
|
||||||
|
v3_routeConfig: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tsconfigPaths(),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["@shopify/app-bridge-react", "@shopify/polaris"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user