initial version (template only)

This commit is contained in:
Gerhard Scheikl
2026-04-28 13:34:35 +02:00
commit 0f75dbaccb
44 changed files with 14066 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect, Form, useLoaderData } from "react-router";
import { login } from "../../shopify.server";
import styles from "./styles.module.css";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
if (url.searchParams.get("shop")) {
throw redirect(`/app?${url.searchParams.toString()}`);
}
return { showForm: Boolean(login) };
};
export default function App() {
const { showForm } = useLoaderData<typeof loader>();
return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>A short heading about [your app]</h1>
<p className={styles.text}>
A tagline about [your app] that describes your value proposition.
</p>
{showForm && (
<Form className={styles.form} method="post" action="/auth/login">
<label className={styles.label}>
<span>Shop domain</span>
<input className={styles.input} type="text" name="shop" />
<span>e.g: my-shop-domain.myshopify.com</span>
</label>
<button className={styles.button} type="submit">
Log in
</button>
</Form>
)}
<ul className={styles.list}>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
</ul>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
.index {
align-items: center;
display: flex;
justify-content: center;
height: 100%;
width: 100%;
text-align: center;
padding: 1rem;
}
.heading,
.text {
padding: 0;
margin: 0;
}
.text {
font-size: 1.2rem;
padding-bottom: 2rem;
}
.content {
display: grid;
gap: 2rem;
}
.form {
display: flex;
align-items: center;
justify-content: flex-start;
margin: 0 auto;
gap: 1rem;
}
.label {
display: grid;
gap: 0.2rem;
max-width: 20rem;
text-align: left;
font-size: 1rem;
}
.input {
padding: 0.4rem;
}
.button {
padding: 0.4rem;
}
.list {
list-style: none;
padding: 0;
padding-top: 3rem;
margin: 0;
display: flex;
gap: 2rem;
}
.list > li {
max-width: 20rem;
text-align: left;
}
@media only screen and (max-width: 50rem) {
.list {
display: block;
}
.list > li {
padding-bottom: 1rem;
}
}
+345
View File
@@ -0,0 +1,345 @@
import { useEffect } from "react";
import type {
ActionFunctionArgs,
HeadersFunction,
LoaderFunctionArgs,
} from "react-router";
import { useFetcher } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import { boundary } from "@shopify/shopify-app-react-router/server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const color = ["Red", "Orange", "Yellow", "Green"][
Math.floor(Math.random() * 4)
];
const response = await admin.graphql(
`#graphql
mutation populateProduct($product: ProductCreateInput!) {
productCreate(product: $product) {
product {
id
title
handle
status
variants(first: 10) {
edges {
node {
id
price
barcode
createdAt
}
}
}
demoInfo: metafield(namespace: "$app", key: "demo_info") {
jsonValue
}
}
}
}`,
{
variables: {
product: {
title: `${color} Snowboard`,
metafields: [
{
namespace: "$app",
key: "demo_info",
value: "Created by React Router Template",
},
],
},
},
},
);
const responseJson = await response.json();
const product = responseJson.data!.productCreate!.product!;
const variantId = product.variants.edges[0]!.node!.id!;
const variantResponse = await admin.graphql(
`#graphql
mutation shopifyReactRouterTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
price
barcode
createdAt
}
}
}`,
{
variables: {
productId: product.id,
variants: [{ id: variantId, price: "100.00" }],
},
},
);
const variantResponseJson = await variantResponse.json();
const metaobjectResponse = await admin.graphql(
`#graphql
mutation shopifyReactRouterTemplateUpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
metaobject {
id
handle
title: field(key: "title") {
jsonValue
}
description: field(key: "description") {
jsonValue
}
}
userErrors {
field
message
}
}
}`,
{
variables: {
handle: {
type: "$app:example",
handle: "demo-entry",
},
metaobject: {
fields: [
{ key: "title", value: "Demo Entry" },
{
key: "description",
value:
"This metaobject was created by the Shopify app template to demonstrate the metaobject API.",
},
],
},
},
},
);
const metaobjectResponseJson = await metaobjectResponse.json();
return {
product: responseJson!.data!.productCreate!.product,
variant:
variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants,
metaobject:
metaobjectResponseJson!.data!.metaobjectUpsert!.metaobject,
};
};
export default function Index() {
const fetcher = useFetcher<typeof action>();
const shopify = useAppBridge();
const isLoading =
["loading", "submitting"].includes(fetcher.state) &&
fetcher.formMethod === "POST";
useEffect(() => {
if (fetcher.data?.product?.id) {
shopify.toast.show("Product created");
}
}, [fetcher.data?.product?.id, shopify]);
const generateProduct = () => fetcher.submit({}, { method: "POST" });
return (
<s-page heading="Shopify app template">
<s-button slot="primary-action" onClick={generateProduct}>
Generate a product
</s-button>
<s-section heading="Congrats on creating a new Shopify app 🎉">
<s-paragraph>
This embedded app template uses{" "}
<s-link
href="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
>
App Bridge
</s-link>{" "}
interface examples like an{" "}
<s-link href="/app/additional">additional page in the app nav</s-link>
, as well as an{" "}
<s-link
href="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
>
Admin GraphQL
</s-link>{" "}
mutation demo, to provide a starting point for app development.
</s-paragraph>
</s-section>
<s-section heading="Get started with products">
<s-paragraph>
Generate a product with GraphQL and get the JSON output for that
product. Learn more about the{" "}
<s-link
href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
target="_blank"
>
productCreate
</s-link>{" "}
mutation in our API references. Includes a product{" "}
<s-link
href="https://shopify.dev/docs/apps/build/custom-data/metafields"
target="_blank"
>
metafield
</s-link>{" "}
and{" "}
<s-link
href="https://shopify.dev/docs/apps/build/custom-data/metaobjects"
target="_blank"
>
metaobject
</s-link>
.
</s-paragraph>
<s-stack direction="inline" gap="base">
<s-button
onClick={generateProduct}
{...(isLoading ? { loading: true } : {})}
>
Generate a product
</s-button>
{fetcher.data?.product && (
<s-button
onClick={() => {
shopify.intents.invoke?.("edit:shopify/Product", {
value: fetcher.data?.product?.id,
});
}}
target="_blank"
variant="tertiary"
>
Edit product
</s-button>
)}
</s-stack>
{fetcher.data?.product && (
<s-section heading="productCreate mutation">
<s-stack direction="block" gap="base">
<s-box
padding="base"
borderWidth="base"
borderRadius="base"
background="subdued"
>
<pre style={{ margin: 0 }}>
<code>{JSON.stringify(fetcher.data.product, null, 2)}</code>
</pre>
</s-box>
<s-heading>productVariantsBulkUpdate mutation</s-heading>
<s-box
padding="base"
borderWidth="base"
borderRadius="base"
background="subdued"
>
<pre style={{ margin: 0 }}>
<code>{JSON.stringify(fetcher.data.variant, null, 2)}</code>
</pre>
</s-box>
<s-heading>metaobjectUpsert mutation</s-heading>
<s-box
padding="base"
borderWidth="base"
borderRadius="base"
background="subdued"
>
<pre style={{ margin: 0 }}>
<code>
{JSON.stringify(fetcher.data.metaobject, null, 2)}
</code>
</pre>
</s-box>
</s-stack>
</s-section>
)}
</s-section>
<s-section slot="aside" heading="App template specs">
<s-paragraph>
<s-text>Framework: </s-text>
<s-link href="https://reactrouter.com/" target="_blank">
React Router
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>Interface: </s-text>
<s-link
href="https://shopify.dev/docs/api/app-home/using-polaris-components"
target="_blank"
>
Polaris web components
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>API: </s-text>
<s-link
href="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
>
GraphQL
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>Custom data: </s-text>
<s-link
href="https://shopify.dev/docs/apps/build/custom-data"
target="_blank"
>
Metafields &amp; metaobjects
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>Database: </s-text>
<s-link href="https://www.prisma.io/" target="_blank">
Prisma
</s-link>
</s-paragraph>
</s-section>
<s-section slot="aside" heading="Next steps">
<s-unordered-list>
<s-list-item>
Build an{" "}
<s-link
href="https://shopify.dev/docs/apps/getting-started/build-app-example"
target="_blank"
>
example app
</s-link>
</s-list-item>
<s-list-item>
Explore Shopify&apos;s API with{" "}
<s-link
href="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
target="_blank"
>
GraphiQL
</s-link>
</s-list-item>
</s-unordered-list>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};
+37
View File
@@ -0,0 +1,37 @@
export default function AdditionalPage() {
return (
<s-page heading="Additional page">
<s-section heading="Multiple pages">
<s-paragraph>
The app template comes with an additional page which demonstrates how
to create multiple pages within app navigation using{" "}
<s-link
href="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
>
App Bridge
</s-link>
.
</s-paragraph>
<s-paragraph>
To create your own page and have it show up in the app navigation, add
a page inside <code>app/routes</code>, and a link to it in the{" "}
<code>&lt;ui-nav-menu&gt;</code> component found in{" "}
<code>app/routes/app.jsx</code>.
</s-paragraph>
</s-section>
<s-section slot="aside" heading="Resources">
<s-unordered-list>
<s-list-item>
<s-link
href="https://shopify.dev/docs/apps/design-guidelines/navigation#app-nav"
target="_blank"
>
App nav best practices
</s-link>
</s-list-item>
</s-unordered-list>
</s-section>
</s-page>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { Outlet, useLoaderData, useRouteError } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { AppProvider } from "@shopify/shopify-app-react-router/react";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
// eslint-disable-next-line no-undef
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};
export default function App() {
const { apiKey } = useLoaderData<typeof loader>();
return (
<AppProvider embedded apiKey={apiKey}>
<s-app-nav>
<s-link href="/app">Home</s-link>
<s-link href="/app/additional">Additional page</s-link>
</s-app-nav>
<Outlet />
</AppProvider>
);
}
// Shopify needs React Router to catch some thrown responses, so that their headers are included in the response.
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};
+14
View File
@@ -0,0 +1,14 @@
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { boundary } from "@shopify/shopify-app-react-router/server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};
+16
View File
@@ -0,0 +1,16 @@
import type { LoginError } from "@shopify/shopify-app-react-router/server";
import { LoginErrorType } from "@shopify/shopify-app-react-router/server";
interface LoginErrorMessage {
shop?: string;
}
export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage {
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 {};
}
+49
View File
@@ -0,0 +1,49 @@
import { AppProvider } from "@shopify/shopify-app-react-router/react";
import { useState } from "react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData } from "react-router";
import { login } from "../../shopify.server";
import { loginErrorMessage } from "./error.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const errors = loginErrorMessage(await login(request));
return { errors };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const errors = loginErrorMessage(await login(request));
return {
errors,
};
};
export default function Auth() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [shop, setShop] = useState("");
const { errors } = actionData || loaderData;
return (
<AppProvider embedded={false}>
<s-page>
<Form method="post">
<s-section heading="Log in">
<s-text-field
name="shop"
label="Shop domain"
details="example.myshopify.com"
value={shop}
onChange={(e) => setShop(e.currentTarget.value)}
autocomplete="on"
error={errors.shop}
></s-text-field>
<s-button type="submit">Log in</s-button>
</s-section>
</Form>
</s-page>
</AppProvider>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { payload, session, topic, shop } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const current = payload.current as string[];
if (session) {
await db.session.update({
where: {
id: session.id
},
data: {
scope: current.toString(),
},
});
}
return new Response();
};
+17
View File
@@ -0,0 +1,17 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
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();
};