Compare commits

..

4 Commits

Author SHA1 Message Date
Chukwuleta Tobechi
d14588f55f Include order scopes 2025-02-12 05:51:00 -08:00
Chukwuleta Tobechi
6e845a85cd manage routing for npm build 2025-02-10 12:57:42 -08:00
Chukwuleta Tobechi
329994bde9 test locally 2025-02-10 08:16:49 -08:00
Chukwuleta Tobechi
c5c45db645 shopify routes revamp 2025-02-10 03:03:30 -08:00
73 changed files with 16633 additions and 613 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
DATABASE_URL=file:/app/data/database.sqlite
SHOPIFY_APP_URL=http://localhost:3000/shopifyapp/
SHOPIFY_API_KEY=your_client_id
SHOPIFY_API_SECRET=your_client_secret
DOMAIN=YOUR_HOSTED_APP_URL.COM
LETSENCRYPT_EMAIL=johndoe@example.com

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"
},
};

17
.gitattributes vendored
View File

@ -1,17 +0,0 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.c text
*.h text
# Declare files that will always have CRLF line endings on checkout.
*.sln text eol=crlf
# Declare files that will always have CRLF line endings on checkout.
*.sh text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary

View File

@ -1,26 +0,0 @@
name: CI
on:
push:
tags:
- '*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Publishing docker image
env:
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: ./.github/workflows/publish-docker.sh

View File

@ -1,11 +0,0 @@
#!/usr/bin/env bash
TAG="${GITHUB_REF#refs/tags/}"
sudo docker login --username=$DOCKERHUB_USERNAME --password=$DOCKERHUB_TOKEN
sudo docker buildx create --use
sudo docker buildx build \
-t "btcpayserver/shopify-app-deployer:${TAG}" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push .

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

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

View File

@ -1,9 +1,9 @@
FROM node:20-alpine3.20
FROM node:18-alpine3.20
# Install xdg-utils (BTCPay Server mod)
RUN apk add --no-cache bash xdg-utils git
RUN apk add --no-cache xdg-utils
# Install Shopify CLI globally (BTCPay Server mod)
RUN npm install -g @shopify/cli@3.92.1
RUN npm install -g @shopify/cli@latest
EXPOSE 3000
@ -13,8 +13,13 @@ ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm install && npm ci --omit=dev && npm cache clean --force
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 install && npm cache clean --force
ENTRYPOINT ["/app/docker-entrypoint.sh"]
RUN npm run build
CMD ["npm", "run", "docker-start"]

View File

@ -2,27 +2,6 @@
This is an app that provides a checkout extension to make it possible for Shopify merchants to accept Bitcoin payments using BTCPay Server.
The docker image run a lightweight API in `deploy.sh` running on port `5000`.
This host a single route on `/deploy`, which will build and deploy on shopify server the app.
```json
{
"cliToken": "PARTNER CLI TOKEN",
"clientId": "APP_CLIENT_ID",
"pluginUrl": "BTCPAY_PLUGIN_URL",
"appName": "APP NAME"
}
```
It streams back the logs of the deployment process to the client.
This server is used by the [BTCPay Server Shopify plugin](https://github.com/btcpayserver/btcpayserver-shopify-plugin) in order to deploy the app.
You can find installation instructions on our official documentation page: [BTCPay Server for Shopify](https://docs.btcpayserver.org/ShopifyV2/)
## Maintainers
The image is hosted on [docker hub](https://hub.docker.com/r/btcpayserver/shopify-app-deployer), to publish a new one, push a new tag to the repository. The github action is set up to create the image and upload it.
Increment `VERSION` when you want the plugin to be able to notify the user that a new app deployment is needed.
This app is based on the official [Shopify Remix app template](https://github.com/Shopify/shopify-app-template-remix). Other than the original template we have a docker-compose.yml file in place that makes sure nginx and Let's Encrypt companion is deployed and can be used on any cheap VPS.

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(`/shopifyapp/app?${url.searchParams.toString()}`);
}
return json({ showForm: Boolean(login) });
};
export default function App() {
return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>Accept Bitcoin in Shopify with BTCPay Server</h1>
<p className={styles.text}>
Add Bitcoin as a payment option to your store with{" "}
<a
href="https://btcpayserver.org/"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#3498db", textDecoration: "underline" }}
>
BTCPay Server
</a>
. It's free, simple, secure, and puts you in full control.
</p>
<ul className={styles.list}>
<li>
<strong>Zero fees: </strong> Enjoy a payment gateway with no fees. Yes, You saw that right. Zero fees!
</li>
<li>
<strong>No middlemen: </strong> Say goodbye to intermediaries and tedious paperwork, and get your money directly in your wallet
</li>
<li>
<strong>Community-driven support: </strong> Get responsive assistance from our dedicated community
</li>
</ul>
<div>
<a className={styles.button}
target="_blank"
href="https://docs.btcpayserver.org/Shopify/">
Get Started
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
/* Main container for the landing page */
.index {
align-items: center;
display: flex;
justify-content: center;
height: 100vh;
width: 100%;
text-align: center;
padding: 2rem;
background-color: #f9fafb; /* Soft neutral background */
}
/* Headings */
.heading {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50; /* Dark, modern text color */
margin-bottom: 1rem;
}
.text {
font-size: 1.2rem;
color: #7f8c8d; /* Subtle gray for supporting text */
line-height: 1.6;
margin-bottom: 2rem;
}
/* Main content container */
.content {
display: flex;
flex-direction: column;
align-items: center;
max-width: 800px;
padding: 2rem;
background-color: #ffffff; /* Clean white card background */
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Unordered list for feature highlights */
.list {
list-style: none;
padding: 0;
margin: 2rem 0;
display: grid;
gap: 1.5rem;
width: 100%;
text-align: left;
}
.list > li {
font-size: 1rem;
color: #34495e; /* Slightly darker gray for readability */
line-height: 1.4;
display: flex;
align-items: flex-start;
}
.list > li::before {
content: "✔";
color: #27ae60; /* Green checkmark for positivity */
margin-right: 0.5rem;
font-size: 1.2rem;
font-weight: bold;
}
/* Links section for documentation */
.docs {
margin-top: 2rem;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.docs a {
text-decoration: none;
color: #3498db; /* Shopify-inspired blue for links */
font-weight: 500;
margin: 0.5rem 0;
transition: color 0.3s ease;
}
.docs a:hover {
color: #2c3e50; /* Darker on hover */
text-decoration: underline;
}
/* Call-to-action button */
.button {
display: inline-block;
background-color: #5cb85c; /* Shopify green */
color: #ffffff;
padding: 0.8rem 2rem;
font-size: 1rem;
font-weight: bold;
border: none;
border-radius: 6px;
text-decoration: none;
cursor: pointer;
transition: background-color 0.3s ease;
margin-top: 2rem;
}
.button:hover {
background-color: #4cae4c; /* Slightly darker green on hover */
}
/* Responsive design for smaller screens */
@media only screen and (max-width: 768px) {
.content {
padding: 1.5rem;
}
.heading {
font-size: 2rem;
}
.text {
font-size: 1rem;
}
.list {
gap: 1rem;
}
.button {
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
}
}

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,55 @@
import styles from "./_index/styles.module.css"
import { redirect, json } from '@remix-run/node';
import { login } from "../shopify.server";
export const loader = async ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get("shop")) {
throw redirect(`/shopifyapp/app?${url.searchParams.toString()}`);
}
return json({ showForm: Boolean(login) });
};
export default function App() {
return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>Accept Bitcoin in Shopify with BTCPay Server</h1>
<p className={styles.text}>
Add Bitcoin as a payment option to your store with{" "}
<a
href="https://btcpayserver.org/"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#3498db", textDecoration: "underline" }}
>
BTCPay Server
</a>
. It's free, simple, secure, and puts you in full control.
</p>
<ul className={styles.list}>
<li>
<strong>Zero fees: </strong> Enjoy a payment gateway with no fees. Yes, You saw that right. Zero fees!
</li>
<li>
<strong>No middlemen: </strong> Say goodbye to intermediaries and tedious paperwork, and get your money directly in your wallet
</li>
<li>
<strong>Community-driven support: </strong> Get responsive assistance from our dedicated community
</li>
</ul>
<div>
<a className={styles.button}
target="_blank"
href="https://docs.btcpayserver.org/Shopify/">
Get Started
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import { json } from '@remix-run/node';
import { cors } from 'remix-utils/cors'
import db from '../db.server';
export async function loader({ request }) {
const url = new URL(request.url);
const shopName = url.searchParams.get("shopName");
if (request.method === "OPTIONS") {
return json({ status: 204 });
}
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
});
}

View File

@ -0,0 +1,162 @@
import db from "../db.server";
import { json } from "@remix-run/node";
import { useEffect, useState } from "react";
import { authenticate } from "../shopify.server";
import { useFetcher, useLoaderData } from "@remix-run/react";
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
import { Page, Text, Card, Button, BlockStack, InlineGrid, TextField, Box } from "@shopify/polaris";
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());
let { btcpayUrl, btcpayStoreId } = formData;
if (!btcpayUrl || !btcpayStoreId) {
return json({ success: false, message: `Please input your BTCPay server domain url and store Id` }, { status: 400 });
}
btcpayUrl = btcpayUrl.endsWith('/') ? btcpayUrl.slice(0, -1) : btcpayUrl;
const isValidBTCPayStore = await validateBTCPayStoreInstance(btcpayUrl, btcpayStoreId, 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 Url and the Store ID (where the plugin is installed)
</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
primary
loading={fetcher.state === "submitting"}
disabled={fetcher.state === "submitting"}
>
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>
);
}

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="/shopifyapp/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);
};

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,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: "/shopifyapp/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;

View File

@ -1,27 +0,0 @@
#!/usr/bin/env bash
COMMIT="$(git log -1 --format=%H)"
pushd .
TEMP_DIR=$(mktemp -d)
echo "COMMIT=${COMMIT}"
echo "VERSION=$(git describe --tags --abbrev=0)"
cd "$TEMP_DIR"
echo "Creating plugin in directory: ${TEMP_DIR}"
cp -rf /app/* "${TEMP_DIR}"
cp shopify.app.toml.example shopify.app.toml
sed -i "s|APP_NAME|${APP_NAME}|g" shopify.app.toml
sed -i "s|CLIENT_ID|${CLIENT_ID}|g" shopify.app.toml
sed -i "s|PLUGIN_URL|${PLUGIN_URL}|g" shopify.app.toml
sed -i "s|PLUGIN_URL|${PLUGIN_URL}|g" extensions/btcpaycheckout/src/Checkout.jsx
echo "Settings saved"
if shopify app deploy -f --no-color; then
echo "SUCCESS=true"
else
echo "SUCCESS=false"
fi
popd
rm -rf "$TEMP_DIR"

View File

@ -1,3 +0,0 @@
#!/usr/bin/env bash
node server.js

2
env.d.ts vendored Normal file
View File

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

View File

@ -0,0 +1,3 @@
export const config = {
appUrl: process.env.DOMAIN
};

View File

@ -1,10 +0,0 @@
{
"shop_name": "Název obchodu",
"reviewAndPay": "Zkontrolujte a zaplaťte pomocí BTCpay Server!",
"reviewOrderMessage": "Prosím, zkontrolujte si objednávku a vyplňte platbu pomocí BTCpay Server.",
"complete_payment": "Kompletní platba",
"error": {
"fetch_invoice": "Chyba BTCpay Serveru. Nepodařilo se získat fakturu. {{error}}",
"general": "Chyba BTCpay Serveru. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Handelsnavn",
"reviewAndPay": "Gennemgå og betale ved hjælp af BTCPAy Server!",
"reviewOrderMessage": "Gennemgå din ordre og fuldføre betalingen ved hjælp af BTCPAy Server.",
"complete_payment": "Fuldstændig betaling",
"error": {
"fetch_invoice": "BTCPay Server Fejl. Kunne ikke hente faktura. {{error}}",
"general": "BTCPay Server Fejl. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Name des Shops",
"reviewAndPay": "Bewerten und bezahlen Sie mit dem BTCPay-Server!",
"reviewOrderMessage": "Bitte überprüfen Sie Ihre Bestellung und schließen Sie die Zahlung mit dem BTCPay-Server ab.",
"complete_payment": "Zahlung abschließen",
"error": {
"fetch_invoice": "BTCPay-Serverfehler. Rechnung konnte nicht abgerufen werden. {{error}}",
"general": "BTCPay-Serverfehler. {{error}}"
}
}

View File

@ -1,10 +1,5 @@
{
"shop_name": "Shop name",
"reviewAndPay": "Review and pay using BTCPay Server!",
"reviewOrderMessage": "Please review your order and complete the payment using BTCPay Server.",
"complete_payment": "Complete Payment",
"error": {
"fetch_invoice": "BTCPay Server Error. Failed to fetch invoice. {{error}}",
"general": "BTCPay Server Error. {{error}}"
}
}
"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

@ -1,10 +0,0 @@
{
"shop_name": "Nombre de la tienda",
"reviewAndPay": "¡Revisa y paga usando BTCPay Server!",
"reviewOrderMessage": "Por favor revisa tu pedido y completa el pago usando BTCPay Server.",
"complete_payment": "Completar Pago",
"error": {
"fetch_invoice": "Error del Servidor BTCPay. Error al obtener la factura. {{error}}",
"general": "Error del Servidor BTCPay. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Kaupan nimi",
"reviewAndPay": "Tarkista ja maksa käyttäen BTCPay Server!",
"reviewOrderMessage": "Tarkista tilauksesi ja suorita maksu BTCPay-palvelimella.",
"complete_payment": "Täydellinen maksu",
"error": {
"fetch_invoice": "BTCPay-palvelinvirhe. Laskun nouto epäonnistui. {{error}}",
"general": "BTCPay-palvelinvirhe. {{error}}"
}
}

View File

@ -1,10 +1,5 @@
{
"shop_name": "Nom du magasin",
"reviewAndPay": "Vérifiez et payez en utilisant BTCPay Server !",
"reviewOrderMessage": "Veuillez vérifier votre commande et effectuer le paiement via le serveur BTCPay.",
"complete_payment": "Terminer le Paiement",
"error": {
"fetch_invoice": "Erreur du serveur BTCPay. Échec de la récupération de la facture. {{error}}",
"general": "Erreur du serveur BTCPay. {{error}}"
}
}
"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

@ -1,10 +0,0 @@
{
"shop_name": "दुकान का नाम",
"reviewAndPay": "BTCPay सर्वर का उपयोग करके समीक्षा और भुगतान करें!",
"reviewOrderMessage": "कृपया अपने ऑर्डर की समीक्षा करें और BTCPay सर्वर का उपयोग करके भुगतान पूरा करें।.",
"complete_payment": "पूर्ण भुगतान",
"error": {
"fetch_invoice": "BTCPay सर्वर त्रुटि। चालान प्राप्त करने में विफल रहा।. {{error}}",
"general": "BTCPay सर्वर त्रुटि।. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "A bolt neve",
"reviewAndPay": "Áttekintés és fizetés segítségével BTCPay Server!",
"reviewOrderMessage": "Kérjük, olvassa el megrendelését, és töltse ki a kifizetést a BTCPay Server használatával.",
"complete_payment": "Teljes kifizetés",
"error": {
"fetch_invoice": "BTCPay szerver hiba. Nem sikerült számlát beszerezni. {{error}}",
"general": "BTCPay szerver hiba. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Nome negozio",
"reviewAndPay": "Rivedere e pagare usando BTCPay Server!",
"reviewOrderMessage": "Per favore controlla il tuo ordine e completa il pagamento usando BTCPay Server.",
"complete_payment": "Completa Pagamento",
"error": {
"fetch_invoice": "Errore BTCPay Server. Impossibile recuperare la fattura. {{error}}",
"general": "Errore BTCPay Server. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "店舗名",
"reviewAndPay": "BTCPay Server でのレビューと支払い!",
"reviewOrderMessage": "BTCPay Server を使用して注文を確認し、支払いを完了してください.",
"complete_payment": "完全な支払",
"error": {
"fetch_invoice": "BTCPay Server エラー。 請求書を受け取りませんでした. {{error}}",
"general": "BTCPay Server エラー. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "가게 이름",
"reviewAndPay": "BTCPay Server를 사용하여 검토 및 지불!",
"reviewOrderMessage": "주문을 검토하고 BTCPay Server를 사용하여 지불을 완료하십시오.",
"complete_payment": "결제 완료",
"error": {
"fetch_invoice": "BTCPay 서버 오류. 청구서에 실패. {{error}}",
"general": "BTCPay 서버 오류. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Butikknavn",
"reviewAndPay": "Se over og betal med BTCPay Server!",
"reviewOrderMessage": "Vennligst se over bestillingen din og fullfør betalingen med BTCPay Server.",
"complete_payment": "Fullfør betaling",
"error": {
"fetch_invoice": "BTCPay Server-feil. Klarte ikke hente faktura. {{error}}",
"general": "BTCPay Server-feil. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Winkelnaam",
"reviewAndPay": "Bekijk en betaal met BTCPay Server!",
"reviewOrderMessage": "Bekijk uw bestelling en vul de betaling met BTCPay Server.",
"complete_payment": "Volledige betaling",
"error": {
"fetch_invoice": "BTCPay-serverfout Ophalen van factuur is mislukt. {{error}}",
"general": "BTCPay-serverfout. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Nazwa sklepu",
"reviewAndPay": "Przegląd i zapłacić za pomocą serwera BTCPAy!",
"reviewOrderMessage": "Proszę przejrzeć zamówienie i dokonać płatności za pomocą serwera BTCPAy.",
"complete_payment": "Płatność pełna",
"error": {
"fetch_invoice": "Błąd serwera BTCPAy. Nie udało się pobrać faktury. {{error}}",
"general": "Błąd serwera BTCPAy. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Nome da loja",
"reviewAndPay": "Reveja e pague usando o BTCPay Server!",
"reviewOrderMessage": "Por favor, reveja seu pedido e complete o pagamento usando o BTCPay Server.",
"complete_payment": "Pagamento completo",
"error": {
"fetch_invoice": "Erro no servidor BTCPay. Não consegui pegar a fatura. {{error}}",
"general": "Erro no servidor BTCPay. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Nome da loja",
"reviewAndPay": "Revise e pague usando BTCPay Server!",
"reviewOrderMessage": "Por favor revise seu pedido e complete o pagamento usando BTCPay Server.",
"complete_payment": "Completar Pagamento",
"error": {
"fetch_invoice": "Erro do BTCPay Server. Falha ao buscar fatura. {{error}}",
"general": "Erro do BTCPay Server. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Название магазина",
"reviewAndPay": "Обзор и оплата с помощью BTCPay Server!",
"reviewOrderMessage": "Пожалуйста, просмотрите ваш заказ и заполните оплату с помощью BTCPay Server.",
"complete_payment": "Полный платеж",
"error": {
"fetch_invoice": "Ошибка BTCPay Server. Не удалось получить счет. {{error}}",
"general": "Ошибка BTCPay Server. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Shop name",
"reviewAndPay": "Granska och betala med BTCPay Server!",
"reviewOrderMessage": "Vänligen granska din beställning och slutföra betalningen med hjälp av BTCPay Server.",
"complete_payment": "Fullständig betalning",
"error": {
"fetch_invoice": "BTCPay Server Fel. Misslyckades med att hämta faktura. {{error}}",
"general": "BTCPay Server Fel. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "ชื่อร้านค้า",
"reviewAndPay": "ทบทวนและจ่ายจากเซิร์ฟเวอร์บีทีซี!",
"reviewOrderMessage": "กรุณาทบทวนคําสั่งของคุณ และชําระเงินด้วยเซิร์ฟเวอร์บีทีซี.",
"complete_payment": "การชําระเงินที่สมบูรณ์",
"error": {
"fetch_invoice": "เซิร์ฟเวอร์ BTC มีข้อผิดพลาด ล้มเหลวในการเรียกใบแจ้งหนี้. {{error}}",
"general": "เซิร์ฟเวอร์ BTC มีข้อผิดพลาด. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Dükkan adı",
"reviewAndPay": "BTCPay Server kullanarak yorum ve ödeme!",
"reviewOrderMessage": "Lütfen siparişinizi gözden geçirin ve BTCPay Server kullanarak ödeme tamamlayın.",
"complete_payment": "Tamam Ödeme",
"error": {
"fetch_invoice": "BTCPay Server Hatası. Fatura getirmek için başarısız oldu. {{error}}",
"general": "BTCPay Server Hatası. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "Tên cửa hàng",
"reviewAndPay": "Xem lại và trả tiền bằng máy chủ trả tiền BTC!",
"reviewOrderMessage": "Vui lòng xem lại đơn đặt hàng và hoàn tất việc thanh toán bằng máy phục vụ trả tiền BTC.",
"complete_payment": "Trả tiền đầy đủ",
"error": {
"fetch_invoice": "Lỗi trình phục vụ trả tiền BTC. Không lấy được hóa đơn. {{error}}",
"general": "Lỗi trình phục vụ trả tiền BTC. {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "商店名称",
"reviewAndPay": "用 BTCPay 服务器审查并支付!",
"reviewOrderMessage": "请审查您的订单并使用 BTCPay 服务器完成支付 .",
"complete_payment": "全额付款",
"error": {
"fetch_invoice": "BTCPay 服务器出错 。 获取发票失败 . {{error}}",
"general": "BTCPay 服务器出错 . {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "店名",
"reviewAndPay": "使用 BTCPay 伺服器去審查并支付!",
"reviewOrderMessage": "請回復您的訂單并用 BTCPay 伺服器完成支付 .",
"complete_payment": "全部付款",
"error": {
"fetch_invoice": "BTCPay 伺服器出錯 。 取回收據失敗 . {{error}}",
"general": "BTCPay 伺服器出錯 . {{error}}"
}
}

View File

@ -1,10 +0,0 @@
{
"shop_name": "商店名称",
"reviewAndPay": "用 BTCPay 服务器审查并支付!",
"reviewOrderMessage": "请审查您的订单并使用 BTCPay 服务器完成支付 .",
"complete_payment": "全额付款",
"error": {
"fetch_invoice": "BTCPay 服务器出错 。 获取发票失败 . {{error}}",
"general": "BTCPay 服务器出错 . {{error}}"
}
}

View File

@ -3,7 +3,7 @@
# The version of APIs your extension will receive. Learn more:
# https://shopify.dev/docs/api/usage/versioning
api_version = "2025-07"
api_version = "2024-10"
[[extensions]]
name = "BTCPay Checkout"
@ -22,7 +22,7 @@ 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 = false
api_access = true
# Gives your extension access to make external network calls, using the
# JavaScript `fetch()` API. Learn more:

View File

@ -1,14 +1,21 @@
import {
reactExtension,
Banner,
BlockStack,
Button,
Text,
useApi,
Link,
Modal,
Spinner,
TextBlock,
useTotalAmount,
useInstructions,
useTranslate,
useSelectedPaymentOptions
} from "@shopify/ui-extensions-react/checkout";
import { useEffect, useState } from "react";
import { useEffect, useState } from 'react';
import { config } from '../config';
// 1. Choose an extension target
export default reactExtension(
@ -18,57 +25,220 @@ export default reactExtension(
function Extension() {
const translate = useTranslate();
const { shop, ui, checkoutToken } = useApi();
const { currencyCode, amount } = useTotalAmount();
const instructions = useInstructions();
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 [isPaid, setIsPaid] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [modalTitle, setModalTitle] = useState('Pay with Bitcoin/Lightning Network');
const shopName = shop.myshopifyDomain.split('.myshopify.com')[0];
const options = useSelectedPaymentOptions();
const { shop, checkoutToken } = useApi();
const [isLoading, setIsLoading] = useState(true);
const [isSuccess, setIsSuccess] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const hasManualPayment = options.some((option) => option.type.toLowerCase() === 'manualpayment');
const appUrl = `PLUGIN_URL/checkout?checkout_token=${checkoutToken.current}`;
let { appUrl } = config;
useEffect(() => {
if (!hasManualPayment) return;
const fetchInvoice = async () => {
setIsLoading(true);
try {
const response = await fetch(`${appUrl}&redirect=false`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
setIsSuccess(true);
}
else if (response.status !== 404) {
const errorText = await response.text();
setErrorMessage(translate("error.fetch_invoice", { error: errorText || response.statusText }));
}
} catch (error) {
setErrorMessage(translate("error.general", { error: error.message }));
}
finally {
setIsLoading(false);
}
};
const timer = setTimeout(fetchInvoice, 1000);
return () => clearTimeout(timer)
}, [hasManualPayment]);
const hasManualPaymentOption = options.some((option) => option.type.toLowerCase() === 'manualpayment');
if (hasManualPaymentOption) {
const timer = setTimeout(async () => {
await validateToken();
}, 5000);
return () => {
clearTimeout(timer);
};
}
}, [options]);
if (!hasManualPayment) return null;
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 retrieveBTCPayUrl = async (shopName) => {
appUrl = appUrl.endsWith('/') ? appUrl.slice(0, -1) : appUrl;
const response = await fetch(
`https://${appUrl}/shopifyapp/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 setCheckTokenValidity = async (btcpayurl, btcpaystoreId, shopName) => {
try {
const validationResponse = await validateCheckoutToken(btcpayurl, btcpaystoreId, shopName, checkoutToken.current);
if (validationResponse.success) {
if(validationResponse.data.financialStatus === "success"){
setIsTokenValid(false);
setIsPaid(true);
}
else{
setIsTokenValid(true);
setOrderId(validationResponse.data.orderId);
setModalTitle(validationResponse.data.paymentMethodDescription);
setRetryCount(0);
}
} else {
retryTokenValidation();
}
} catch (error) {
retryTokenValidation();
}
};
const retryTokenValidation = () => {
if (retryCount >= 3) {
setError("Failed to connect to BTCPay Server instance. Please contact support or try again later.");
setIsTokenValid(false);
} else {
setRetryCount(retryCount + 1);
setIsTokenValid(false);
setTimeout(async () => {
await validateToken();
}, 3000);
}
};
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');
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 (
<BlockStack>
{isLoading && <Spinner />}
{!isLoading && errorMessage && (
<Text size="large" appearance="critical">{errorMessage}</Text>
<>
{error && (
<Banner status="critical">
<Text>{error}</Text>
</Banner>
)}
{isTokenValid ? (
<BlockStack>
<Text>Shop name: {shop.name}</Text>
<Text size="large" alignment="center" bold>Review and pay using BTCPay Server!</Text>
<Text>Please review your order and complete the payment using BTCPay Server.</Text>
<Button onPress={async () => {
await CreateBTCPayOrder();
ui.overlay.open('btc-pay-modal');
}}
overlay={
<Modal
id="btc-pay-modal"
padding
title={modalTitle}
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>
) : isPaid ? (
<BlockStack>
<Text>Shop name: {shop.name}</Text>
<Text size="large" alignment="center" bold>Thank you! Your payment was acknowledged.</Text>
</BlockStack>
) : (
<BlockStack>
<Text>Shop name: {shop.name}</Text>
<Text size="large" alignment="center" bold>Review and pay using BTCPay Server!</Text>
<Text>Kindly ignore if the payment method selected was not payment with BTCPay Server</Text>
<Spinner />
<Text>Validating your payment options...</Text>
</BlockStack>
)}
{!isLoading && isSuccess && (
<>
<Text>{translate("shop_name")}: {shop.name}</Text>
<Text size="large" alignment="center" bold>{translate("reviewAndPay")}</Text>
<Text>{translate("reviewOrderMessage")}.</Text>
<Button to={appUrl} external>{translate("complete_payment")}</Button>
</>
)}
</BlockStack>
</>
);
}

View File

@ -0,0 +1,14 @@
# add CORS headers
add_header Access-Control-Allow-Origin '*' always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, DELETE, PUT' always;
add_header Access-Control-Allow-Headers 'Authorization, Content-Type, X-Shopify-Hmac-Sha256' always;
# Preflight request handling
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin '*' always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, DELETE, PUT' always;
add_header Access-Control-Allow-Headers 'Authorization, Content-Type, X-Shopify-Hmac-Sha256' always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}

15353
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,66 @@
{
"name": "btcpayserverdemoplugin",
"private": true,
"scripts": {
"build": "prisma generate && prisma migrate deploy && 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",
"@shopify/ui-extensions-react": "2024.10.x",
"dotenv": "^16.4.7",
"isbot": "^5.1.0",
"prisma": "^5.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-utils": "^7.7.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/*"
],
"dependencies": {
"express": "^4.21.2"
}
"trustedDependencies": [
"@shopify/plugin-cloudflare"
],
"resolutions": {},
"overrides": {},
"author": "PC"
}

View File

@ -0,0 +1,34 @@
-- 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
);
-- 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 = env("DATABASE_URL")
}
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: {},
};

View File

@ -1,31 +0,0 @@
const express = require('express');
const { spawn } = require('child_process');
const app = express();
app.use(express.json());
app.post('/deploy', (req, res) => {
const process = spawn('bash', ['/app/deploy.sh'],
{
env:
{
"SHOPIFY_CLI_PARTNERS_TOKEN": req.body.cliToken,
"CLIENT_ID": req.body.clientId,
"PLUGIN_URL": req.body.pluginUrl,
"APP_NAME": req.body.appName
}
});
res.setHeader('Content-Type', 'text/plain');
process.stdout.on('data', (data) => {
res.write(data.toString()); // Stream stdout
});
process.stderr.on('data', (data) => {
res.write(data.toString()); // Stream stderr
});
process.on('close', (code) => {
res.end(`\nProcess exited with code ${code}\n`);
});
});
app.listen(5000, () => console.log('Server running on port 5000'));

View File

@ -1,18 +1,45 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
name = "APP_NAME"
client_id = "CLIENT_ID"
application_url = "PLUGIN_URL"
client_id = "YOURCLIENT_ID"
name = "BTCPay Server APPNAME"
handle = "btcpay-server-APPNAME"
application_url = "https://YOUR_HOSTED_APP_URL.COM/shopifyapp/"
embedded = true
[build]
automatically_update_urls_on_dev = false
dev_store_url = "YOURDEV_STORE.myshopify.com"
include_config_on_deploy = true
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_orders,write_orders,read_customers"
scopes = "read_orders,write_orders"
[auth]
redirect_urls = ["PLUGIN_URL/checkout"]
redirect_urls = [
"https://YOUR_HOSTED_APP_URL.COM/shopifyapp/auth/callback",
"https://YOUR_HOSTED_APP_URL.COM/shopifyapp/auth/shopify/callback",
"https://YOUR_HOSTED_APP_URL.COM/shopifyapp/api/auth/callback"
]
[webhooks]
api_version = "2025-07"
api_version = "2024-10"
[[webhooks.subscriptions]]
uri = "/shopifyapp/webhooks/customers/data_request"
compliance_topics = [ "customers/data_request" ]
[[webhooks.subscriptions]]
uri = "/shopifyapp/webhooks/customers/redact"
compliance_topics = [ "customers/redact" ]
[[webhooks.subscriptions]]
uri = "/shopifyapp/webhooks/shop/redact"
compliance_topics = [ "shop/redact" ]
[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "/shopifyapp/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 = "/shopifyapp/webhooks/app/uninstalled"
[commands]
predev = "npx prisma generate"
dev = "npx prisma migrate deploy && npm exec remix vite:dev"

57
vite.config.js Normal file
View File

@ -0,0 +1,57 @@
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,
emptyOutDir: true,
assetsDir: 'shopifyapp/assets',
},
});