first version
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { generateInvoice } from "../services/invoice/generateInvoice.server";
|
||||
|
||||
/**
|
||||
* Flow action endpoint: "Generate invoice for order".
|
||||
*
|
||||
* Expected payload (verified by `authenticate.flow`):
|
||||
* {
|
||||
* "shop_id": "...",
|
||||
* "shopify_domain": "...",
|
||||
* "properties": { "order_id": "gid://shopify/Order/..." },
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* Returns 200 on success (Flow treats any 2xx as success) and 4xx/5xx
|
||||
* with a JSON body describing the failure.
|
||||
*/
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { session, admin, payload } = await authenticate.flow(request);
|
||||
|
||||
const orderId = extractOrderId(payload);
|
||||
if (!orderId) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Missing 'order_id' in Flow payload properties." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateInvoice({
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
});
|
||||
return { ok: true, ...result };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("flow.generate-invoice failed:", err);
|
||||
return Response.json({ ok: false, error: message }, { status: 422 });
|
||||
}
|
||||
};
|
||||
|
||||
function extractOrderId(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const props = (payload as { properties?: Record<string, unknown> }).properties;
|
||||
if (!props || typeof props !== "object") return null;
|
||||
const raw = props["order_id"];
|
||||
if (typeof raw === "string" && raw.length > 0) return raw;
|
||||
if (typeof raw === "number") return String(raw);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { generateInvoice } from "../services/invoice/generateInvoice.server";
|
||||
import { sendInvoiceEmail } from "../services/invoice/email.server";
|
||||
|
||||
/**
|
||||
* Flow action endpoint: "Send invoice email to customer".
|
||||
*
|
||||
* Generates the invoice if missing, then sends it via the shop's configured
|
||||
* SMTP. Marks the invoice as `sent`, locking it from in-place regeneration.
|
||||
*
|
||||
* Expected payload properties:
|
||||
* - order_id (required)
|
||||
* - recipient_email_override (optional)
|
||||
*/
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { session, admin, payload } = await authenticate.flow(request);
|
||||
|
||||
const orderId = extractOrderId(payload);
|
||||
if (!orderId) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Missing 'order_id' in Flow payload properties." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const recipientOverride = extractProp(payload, "recipient_email_override");
|
||||
|
||||
try {
|
||||
// Make sure an invoice exists.
|
||||
const orderGid = orderId.startsWith("gid://")
|
||||
? orderId
|
||||
: `gid://shopify/Order/${orderId}`;
|
||||
let invoice = await db.invoice.findFirst({
|
||||
where: {
|
||||
shopDomain: session.shop,
|
||||
orderId: orderGid,
|
||||
kind: "invoice",
|
||||
cancelledAt: null,
|
||||
},
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
if (!invoice) {
|
||||
const generated = await generateInvoice({
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
});
|
||||
invoice = await db.invoice.findUnique({ where: { id: generated.invoiceId } });
|
||||
}
|
||||
if (!invoice) throw new Error("Failed to materialise an invoice for this order.");
|
||||
|
||||
const result = await sendInvoiceEmail({
|
||||
shopDomain: session.shop,
|
||||
invoiceId: invoice.id,
|
||||
toAddress: recipientOverride || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return Response.json(
|
||||
{ ok: false, error: result.errorMessage ?? "Email send failed." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
return { ok: true, invoiceNumber: invoice.invoiceNumber, toAddress: result.toAddress };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("flow.send-invoice-email failed:", err);
|
||||
return Response.json({ ok: false, error: message }, { status: 422 });
|
||||
}
|
||||
};
|
||||
|
||||
function extractOrderId(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const props = (payload as { properties?: Record<string, unknown> }).properties;
|
||||
if (!props || typeof props !== "object") return null;
|
||||
const raw = props["order_id"];
|
||||
if (typeof raw === "string" && raw.length > 0) return raw;
|
||||
if (typeof raw === "number") return String(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractProp(payload: unknown, key: string): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const props = (payload as { properties?: Record<string, unknown> }).properties;
|
||||
if (!props || typeof props !== "object") return null;
|
||||
const raw = props[key];
|
||||
if (typeof raw === "string" && raw.length > 0) return raw;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { generateInvoice } from "../services/invoice/generateInvoice.server";
|
||||
import { cancelAndReissue } from "../services/invoice/cancelAndReissue.server";
|
||||
|
||||
/**
|
||||
* GET /api/orders/:orderId/invoice → returns latest invoice metadata + history
|
||||
* POST /api/orders/:orderId/invoice → generates (or regenerates) the invoice
|
||||
*
|
||||
* `orderId` may be a numeric Shopify order id or a full GID; the generator
|
||||
* normalises it.
|
||||
*/
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const orderId = requireOrderId(params);
|
||||
const orderGid = orderId.startsWith("gid://")
|
||||
? orderId
|
||||
: `gid://shopify/Order/${orderId}`;
|
||||
|
||||
const invoices = await db.invoice.findMany({
|
||||
where: { shopDomain: session.shop, orderId: orderGid },
|
||||
orderBy: [{ issuedAt: "desc" }],
|
||||
});
|
||||
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt);
|
||||
|
||||
return {
|
||||
latest: latest ? serialise(latest) : null,
|
||||
history: invoices.map(serialise),
|
||||
};
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
if (request.method !== "POST") {
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
const orderId = requireOrderId(params);
|
||||
const url = new URL(request.url);
|
||||
let op = url.searchParams.get("action");
|
||||
if (!op) {
|
||||
// Also accept the action from the form body (used by the in-app fetcher).
|
||||
const ct = request.headers.get("content-type") || "";
|
||||
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData();
|
||||
op = (form.get("action") as string | null) ?? null;
|
||||
}
|
||||
}
|
||||
op = op ?? "generate";
|
||||
|
||||
try {
|
||||
if (op === "cancel_reissue") {
|
||||
const result = await cancelAndReissue({
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
});
|
||||
return { ok: true, op, ...result };
|
||||
}
|
||||
|
||||
const result = await generateInvoice({
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
});
|
||||
return { ok: true, op: "generate", ...result };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("invoice action failed:", err);
|
||||
return Response.json({ ok: false, error: message }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
function requireOrderId(params: { orderId?: string }): string {
|
||||
const id = params.orderId;
|
||||
if (!id) throw new Response("orderId is required", { status: 400 });
|
||||
return id;
|
||||
}
|
||||
|
||||
function serialise(invoice: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
version: number;
|
||||
kind: string;
|
||||
pdfUrl: string;
|
||||
status: string;
|
||||
sentAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
issuedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
version: invoice.version,
|
||||
kind: invoice.kind,
|
||||
pdfUrl: invoice.pdfUrl,
|
||||
status: invoice.status,
|
||||
sentAt: invoice.sentAt?.toISOString() ?? null,
|
||||
cancelledAt: invoice.cancelledAt?.toISOString() ?? null,
|
||||
issuedAt: invoice.issuedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
+68
-321
@@ -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 & 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'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);
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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><ui-nav-menu></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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { Link, useLoaderData, useNavigation, useFetcher } from "react-router";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
interface RecentOrder {
|
||||
id: string; // gid
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPrice: string;
|
||||
currency: string;
|
||||
hasInvoice: boolean;
|
||||
invoiceNumber?: string;
|
||||
invoiceSent?: boolean;
|
||||
invoiceCancelled?: boolean;
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
const RECENT_ORDERS_QUERY = `#graphql
|
||||
query RecentOrders($first: Int!) {
|
||||
orders(first: $first, sortKey: CREATED_AT, reverse: true) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
displayFinancialStatus
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const filter = url.searchParams.get("filter") ?? "all";
|
||||
|
||||
// Recent orders from Shopify (first 25).
|
||||
let orders: RecentOrder[] = [];
|
||||
try {
|
||||
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
orders?: {
|
||||
nodes?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const nodes = json.data?.orders?.nodes ?? [];
|
||||
const orderIds = nodes.map((n) => n.id);
|
||||
|
||||
// Look up which of these orders already have a non-cancelled invoice.
|
||||
const invoices = await db.invoice.findMany({
|
||||
where: {
|
||||
shopDomain: session.shop,
|
||||
orderId: { in: orderIds },
|
||||
kind: "invoice",
|
||||
},
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
const latestByOrder = new Map<string, (typeof invoices)[number]>();
|
||||
for (const inv of invoices) {
|
||||
if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv);
|
||||
}
|
||||
|
||||
orders = nodes.map((n) => {
|
||||
const inv = latestByOrder.get(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
createdAt: n.createdAt,
|
||||
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
|
||||
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
|
||||
hasInvoice: !!inv && !inv.cancelledAt,
|
||||
invoiceNumber: inv?.invoiceNumber,
|
||||
invoiceSent: !!inv?.sentAt,
|
||||
invoiceCancelled: !!inv?.cancelledAt,
|
||||
pdfUrl: inv?.pdfUrl,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to load recent orders:", err);
|
||||
}
|
||||
|
||||
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
|
||||
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
|
||||
|
||||
return { orders, filter };
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const { orders, filter } = useLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
|
||||
return (
|
||||
<s-page heading="Invoices">
|
||||
<s-section heading="Recent orders">
|
||||
<s-paragraph>
|
||||
Generate or view the latest invoice for each order. For sent
|
||||
invoices, use Cancel & reissue to issue a Stornorechnung followed
|
||||
by a fresh invoice.
|
||||
</s-paragraph>
|
||||
|
||||
<s-stack direction="inline" gap="base">
|
||||
<Link to="?filter=all">All</Link>
|
||||
<Link to="?filter=missing">Missing invoice</Link>
|
||||
<Link to="?filter=with">Has invoice</Link>
|
||||
</s-stack>
|
||||
|
||||
{isLoading ? (
|
||||
<s-paragraph>Loading…</s-paragraph>
|
||||
) : orders.length === 0 ? (
|
||||
<s-paragraph>No orders match the current filter.</s-paragraph>
|
||||
) : (
|
||||
<s-unordered-list>
|
||||
{orders.map((o) => (
|
||||
<OrderRow key={o.id} order={o} />
|
||||
))}
|
||||
</s-unordered-list>
|
||||
)}
|
||||
</s-section>
|
||||
<s-section heading="Bulk generate">
|
||||
<s-paragraph>
|
||||
Use the buttons next to each order to generate one at a time. (A
|
||||
true multi-select bulk run will be added once the admin block
|
||||
extension is installed.)
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
Tip: filter by <em>Missing invoice</em> to see orders that still
|
||||
need one.
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderRow({ order }: { order: RecentOrder }) {
|
||||
const numericId = order.id.replace(/^.*\//, "");
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
|
||||
return (
|
||||
<s-list-item>
|
||||
<s-stack direction="inline" gap="base" align-items="center">
|
||||
<strong>{order.name}</strong>
|
||||
<span>{order.totalPrice} {order.currency}</span>
|
||||
{order.hasInvoice ? (
|
||||
<span>
|
||||
✓ {order.invoiceNumber}
|
||||
{order.invoiceCancelled
|
||||
? " (cancelled)"
|
||||
: order.invoiceSent
|
||||
? " (sent)"
|
||||
: ""}
|
||||
{order.pdfUrl ? (
|
||||
<>
|
||||
{" — "}
|
||||
<a href={order.pdfUrl} target="_blank" rel="noreferrer">PDF</a>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>— no invoice yet</span>
|
||||
)}
|
||||
<fetcher.Form method="post" action={`/api/orders/${numericId}/invoice`}>
|
||||
<s-button type="submit" disabled={isBusy} variant="primary">
|
||||
{order.hasInvoice
|
||||
? order.invoiceSent
|
||||
? "Cancel & reissue"
|
||||
: "Regenerate"
|
||||
: "Generate"}
|
||||
</s-button>
|
||||
{order.hasInvoice && order.invoiceSent && (
|
||||
<input type="hidden" name="action" value="cancel_reissue" />
|
||||
)}
|
||||
</fetcher.Form>
|
||||
{fetcher.data?.error && (
|
||||
<span style={{ color: "red" }}>{fetcher.data.error}</span>
|
||||
)}
|
||||
</s-stack>
|
||||
</s-list-item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import {
|
||||
isValidAtVatId,
|
||||
isValidBic,
|
||||
isValidIban,
|
||||
normaliseIban,
|
||||
} from "../services/invoice/validation";
|
||||
|
||||
interface SettingsFieldErrors {
|
||||
vatId?: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
smtpPort?: string;
|
||||
paymentTermDays?: string;
|
||||
invoiceSeed?: string;
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const settings = await db.shopSettings.upsert({
|
||||
where: { shopDomain: session.shop },
|
||||
update: {},
|
||||
create: { shopDomain: session.shop },
|
||||
});
|
||||
return { settings };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const form = await request.formData();
|
||||
const errors: SettingsFieldErrors = {};
|
||||
|
||||
const str = (k: string, fallback = "") => (form.get(k) ?? fallback).toString().trim();
|
||||
const bool = (k: string) => form.get(k) === "on";
|
||||
const intOrNull = (k: string): number | null => {
|
||||
const raw = form.get(k);
|
||||
if (raw === null || raw === "") return null;
|
||||
const n = parseInt(raw.toString(), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const vatId = str("vatId").toUpperCase();
|
||||
if (vatId && !isValidAtVatId(vatId)) {
|
||||
errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678).";
|
||||
}
|
||||
|
||||
const iban = normaliseIban(str("iban"));
|
||||
if (iban && !isValidIban(iban)) {
|
||||
errors.iban = "Invalid IBAN (failed checksum or unknown country length).";
|
||||
}
|
||||
|
||||
const bic = str("bic").toUpperCase();
|
||||
if (bic && !isValidBic(bic)) {
|
||||
errors.bic = "Invalid BIC (8 or 11 alphanumeric characters).";
|
||||
}
|
||||
|
||||
const smtpPort = intOrNull("smtpPort");
|
||||
if (smtpPort !== null && (smtpPort < 1 || smtpPort > 65535)) {
|
||||
errors.smtpPort = "Port must be between 1 and 65535.";
|
||||
}
|
||||
|
||||
const paymentTermDays = intOrNull("paymentTermDays");
|
||||
if (paymentTermDays !== null && (paymentTermDays < 0 || paymentTermDays > 365)) {
|
||||
errors.paymentTermDays = "Must be between 0 and 365.";
|
||||
}
|
||||
|
||||
const invoiceSeed = intOrNull("invoiceSeed");
|
||||
if (invoiceSeed !== null && invoiceSeed < 0) {
|
||||
errors.invoiceSeed = "Must be a non-negative number.";
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, errors, savedAt: null as string | null };
|
||||
}
|
||||
|
||||
const data = {
|
||||
companyName: str("companyName"),
|
||||
legalForm: str("legalForm"),
|
||||
ownerName: str("ownerName"),
|
||||
addressLine1: str("addressLine1"),
|
||||
addressLine2: str("addressLine2"),
|
||||
postalCode: str("postalCode"),
|
||||
city: str("city"),
|
||||
countryCode: str("countryCode", "AT").toUpperCase(),
|
||||
phone: str("phone"),
|
||||
email: str("email"),
|
||||
website: str("website"),
|
||||
vatId,
|
||||
taxNumber: str("taxNumber"),
|
||||
registrationNo: str("registrationNo"),
|
||||
registrationCourt: str("registrationCourt"),
|
||||
bankName: str("bankName"),
|
||||
iban,
|
||||
bic,
|
||||
giroCodeEnabled: bool("giroCodeEnabled"),
|
||||
numberingMode: str("numberingMode", "shopify_order_number"),
|
||||
invoicePrefix: str("invoicePrefix"),
|
||||
invoiceSeed: invoiceSeed ?? 1000,
|
||||
defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de",
|
||||
paymentTermDays: paymentTermDays ?? 14,
|
||||
footerNote: str("footerNote"),
|
||||
kleinunternehmer: bool("kleinunternehmer"),
|
||||
logoUrl: str("logoUrl"),
|
||||
smtpHost: str("smtpHost"),
|
||||
smtpPort: smtpPort ?? 587,
|
||||
smtpSecure: bool("smtpSecure"),
|
||||
smtpUser: str("smtpUser"),
|
||||
smtpPassword: str("smtpPassword"),
|
||||
smtpFromName: str("smtpFromName"),
|
||||
smtpFromEmail: str("smtpFromEmail"),
|
||||
smtpReplyTo: str("smtpReplyTo"),
|
||||
};
|
||||
|
||||
await db.shopSettings.upsert({
|
||||
where: { shopDomain: session.shop },
|
||||
update: data,
|
||||
create: { shopDomain: session.shop, ...data },
|
||||
});
|
||||
|
||||
return { ok: true, errors: {}, savedAt: new Date().toISOString() };
|
||||
};
|
||||
|
||||
export default function SettingsRoute() {
|
||||
const { settings } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const nav = useNavigation();
|
||||
const isSaving = nav.state === "submitting";
|
||||
const errors = actionData?.errors ?? {};
|
||||
|
||||
return (
|
||||
<s-page heading="Invoice settings">
|
||||
<s-paragraph>
|
||||
Configure issuer details, bank, numbering, language and SMTP. These
|
||||
values are written into every PDF invoice this app generates and must
|
||||
match your legal records.
|
||||
</s-paragraph>
|
||||
|
||||
{actionData?.ok && (
|
||||
<s-banner tone="success">Settings saved.</s-banner>
|
||||
)}
|
||||
{actionData && !actionData.ok && (
|
||||
<s-banner tone="critical">
|
||||
Please fix the errors highlighted below.
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<Form method="post">
|
||||
<s-section heading="Company">
|
||||
<s-stack direction="block" gap="base">
|
||||
<Field label="Company name" name="companyName" defaultValue={settings.companyName} />
|
||||
<Field label="Legal form (e.g. e.U., GmbH)" name="legalForm" defaultValue={settings.legalForm} />
|
||||
<Field label="Owner / managing director" name="ownerName" defaultValue={settings.ownerName} />
|
||||
<Field label="Address line 1" name="addressLine1" defaultValue={settings.addressLine1} />
|
||||
<Field label="Address line 2" name="addressLine2" defaultValue={settings.addressLine2} />
|
||||
<s-stack direction="inline" gap="base">
|
||||
<Field label="Postal code" name="postalCode" defaultValue={settings.postalCode} />
|
||||
<Field label="City" name="city" defaultValue={settings.city} />
|
||||
<Field label="Country (ISO)" name="countryCode" defaultValue={settings.countryCode} />
|
||||
</s-stack>
|
||||
<s-stack direction="inline" gap="base">
|
||||
<Field label="Phone" name="phone" defaultValue={settings.phone} />
|
||||
<Field label="E-mail" name="email" defaultValue={settings.email} />
|
||||
<Field label="Website" name="website" defaultValue={settings.website} />
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Legal identifiers">
|
||||
<s-stack direction="block" gap="base">
|
||||
<Field
|
||||
label="VAT ID (UID)"
|
||||
name="vatId"
|
||||
defaultValue={settings.vatId}
|
||||
error={errors.vatId}
|
||||
helpText="Austrian format: ATU followed by 8 digits."
|
||||
/>
|
||||
<Field label="Tax number (Steuernummer)" name="taxNumber" defaultValue={settings.taxNumber} />
|
||||
<Field label="Commercial register no. (FN)" name="registrationNo" defaultValue={settings.registrationNo} />
|
||||
<Field label="Commercial register court" name="registrationCourt" defaultValue={settings.registrationCourt} />
|
||||
<Toggle
|
||||
label="I am a Kleinunternehmer (no VAT charged, § 6 Abs. 1 Z 27 UStG)"
|
||||
name="kleinunternehmer"
|
||||
checked={settings.kleinunternehmer}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Bank">
|
||||
<s-stack direction="block" gap="base">
|
||||
<Field label="Bank name" name="bankName" defaultValue={settings.bankName} />
|
||||
<Field
|
||||
label="IBAN"
|
||||
name="iban"
|
||||
defaultValue={settings.iban}
|
||||
error={errors.iban}
|
||||
/>
|
||||
<Field
|
||||
label="BIC"
|
||||
name="bic"
|
||||
defaultValue={settings.bic}
|
||||
error={errors.bic}
|
||||
/>
|
||||
<Toggle
|
||||
label="Render GiroCode (EPC QR) on unpaid invoices"
|
||||
name="giroCodeEnabled"
|
||||
checked={settings.giroCodeEnabled}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Invoice numbering">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
<strong>shopify_order_number</strong> reuses the Shopify order
|
||||
number (e.g. <code>RE-1004</code>). <strong>prefix_sequential</strong>
|
||||
{" "}allocates a strictly gapless number from an internal counter
|
||||
(legally safest under § 11 UStG).
|
||||
</s-paragraph>
|
||||
<Select
|
||||
label="Numbering mode"
|
||||
name="numberingMode"
|
||||
defaultValue={settings.numberingMode}
|
||||
options={[
|
||||
{ value: "shopify_order_number", label: "Use Shopify order number" },
|
||||
{ value: "prefix_sequential", label: "Sequential (gapless, app-managed)" },
|
||||
]}
|
||||
/>
|
||||
<Field label="Invoice number prefix" name="invoicePrefix" defaultValue={settings.invoicePrefix} />
|
||||
<Field
|
||||
label="Sequential start (last issued; next = this + 1)"
|
||||
name="invoiceSeed"
|
||||
type="number"
|
||||
defaultValue={String(settings.invoiceSeed)}
|
||||
error={errors.invoiceSeed}
|
||||
/>
|
||||
<Field
|
||||
label="Payment term (days)"
|
||||
name="paymentTermDays"
|
||||
type="number"
|
||||
defaultValue={String(settings.paymentTermDays)}
|
||||
error={errors.paymentTermDays}
|
||||
/>
|
||||
<Select
|
||||
label="Default language"
|
||||
name="defaultLanguage"
|
||||
defaultValue={settings.defaultLanguage}
|
||||
options={[
|
||||
{ value: "de", label: "German (de)" },
|
||||
{ value: "en", label: "English (en)" },
|
||||
]}
|
||||
/>
|
||||
<Field label="Footer note (optional)" name="footerNote" defaultValue={settings.footerNote} />
|
||||
<Field label="Logo URL (PNG/JPG, served from Shopify Files or any HTTPS URL)" name="logoUrl" defaultValue={settings.logoUrl} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="SMTP (used by the email Flow action)">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-stack direction="inline" gap="base">
|
||||
<Field label="Host" name="smtpHost" defaultValue={settings.smtpHost} />
|
||||
<Field
|
||||
label="Port"
|
||||
name="smtpPort"
|
||||
type="number"
|
||||
defaultValue={String(settings.smtpPort)}
|
||||
error={errors.smtpPort}
|
||||
/>
|
||||
</s-stack>
|
||||
<Toggle
|
||||
label="Use TLS (SMTPS) — typically required for port 465"
|
||||
name="smtpSecure"
|
||||
checked={settings.smtpSecure}
|
||||
/>
|
||||
<Field label="Username" name="smtpUser" defaultValue={settings.smtpUser} />
|
||||
<Field label="Password" name="smtpPassword" type="password" defaultValue={settings.smtpPassword} />
|
||||
<s-stack direction="inline" gap="base">
|
||||
<Field label="From name" name="smtpFromName" defaultValue={settings.smtpFromName} />
|
||||
<Field label="From e-mail" name="smtpFromEmail" defaultValue={settings.smtpFromEmail} />
|
||||
<Field label="Reply-to" name="smtpReplyTo" defaultValue={settings.smtpReplyTo} />
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
|
||||
Save settings
|
||||
</s-button>
|
||||
</s-stack>
|
||||
</Form>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
type?: string;
|
||||
error?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
function Field({ label, name, defaultValue = "", type = "text", error, helpText }: FieldProps) {
|
||||
// Polaris web-components don't expose a generic html `type` prop; we use
|
||||
// distinct tags only when needed (e.g. password). The form action parses
|
||||
// numeric strings server-side.
|
||||
if (type === "password") {
|
||||
return (
|
||||
// s-password-field renders an obscured input
|
||||
<s-password-field
|
||||
label={label}
|
||||
name={name}
|
||||
value={defaultValue}
|
||||
{...(error ? { error } : {})}
|
||||
{...(helpText ? { details: helpText } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<s-text-field
|
||||
label={label}
|
||||
name={name}
|
||||
value={defaultValue}
|
||||
{...(error ? { error } : {})}
|
||||
{...(helpText ? { details: helpText } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleProps { label: string; name: string; checked: boolean }
|
||||
function Toggle({ label, name, checked }: ToggleProps) {
|
||||
return (
|
||||
<s-checkbox
|
||||
name={name}
|
||||
label={label}
|
||||
{...(checked ? { checked: true } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
function Select({ label, name, defaultValue, options }: SelectProps) {
|
||||
return (
|
||||
<s-select label={label} name={name} value={defaultValue}>
|
||||
{options.map((o) => (
|
||||
<s-option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</s-option>
|
||||
))}
|
||||
</s-select>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -19,7 +19,8 @@ export default function App() {
|
||||
<AppProvider embedded apiKey={apiKey}>
|
||||
<s-app-nav>
|
||||
<s-link href="/app">Home</s-link>
|
||||
<s-link href="/app/additional">Additional page</s-link>
|
||||
<s-link href="/app/invoices">Invoices</s-link>
|
||||
<s-link href="/app/settings">Settings</s-link>
|
||||
</s-app-nav>
|
||||
<Outlet />
|
||||
</AppProvider>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
|
||||
// We don't auto-generate invoices on order create. This handler just
|
||||
// acknowledges the webhook so Shopify keeps it healthy and gives us a
|
||||
// hook point for future work (e.g. cache invalidation).
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
return new Response();
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
|
||||
// Acknowledged but not yet acted on. Future: invalidate cached invoice
|
||||
// snapshots when a relevant field on the order changes.
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
return new Response();
|
||||
};
|
||||
Reference in New Issue
Block a user