Rewrite the shopify-app

This commit is contained in:
ndeet 2024-12-27 18:58:34 +01:00 committed by nicolas.dorier
parent f20d69924e
commit 9b526c7a45
No known key found for this signature in database
GPG Key ID: 6618763EF09186FE
47 changed files with 276 additions and 16578 deletions

View File

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

View File

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

View File

@ -1,13 +0,0 @@
/** @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 Normal file
View File

@ -0,0 +1,17 @@
# 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,37 +0,0 @@
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
View File

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

View File

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

View File

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

View File

@ -2,6 +2,21 @@
This is an app that provides a checkout extension to make it possible for Shopify merchants to accept Bitcoin payments using BTCPay Server.
You can find installation instructions on our official documentation page: [BTCPay Server for Shopify](https://docs.btcpayserver.org/ShopifyV2/)
The docker image run a lightweight API in `deploy.sh` running on port `5000`.
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.
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/)

View File

@ -1,11 +0,0 @@
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;

View File

@ -1,53 +0,0 @@
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);
});
}

View File

@ -1,30 +0,0 @@
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

@ -1,57 +0,0 @@
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}>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

@ -1,131 +0,0 @@
/* 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

@ -1,38 +0,0 @@
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

@ -1,162 +0,0 @@
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

@ -1,83 +0,0 @@
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

@ -1,39 +0,0 @@
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);
};

View File

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

View File

@ -1,11 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,36 +0,0 @@
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;

26
deploy.sh Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
COMMIT="$(git log -1 --format=%H)"
pushd .
TEMP_DIR=$(mktemp -d)
echo "COMMIT=${COMMIT}"
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"

3
docker-entrypoint.sh Executable file
View File

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

2
env.d.ts vendored
View File

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

View File

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

View File

@ -1,5 +0,0 @@
{
"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,5 +0,0 @@
{
"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

@ -22,12 +22,12 @@ 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
api_access = false
# 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
network_access = false
# Loads metafields on checkout resources, including the cart,
# products, customers, and more. Learn more:

View File

@ -1,21 +1,10 @@
import {
reactExtension,
Banner,
BlockStack,
Button,
Text,
useApi,
Link,
Modal,
Spinner,
TextBlock,
useTotalAmount,
useInstructions,
useTranslate,
useSelectedPaymentOptions
useApi
} from "@shopify/ui-extensions-react/checkout";
import { useEffect, useState } from 'react';
import { config } from '../config';
// 1. Choose an extension target
export default reactExtension(
@ -24,221 +13,16 @@ 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();
let { appUrl } = config;
useEffect(() => {
const hasManualPaymentOption = options.some((option) => option.type.toLowerCase() === 'manualpayment');
if (hasManualPaymentOption) {
const timer = setTimeout(async () => {
await validateToken();
}, 5000);
return () => {
clearTimeout(timer);
};
}
}, [options]);
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}/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
const { shop, checkoutToken } = useApi();
const appUrl = `PLUGIN_URL/checkout?checkout_token=${checkoutToken.current}`;
return (
<>
{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>
<Button to={appUrl} external>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>
)}
</>
);
}

View File

@ -1,14 +0,0 @@
# 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;
}

15349
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,66 +1,8 @@
{
"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/*"
],
"trustedDependencies": [
"@shopify/plugin-cloudflare"
],
"resolutions": {},
"overrides": {},
"author": "PC"
"dependencies": {
"express": "^4.21.2"
}
}

View File

@ -1,34 +0,0 @@
-- 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

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

View File

@ -1,44 +0,0 @@
// 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?
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,20 +0,0 @@
// 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: {},
};

31
server.js Normal file
View File

@ -0,0 +1,31 @@
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,45 +1,21 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = "YOURCLIENT_ID"
name = "BTCPay Server APPNAME"
handle = "btcpay-server-APPNAME"
application_url = "https://YOUR_HOSTED_APP_URL.COM/"
# 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"
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 = ""
scopes = "read_orders,write_orders"
[auth]
redirect_urls = [
"https://YOUR_HOSTED_APP_URL.COM/auth/callback",
"https://YOUR_HOSTED_APP_URL.COM/auth/shopify/callback",
"https://YOUR_HOSTED_APP_URL.COM/api/auth/callback"
]
redirect_urls = ["PLUGIN_URL/checkout"]
[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

View File

@ -1,7 +0,0 @@
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"

View File

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