initial commit

This commit is contained in:
TChukwuleta 2024-10-12 19:29:04 +01:00
commit aca5f93151
49 changed files with 17841 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
.cache
build
node_modules

15
.editorconfig Normal file
View 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 lets 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
View File

@ -0,0 +1,6 @@
node_modules
build
public/build
shopify-app-remix
*/*.yml
.shopify

13
.eslintrc.cjs Normal file
View 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"
},
};

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
node_modules
/.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

37
.graphqlrc.js Normal file
View File

@ -0,0 +1,37 @@
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;
}
module.exports = getConfig();

4
.npmrc Normal file
View File

@ -0,0 +1,4 @@
engine-strict=true
auto-install-peers=true
shamefully-hoist=true
enable-pre-post-scripts=true

7
.prettierignore Normal file
View File

@ -0,0 +1,7 @@
package.json
.shadowenv.d
.vscode
node_modules
prisma
public
.shopify

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"graphql.vscode-graphql",
"shopify.polaris-for-vscode"
]
}

25
CHANGELOG.md Normal file
View File

@ -0,0 +1,25 @@
# @shopify/shopify-app-template-remix
## 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

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:18-alpine
EXPOSE 3000
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"]

363
README.md Normal file
View File

@ -0,0 +1,363 @@
# 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 json(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 mandatory webhooks in [/app/routes/webhooks.tsx](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/routes/webhooks.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.
Heres 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/try/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/try/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/try/managed-databases-redis), [Amazon MemoryDB](https://aws.amazon.com/memorydb/) |
| MongoDB | NoSQL / Document | [Digital Ocean](https://www.digitalocean.com/try/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(),
],
});
```
## Gotchas / 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 you app is embedded, which it will be by default.
### Non Embedded
Shopify apps are best when they are embedded into the Shopify Admin. This template is configured that way. If you have a reason to not embed your please make 2 changes:
1. Change the `isEmbeddedApp` prop to false for the `AppProvider` in `/app/routes/app.jsx`
2. Remove any use of App Bridge APIs (`window.shopify`) from your code
3. Update the config for shopifyApp in `app/shopify.server.js`. Pass `isEmbeddedApp: false`
### 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`
## 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
- [REST Admin API](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#using-the-shopify-admin-rest-api): Resource classes to interact with the API
- [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)

11
app/db.server.js Normal file
View File

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
if (process.env.NODE_ENV !== "production") {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
}
const prisma = global.prisma || new PrismaClient();
export default prisma;

53
app/entry.server.jsx Normal file
View File

@ -0,0 +1,53 @@
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";
const ABORT_DELAY = 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}
abortDelay={ABORT_DELAY}
/>,
{
[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);
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}

30
app/root.jsx Normal file
View 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>
);
}

View File

@ -0,0 +1,55 @@
import { json, 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 json({ 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>
);
}

View 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;
}
}

View File

@ -0,0 +1,32 @@
import { json } from '@remix-run/node';
import db from '../db.server';
export async function loader({ request }) {
const url = new URL(request.url);
const shopName = url.searchParams.get("shopName");
if (!shopName) {
return json({
success: false,
message: "shop name is missing",
data: null
});
}
const shop = `${shopName}.myshopify.com`;
const btcpayServerRecord = await db.bTCPayServerStore.findFirst({
where: { shop }
});
if (!btcpayServerRecord) {
return json({
success: false,
data: null,
message: `No record found for shop: ${shopName}`
});
}
return json({
ok: true,
message: `Record found for shop: ${shopName}`,
data: btcpayServerRecord
});
}

164
app/routes/app._index.jsx Normal file
View File

@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { json } from "@remix-run/node";
import { useFetcher, Form, useLoaderData } from "@remix-run/react";
import {
Page,
Text,
Card,
Button,
BlockStack,
InlineGrid,
TextField,
Box,
} from "@shopify/polaris";
import db from "../db.server";
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
export async function loader({ request }) {
await authenticate.admin(request);
const url = new URL(request.url);
const shop = url.searchParams.get('shop');
const btcpayServerRecord = await db.bTCPayServerStore.findFirst({
where: { shop }
});
return { btcpayUrl: btcpayServerRecord?.btcpayUrl, btcpayStoreId: btcpayServerRecord?.btcpayStoreId };
}
export const action = async ({ request }) => {
await authenticate.admin(request);
try {
const url = new URL(request.url);
const shop = url.searchParams.get('shop');
const shopId = shop.split('.myshopify.com')[0];
const formData = Object.fromEntries(await request.formData());
const { btcpayUrl, btcpayStoreId } = formData;
if (!btcpayUrl || !btcpayStoreId) {
return json({ success: false, message: `Please input your BTCPay server domain url and store Id` }, { status: 400 });
}
const isValidBTCPayStore = await validateBTCPayStoreInstance(btcpayUrl, btcpayStoreId, "17404a-7d"); // Replace last parameter with shopId
if (!isValidBTCPayStore) {
return json({ success: false, message: 'Failed to validate BTCPay store. Kindly ensure you have the plugin installed on your BTCPay Server instance.' }, { status: 400 });
}
const shopInfo = await getShopInfo(shop);
var data = {
btcpayUrl,
shopName: shopInfo.name,
shop,
btcpayStoreId,
shopId,
shopOwner: shopInfo.name,
currency: shopInfo.currency,
country: shopInfo.country
};
const user = await db.bTCPayServerStore.upsert({
where: { shop },
update: data,
create: data
});
return json({
btcpayUrl, success: true,
message: "BTCPay URL saved successfully.",
});
} catch (error) {
console.log('Error encountered', error.message)
return json({ success: false, message: `Error: ${error.message}` }, { status: 500 });
}
};
const getShopInfo = async (shopDomain) => {
let session = await findSessionByShop(shopDomain);
const response = await fetch(`https://${shopDomain}/admin/api/2023-04/shop.json`, {
method: 'GET',
headers: {
'X-Shopify-Access-Token': session.accessToken,
'Content-Type': 'application/json',
},
});
const data = await response.json();
return data.shop;
};
const validateBTCPayStoreInstance = async (btcpayUrl, storeId, shopName) => {
try {
const response = await fetch(`${btcpayUrl}/stores/${storeId}/plugins/shopify/validate/${shopName}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.ok;
} catch (error) {
return false;
}
};
const findSessionByShop = async (shop) => {
return await db.session.findFirst({ where: { shop } });
};
export default function Index() {
const fetcher = useFetcher();
const shopify = useAppBridge();
const settings = useLoaderData();
const [formState, setFormState] = useState(settings);
const [errorMessage, setErrorMessage] = useState(null);
useEffect(() => {
if (fetcher.data?.success === false) {
shopify.toast.show(fetcher.data.message || "Error saving BTCPay URL");
setErrorMessage(fetcher.data.message);
} else if (fetcher.data?.success === true) {
setErrorMessage(null);
shopify.toast.show("BTCPay URL saved successfully");
}
}, [fetcher.data, shopify]);
return (
<Page>
<TitleBar title="BTCPay Server - Shopify plugin" />
<BlockStack gap={{ xs: "800", sm: "400" }}>
<InlineGrid columns={{ xs: "1fr", md: "2fr 5fr" }} gap="400">
<Box
as="section"
paddingInlineStart={{ xs: 400, sm: 0 }}
paddingInlineEnd={{ xs: 400, sm: 0 }}
>
<BlockStack gap="400">
<Text as="h3" variant="headingMd">
BTCPay Server
</Text>
<Text as="p" variant="bodyMd">
Please enter your BTCPay server details
</Text>
</BlockStack>
</Box>
<Card roundedAbove="sm">
<fetcher.Form method="POST">
<BlockStack gap="400">
<TextField
label="BTCPay URL"
name="btcpayUrl"
value={formState?.btcpayUrl}
onChange={(v) =>
setFormState({ ...formState, btcpayUrl: v })
}
/>
<TextField
label="BTCPay Store Id"
name="btcpayStoreId"
value={formState?.btcpayStoreId}
onChange={(v) =>
setFormState({ ...formState, btcpayStoreId: v })
}
/>
<Button submit={true}>Save</Button>
</BlockStack>
</fetcher.Form>
</Card>
</InlineGrid>
</BlockStack>
</Page>
);
}

View 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>&lt;NavMenu&gt;</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>
);
}

39
app/routes/app.jsx Normal file
View File

@ -0,0 +1,39 @@
import { json } from "@remix-run/node";
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 json({ 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>
</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);
};

7
app/routes/auth.$.jsx Normal file
View File

@ -0,0 +1,7 @@
import { authenticate } from "../shopify.server";
export const loader = async ({ request }) => {
await authenticate.admin(request);
return null;
};

View 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 {};
}

View File

@ -0,0 +1,66 @@
import { useState } from "react";
import { json } from "@remix-run/node";
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 json({ errors, polarisTranslations });
};
export const action = async ({ request }) => {
const errors = loginErrorMessage(await login(request));
return json({
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>
);
}

View 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();
};

View File

@ -0,0 +1,12 @@
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { shop, payload, topic } = await authenticate.webhook(request);
// Implement handling of mandatory compliance topics
// See: https://shopify.dev/docs/apps/build/privacy-law-compliance
console.log(`Received ${topic} webhook for ${shop}`);
console.log(JSON.stringify(payload, null, 2));
return new Response();
};

View File

@ -0,0 +1,12 @@
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { shop, payload, topic } = await authenticate.webhook(request);
// Implement handling of mandatory compliance topics
// See: https://shopify.dev/docs/apps/build/privacy-law-compliance
console.log(`Received ${topic} webhook for ${shop}`);
console.log(JSON.stringify(payload, null, 2));
return new Response();
};

View File

@ -0,0 +1,12 @@
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { shop, payload, topic } = await authenticate.webhook(request);
// Implement handling of mandatory compliance topics
// See: https://shopify.dev/docs/apps/build/privacy-law-compliance
console.log(`Received ${topic} webhook for ${shop}`);
console.log(JSON.stringify(payload, null, 2));
return new Response();
};

36
app/shopify.server.js Normal file
View File

@ -0,0 +1,36 @@
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 { restResources } from "@shopify/shopify-api/rest/admin/2024-07";
import prisma from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: ApiVersion.October24,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
restResources,
future: {
unstable_newEmbeddedAuthStrategy: true,
},
...(process.env.SHOP_CUSTOM_DOMAIN
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
: {}),
});
export default shopify;
export const apiVersion = ApiVersion.October24;
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;

2
env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="@remix-run/node" />

0
extensions/.gitkeep Normal file
View File

View File

@ -0,0 +1,39 @@
# Checkout UI Extension
Checkout UI extensions let app developers build custom functionality that merchants can install at defined targets in the checkout flow. You can learn more about checkout UI extensions in Shopifys [developer documentation](https://shopify.dev/api/checkout-extensions/checkout).
## Prerequisites
Before you start building your extension, make sure that youve created a [development store](https://shopify.dev/docs/apps/tools/development-stores) with the [checkout extensibility developer preview](https://shopify.dev/docs/api/release-notes/developer-previews#previewing-new-features).
## Your new Extension
Your new extension contains the following files:
- `README.md`, the file you are reading right now.
- `shopify.extension.toml`, the configuration file for your extension. This file defines your extensions name, where it will appear in the checkout, and other metadata.
- `src/Checkout.jsx`, the source code for your extension.
- `locales/en.default.json` and `locales/fr.json`, which contain translations used to [localized your extension](https://shopify.dev/docs/apps/checkout/best-practices/localizing-ui-extensions).
By default, your extension is configured to target the `purchase.checkout.block.render` [extension target](https://shopify.dev/docs/api/checkout-ui-extensions/extension-targets-overview). You will find the target both in your `shopify.extension.toml`, and in the source code of your extension. The default target allows the merchant to configure where in the checkout *they* want your extension to appear. If you are building an extension that is tied to existing UI element in the checkout, such as the cart lines or shipping options, you can change the extension target so that your UI extension will render in the correct location. Check out the list of [all available extension targets](https://shopify.dev/docs/api/checkout-ui-extensions/extension-targets-overview) to get some inspiration for the kinds of content you can provide with checkout UI extensions.
To build your extension, you will need to use APIs provided by Shopify that let you render content, and to read and write data in the checkout. The following resources will help you get started with checkout extensions:
- [APIs by extension target](https://shopify.dev/docs/api/checkout-ui-extensions/targets)
- [All APIs for reading and writing checkout data](https://shopify.dev/docs/api/checkout-ui-extensions/apis)
- [Available components and their properties](https://shopify.dev/docs/api/checkout-ui-extensions/components)
## Useful Links
- [Checkout app documentation](https://shopify.dev/apps/checkout)
- [Checkout UI extension documentation](https://shopify.dev/api/checkout-extensions)
- [Configuration](https://shopify.dev/docs/api/checkout-ui-extensions/configuration)
- [Extension Targets](https://shopify.dev/docs/api/checkout-ui-extensions/targets)
- [API Reference](https://shopify.dev/docs/api/checkout-ui-extensions/apis)
- [UI Components](https://shopify.dev/docs/api/checkout-ui-extensions/components)
- [Checkout UI extension tutorials](https://shopify.dev/docs/apps/checkout)
- [Enable extended delivery instructions](https://shopify.dev/apps/checkout/delivery-instructions)
- [Creating a custom banner](https://shopify.dev/apps/checkout/custom-banners)
- [Thank you and order status pages](https://shopify.dev/docs/apps/checkout/thank-you-order-status)
- [Adding field validation](https://shopify.dev/apps/checkout/validation)
- [Localizing an extension](https://shopify.dev/apps/checkout/localize-ui-extensions)

View File

@ -0,0 +1,5 @@
{
"welcome": "Welcome to the {{target}} extension!",
"iWouldLikeAFreeGiftWithMyOrder": "I would like to receive a free gift with my order",
"attributeChangesAreNotSupported": "Attribute changes are not supported in this checkout"
}

View File

@ -0,0 +1,5 @@
{
"welcome": "Bienvenue dans l'extension {{target}}!",
"iWouldLikeAFreeGiftWithMyOrder": "Je souhaite recevoir un cadeau gratuit avec ma commande",
"attributeChangesAreNotSupported": "Les modifications d'attribut ne sont pas prises en charge dans cette commande"
}

View File

@ -0,0 +1,15 @@
{
"name": "btcpayserverpluginextension",
"private": true,
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"react": "^18.0.0",
"@shopify/ui-extensions": "2024.10.x",
"@shopify/ui-extensions-react": "2024.10.x"
},
"devDependencies": {
"@types/react": "^18.0.0",
"react-reconciler": "0.29.0"
}
}

View File

@ -0,0 +1,52 @@
# Learn more about configuring your checkout UI extension:
# https://shopify.dev/api/checkout-extensions/checkout/configuration
# The version of APIs your extension will receive. Learn more:
# https://shopify.dev/docs/api/usage/versioning
api_version = "2024-10"
[[extensions]]
name = "btcpayserverpluginextension"
handle = "btcpayserverpluginextension"
type = "ui_extension"
# Controls where in Shopify your extension will be injected,
# and the file that contains your extensions source code. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/extension-targets-overview
[[extensions.targeting]]
module = "./src/Checkout.jsx"
target = "purchase.thank-you.block.render"
[extensions.capabilities]
# Gives your extension access to directly query Shopifys storefront API.
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#api-access
api_access = true
# Gives your extension access to make external network calls, using the
# JavaScript `fetch()` API. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#network-access
network_access = true
# Loads metafields on checkout resources, including the cart,
# products, customers, and more. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#metafields
# [[extensions.metafields]]
# namespace = "my_namespace"
# key = "my_key"
# [[extensions.metafields]]
# namespace = "my_namespace"
# key = "my_other_key"
# Defines settings that will be collected from merchants installing
# your extension. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#settings-definition
# [extensions.settings]
# [[extensions.settings.fields]]
# key = "banner_title"
# type = "single_line_text_field"
# name = "Banner title"
# description = "Enter a title for the banner"

View File

@ -0,0 +1,189 @@
import {
reactExtension,
Banner,
BlockStack,
Button,
Text,
useApi,
Link,
Modal,
Spinner,
TextBlock,
useTotalAmount,
useInstructions,
useTranslate,
} from "@shopify/ui-extensions-react/checkout";
import { useEffect, useState } from 'react';
// 1. Choose an extension target
export default reactExtension(
'purchase.thank-you.block.render',
() => <Extension />,
);
function Extension() {
const translate = useTranslate();
const { shop, ui, checkoutToken } = useApi();
const { currencyCode, amount } = useTotalAmount();
const instructions = useInstructions();
const shopifyApplicaitonUrl = 'https://diffs-rays-its-guitar.trycloudflare.com';
const [loading, setLoading] = useState(false);
const [orderId, setOrderId] = useState(null);
const [btcPayUrl, setBtcPayUrl] = useState(null);
const [btcPayStoreId, setBtcPayStoreId] = useState(null);
const [error, setError] = useState(null);
const [modalContent, setModalContent] = useState(null);
const [isTokenValid, setIsTokenValid] = useState(false);
const shopName = shop.myshopifyDomain.split('.myshopify.com')[0];
useEffect(() => {
validateToken();
}, [shopName, checkoutToken]);
const validateToken = async () => {
try {
const storeData = await retrieveBTCPayUrl(shopName);
if (!storeData.btcpayUrl || !storeData.btcpayStoreId) {
setError('Failed to retrieve BTCPay URL or Store ID');
}
setBtcPayStoreId(storeData.btcpayStoreId);
setBtcPayUrl(storeData.btcpayUrl);
await setCheckTokenValidity(storeData.btcpayUrl, storeData.btcpayStoreId, shopName);
} catch (error) {
setError(`Failed to validate token: ${error.message}`);
setIsTokenValid(false);
}
};
const setCheckTokenValidity = async (btcpayurl, btcpaystoreId, shopName) => {
try {
const validationResponse = await validateCheckoutToken(btcpayurl, btcpaystoreId, shopName, checkoutToken.current);
if (validationResponse.success) {
setIsTokenValid(true);
setOrderId(validationResponse.data.orderId);
} else {
setIsTokenValid(false);
}
} catch (error) {
setIsTokenValid(false);
}
};
const retrieveBTCPayUrl = async (shopName) => {
const response = await fetch(`${shopifyApplicaitonUrl}/api/btcpaystores?shopName=${shopName}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
const { btcpayUrl, btcpayStoreId } = data.data;
return { btcpayUrl, btcpayStoreId };
}
};
const validateCheckoutToken = async (btcpayUrl, btcpayStoreId, shopName, token) => {
try {
const response = await fetch(`${btcpayUrl}/stores/${btcpayStoreId}/plugins/shopify/order/${shopName}/${token}`, {
method: 'GET',
headers: {'Content-Type': 'application/json'}
});
if (response.ok) {
return {
success: true,
data: await response.json()
};
} else {
return {
success: false,
error: response.statusText
};
}
} catch (error) {
return {
success: false,
error: error.message,
};
}
};
const CreateBTCPayOrder = async () => {
setLoading(true);
try {
const createOrderPayload = {
shopName: shopName,
orderId: orderId,
currency: currencyCode,
total: amount
};
console.log('Creating BTCPay order...');
const createOrderResponse = await fetch(`${btcPayUrl}/stores/${btcPayStoreId}/plugins/shopify/${shopName}/create-order`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(createOrderPayload)
});
if (!createOrderResponse.ok) {
setError(`Failed to create order: ${createOrderResponse.statusText}`);
}
const orderData = await createOrderResponse.json();
console.log('Order created successfully:', orderData);
setModalContent(orderData);
} catch (error) {
setError(`Failed to load BTC Pay content: ${error.message}`);
} finally {
setLoading(false);
}
};
// 2. Check instructions for feature availability, see https://shopify.dev/docs/api/checkout-ui-extensions/apis/cart-instructions for details
if (!instructions.attributes.canUpdateAttributes) {
// For checkouts such as draft order invoices, cart attributes may not be allowed
// Consider rendering a fallback UI or nothing at all, if the feature is unavailable
return (
<Banner title="btcpaytextshopext" status="warning">
{translate("attributeChangesAreNotSupported")}
</Banner>
);
}
// 3. Render a UI
return (
<>
{isTokenValid && (
<BlockStack>
<Text>Shop name: {shop.name}</Text>
<Text size="large" alignment="center" bold>Review and pay!</Text>
<Text>Please review your order and complete the payment.</Text>
<Button onPress={async () => {
await CreateBTCPayOrder();
ui.overlay.open('btc-pay-modal');
}}
overlay={
<Modal
id="btc-pay-modal"
padding
title="Pay with BTCPay Server"
onClose={async () => {
await setCheckTokenValidity(btcPayUrl, btcPayStoreId, shopName);
}}>
{loading ? (
<Spinner />
) : error ? (
<TextBlock>{error}</TextBlock>
) : modalContent ? (
<Link to={`${modalContent.externalPaymentLink}`} external>
Click to pay invoice
</Link>
) : (
<TextBlock>No content available</TextBlock>
)}
</Modal>
}>Complete Payment</Button>
</BlockStack>
)}
</>
);
}

16008
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

63
package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "btcpayserverdemoplugin",
"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": "^5.11.0",
"@remix-run/dev": "^2.7.1",
"@remix-run/node": "^2.7.1",
"@remix-run/react": "^2.7.1",
"@remix-run/serve": "^2.7.1",
"@shopify/app-bridge-react": "^4.1.2",
"@shopify/polaris": "^12.0.0",
"@shopify/shopify-app-remix": "^3.3.2",
"@shopify/shopify-app-session-storage-prisma": "^5.0.2",
"isbot": "^5.1.0",
"prisma": "^5.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vite-tsconfig-paths": "^5.0.1"
},
"devDependencies": {
"@remix-run/eslint-config": "^2.7.1",
"@shopify/api-codegen-preset": "^1.1.1",
"@types/eslint": "^8.40.0",
"@types/node": "^22.2.0",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.2.4",
"typescript": "^5.2.2",
"vite": "^5.1.3"
},
"workspaces": [
"extensions/*"
],
"trustedDependencies": [
"@shopify/plugin-cloudflare"
],
"resolutions": {},
"overrides": {},
"author": "PC"
}

View File

@ -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
);

View File

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "BTCPayServerStore" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"btcpayUrl" TEXT NOT NULL,
"shopName" TEXT,
"shop" TEXT NOT NULL,
"btcpayStoreId" TEXT NOT NULL,
"shopId" TEXT,
"shopOwner" TEXT,
"currency" TEXT,
"country" TEXT
);
-- CreateIndex
CREATE UNIQUE INDEX "BTCPayServerStore_shop_key" ON "BTCPayServerStore"("shop");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

44
prisma/schema.prisma Normal file
View File

@ -0,0 +1,44 @@
// 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 BTCPayServerStore {
id Int @id @default(autoincrement())
btcpayUrl String
shopName String?
shop String @unique
btcpayStoreId String
shopId String?
shopOwner String?
currency String?
country String?
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

20
remix.config.js Normal file
View 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: {},
};

45
shopify.app.toml Normal file
View File

@ -0,0 +1,45 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = "75e5453a33db2962a714dda480eea008"
name = "btcpayserverdemoplugin"
handle = "btcpayserverdemoplugin"
application_url = "https://vertex-tanks-asus-plans.trycloudflare.com"
embedded = true
[build]
automatically_update_urls_on_dev = true
dev_store_url = "quickstart-4c9539b2.myshopify.com"
include_config_on_deploy = true
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = ""
[auth]
redirect_urls = [
"https://vertex-tanks-asus-plans.trycloudflare.com/auth/callback",
"https://vertex-tanks-asus-plans.trycloudflare.com/auth/shopify/callback",
"https://vertex-tanks-asus-plans.trycloudflare.com/api/auth/callback"
]
[webhooks]
api_version = "2024-10"
[[webhooks.subscriptions]]
uri = "/webhooks/customers/data_request"
compliance_topics = [ "customers/data_request" ]
[[webhooks.subscriptions]]
uri = "/webhooks/customers/redact"
compliance_topics = [ "customers/redact" ]
[[webhooks.subscriptions]]
uri = "/webhooks/shop/redact"
compliance_topics = [ "shop/redact" ]
[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "/webhooks/app/uninstalled"
[pos]
embedded = false

7
shopify.web.toml Normal file
View 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
View 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"]
}
}

55
vite.config.js Normal file
View File

@ -0,0 +1,55 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176
// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the 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: {
port: Number(process.env.PORT || 3000),
hmr: hmrConfig,
fs: {
// See https://vitejs.dev/config/server-options.html#server-fs-allow for more information
allow: ["app", "node_modules"],
},
},
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
}),
tsconfigPaths(),
],
build: {
assetsInlineLimit: 0,
},
});