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
@@ -0,0 +1,8 @@
# Generate invoice (Flow action)
Triggers `POST /api/flow/generate-invoice` on the embedded app.
Input: `order_id` (commerce object reference, type `order`).
The `runtime_url` placeholder above is replaced by the Shopify CLI with the
deployed app URL on `shopify app deploy`. For local dev with `shopify app dev`
the CLI overrides it with the current tunnel URL automatically.
@@ -0,0 +1,18 @@
api_version = "2026-01"
uid = "c5ddcfc1-772d-c55a-bbd6-8502c4a56f15a7dba32e"
[[extensions]]
name = "Generate invoice"
type = "flow_action"
handle = "flow-generate-invoice"
description = "Generates an Austria-compliant PDF invoice for the given order and uploads it to Shopify Files."
runtime_url = "https://example.com/api/flow/generate-invoice"
[extensions.settings]
[[extensions.settings.fields]]
type = "single_line_text_field"
key = "order_id"
name = "Order ID"
description = "The order's GID (use Liquid: {{order.id}} when configuring the workflow)."
required = true
@@ -0,0 +1,8 @@
# Send invoice email (Flow action)
Triggers `POST /api/flow/send-invoice-email` on the embedded app.
Inputs:
- `order_id` (commerce object reference, type `order`, required)
- `recipient_email_override` (single line text, optional)
If no invoice exists yet for the order, the endpoint generates one first.
@@ -0,0 +1,25 @@
api_version = "2026-01"
uid = "755ff6f6-8f68-10df-5f23-2244a5eed905c7b67bc4"
[[extensions]]
name = "Send invoice email"
type = "flow_action"
handle = "flow-send-invoice-email"
description = "Sends the generated PDF invoice via email to the order's customer (or an override address)."
runtime_url = "https://example.com/api/flow/send-invoice-email"
[extensions.settings]
[[extensions.settings.fields]]
type = "single_line_text_field"
key = "order_id"
name = "Order ID"
description = "The order's GID (use Liquid: {{order.id}} when configuring the workflow)."
required = true
[[extensions.settings.fields]]
type = "single_line_text_field"
key = "recipient_email_override"
name = "Recipient email (optional)"
description = "If set, sends to this address instead of the customer's email on the order."
required = false
@@ -0,0 +1,5 @@
{
"admin.order-details.action.render": {
"main": "dist/invoice-action.js"
}
}
@@ -0,0 +1,10 @@
{
"name": "invoice-order-action",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@shopify/ui-extensions": "^2026.1.0",
"preact": "^10.22.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/ActionExtension.tsx' {
const shopify: import('@shopify/ui-extensions/admin.order-details.action.render').Api;
const globalThis: { shopify: typeof shopify };
}
@@ -0,0 +1,11 @@
api_version = "2026-01"
[[extensions]]
name = "Generate invoice"
handle = "invoice-action"
type = "ui_extension"
uid = "linumiq-invoice-order-action"
[[extensions.targeting]]
target = "admin.order-details.action.render"
module = "./src/ActionExtension.tsx"
@@ -0,0 +1,55 @@
import { render } from "preact";
import { useState } from "preact/hooks";
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const { close, data } = (globalThis as any).shopify;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const orderGid: string | undefined = data?.selected?.[0]?.id;
const orderId = orderGid ? orderGid.split("/").pop() : undefined;
async function trigger(action: "generate" | "cancel_reissue") {
if (!orderId) {
setError("No order selected");
return;
}
setBusy(true);
setError(null);
try {
const body = new URLSearchParams({ action });
const res = await fetch(`/api/orders/${orderId}/invoice`, {
method: "POST",
body,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`HTTP ${res.status}: ${txt.slice(0, 200)}`);
}
close();
} catch (e: any) {
setError(e?.message ?? "Failed");
} finally {
setBusy(false);
}
}
return (
<s-admin-action heading="Generate invoice" loading={busy}>
<s-stack gap="200">
<s-text>Create or regenerate the PDF invoice for this order. The file will be uploaded to Shopify Files and linked via order metafields.</s-text>
{error ? <s-banner tone="critical">{error}</s-banner> : null}
</s-stack>
<s-button slot="primary-action" onClick={() => trigger("generate")} disabled={busy}>
Generate / regenerate
</s-button>
<s-button slot="secondary-actions" tone="critical" onClick={() => trigger("cancel_reissue")} disabled={busy}>
Cancel &amp; reissue
</s-button>
</s-admin-action>
);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
@@ -0,0 +1,5 @@
{
"admin.order-details.block.render": {
"main": "dist/invoice-order-block.js"
}
}
@@ -0,0 +1,10 @@
{
"name": "invoice-order-block",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@shopify/ui-extensions": "^2026.1.0",
"preact": "^10.22.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/BlockExtension.tsx' {
const shopify: import('@shopify/ui-extensions/admin.order-details.block.render').Api;
const globalThis: { shopify: typeof shopify };
}
@@ -0,0 +1,11 @@
api_version = "2026-01"
[[extensions]]
name = "Invoice details"
handle = "invoice-order-block"
type = "ui_extension"
uid = "linumiq-invoice-order-block"
[[extensions.targeting]]
target = "admin.order-details.block.render"
module = "./src/BlockExtension.tsx"
@@ -0,0 +1,79 @@
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
interface InvoiceRow {
id: string;
invoiceNumber: string;
version: number;
kind: string;
issuedAt: string;
status: string;
pdfUrl: string;
cancelledAt: string | null;
sentAt: string | null;
}
interface Payload {
latest: InvoiceRow | null;
history: InvoiceRow[];
}
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const { data } = (globalThis as any).shopify;
const orderGid: string | undefined = data?.selected?.[0]?.id;
const orderId = orderGid ? orderGid.split("/").pop() : undefined;
const [payload, setPayload] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!orderId) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/orders/${orderId}/invoice`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: Payload = await res.json();
if (!cancelled) setPayload(json);
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [orderId]);
return (
<s-admin-block heading="Invoice">
{loading ? (
<s-text>Loading</s-text>
) : error ? (
<s-banner tone="critical">{error}</s-banner>
) : !payload?.latest ? (
<s-text>No invoice yet for this order.</s-text>
) : (
<s-stack gap="200">
<s-text weight="bold">{payload.latest.invoiceNumber} (v{payload.latest.version})</s-text>
<s-text>Issued {new Date(payload.latest.issuedAt).toLocaleDateString()}</s-text>
<s-badge tone={payload.latest.sentAt ? "success" : "info"}>
{payload.latest.sentAt ? "Sent" : "Not sent"}
</s-badge>
{payload.latest.pdfUrl ? (
<s-link href={payload.latest.pdfUrl} target="_blank">View PDF</s-link>
) : null}
{payload.history.length > 1 ? (
<s-text tone="subdued">{payload.history.length} versions in history</s-text>
) : null}
</s-stack>
)}
</s-admin-block>
);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}