first version

This commit is contained in:
Gerhard Scheikl
2026-04-28 21:56:11 +02:00
parent 0f75dbaccb
commit 5b2aa5d62b
50 changed files with 5514 additions and 481 deletions
+68 -321
View File
@@ -1,345 +1,92 @@
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 type { LoaderFunctionArgs } from "react-router";
import { Link, useLoaderData } from "react-router";
import { authenticate } from "../shopify.server";
import { boundary } from "@shopify/shopify-app-react-router/server";
import db from "../db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
const { session } = await authenticate.admin(request);
return null;
};
const [settings, recent] = await Promise.all([
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
db.invoice.findMany({
where: { shopDomain: session.shop },
orderBy: [{ issuedAt: "desc" }],
take: 10,
}),
]);
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 settingsConfigured = !!(
settings &&
settings.companyName &&
settings.addressLine1 &&
settings.iban
);
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,
settingsConfigured,
recent: recent.map((i) => ({
id: i.id,
number: i.invoiceNumber,
kind: i.kind,
orderName: i.orderName,
version: i.version,
sentAt: i.sentAt?.toISOString() ?? null,
cancelledAt: i.cancelledAt?.toISOString() ?? null,
issuedAt: i.issuedAt.toISOString(),
pdfUrl: i.pdfUrl,
})),
};
};
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" });
const { settingsConfigured, recent } = useLoaderData<typeof loader>();
return (
<s-page heading="Shopify app template">
<s-button slot="primary-action" onClick={generateProduct}>
Generate a product
</s-button>
<s-page heading="LinumIQ Invoice">
{!settingsConfigured && (
<s-banner tone="warning" heading="Configure your invoice settings">
Complete your company, bank and numbering details so generated
invoices are legally compliant.{" "}
<Link to="/app/settings">Open settings</Link>
</s-banner>
)}
<s-section heading="Congrats on creating a new Shopify app 🎉">
<s-section heading="What this app does">
<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.
Generates Austrian-compliant PDF invoices for your Shopify orders.
Trigger from the order page (Generate invoice action), via Shopify
Flow, or in bulk from the Invoices page. PDFs are stored on
Shopify Files and linked to each order via metafields.
</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 heading="Recent invoices">
{recent.length === 0 ? (
<s-paragraph>No invoices generated yet.</s-paragraph>
) : (
<s-unordered-list>
{recent.map((i) => (
<s-list-item key={i.id}>
{i.kind === "storno" ? "Storno " : ""}
{i.number} order {i.orderName} (v{i.version})
{i.cancelledAt
? " — cancelled"
: i.sentAt
? " — sent"
: ""}
{i.pdfUrl ? (
<>
{" "}
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
</>
) : null}
</s-list-item>
))}
</s-unordered-list>
)}
</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>
<Link to="/app/invoices">Open invoices page</Link>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};