From ca769c49a47bab6f63e0fa1da6581f44e343f84e Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sat, 9 May 2026 21:45:27 +0200 Subject: [PATCH] feat(customer-account): payment extension for order page (shares /api/public/payment-info; dual auth) --- app/routes/api.public.payment-info.tsx | 27 ++++- .../customer-account-payment/package.json | 11 ++ .../customer-account-payment/shopify.d.ts | 7 ++ .../shopify.extension.toml | 15 +++ .../src/CustomerAccount.tsx | 110 ++++++++++++++++++ .../customer-account-payment/tsconfig.json | 18 +++ 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 extensions/customer-account-payment/package.json create mode 100644 extensions/customer-account-payment/shopify.d.ts create mode 100644 extensions/customer-account-payment/shopify.extension.toml create mode 100644 extensions/customer-account-payment/src/CustomerAccount.tsx create mode 100644 extensions/customer-account-payment/tsconfig.json diff --git a/app/routes/api.public.payment-info.tsx b/app/routes/api.public.payment-info.tsx index d55fd64..f245155 100644 --- a/app/routes/api.public.payment-info.tsx +++ b/app/routes/api.public.payment-info.tsx @@ -6,10 +6,13 @@ import { getStrings, pickLanguage } from "../services/invoice/i18n"; import { signGiroCodeUrl } from "../services/invoice/signedUrl"; /** - * Public endpoint consumed by the checkout / thank-you UI extension to fetch - * payment instructions (GiroCode + bank details) for an order. + * Public endpoint consumed by the checkout / thank-you UI extension AND by + * the customer-account order page extension to fetch payment instructions + * (GiroCode + bank details) for an order. * - * Auth: validated Shopify checkout session token (via `authenticate.public.checkout`). + * Auth: validated Shopify session token. The handler tries + * `authenticate.public.customerAccount` first and falls back to + * `authenticate.public.checkout` so a single endpoint serves both surfaces. * The shop domain is derived from `sessionToken.dest`; the order id is read * from the `?orderId=` query parameter (numeric or GID, both accepted). * @@ -22,8 +25,22 @@ import { signGiroCodeUrl } from "../services/invoice/signedUrl"; * - the shop has an IBAN configured. */ export const loader = async ({ request }: LoaderFunctionArgs) => { - const { sessionToken, cors } = await authenticate.public.checkout(request); - const shop = (sessionToken.dest ?? "").toString().replace(/^https?:\/\//, ""); + let sessionToken: { dest?: string } | null = null; + let cors: (res: T) => T = (r) => r; + try { + const auth = await authenticate.public.customerAccount(request); + sessionToken = auth.sessionToken as { dest?: string }; + cors = auth.cors; + } catch { + try { + const auth = await authenticate.public.checkout(request); + sessionToken = auth.sessionToken as { dest?: string }; + cors = auth.cors; + } catch (err) { + throw err; + } + } + const shop = (sessionToken?.dest ?? "").toString().replace(/^https?:\/\//, ""); if (!shop) { return cors(Response.json({ showPaymentInstructions: false, error: "no-shop" }, { status: 400 })); } diff --git a/extensions/customer-account-payment/package.json b/extensions/customer-account-payment/package.json new file mode 100644 index 0000000..663e72d --- /dev/null +++ b/extensions/customer-account-payment/package.json @@ -0,0 +1,11 @@ +{ + "name": "customer-account-payment", + "version": "1.0.0", + "private": true, + "devDependencies": { + "@preact/signals": "^1.3.0", + "@shopify/ui-extensions": "^2026.1.0", + "preact": "^10.22.0", + "typescript": "^5.6.0" + } +} diff --git a/extensions/customer-account-payment/shopify.d.ts b/extensions/customer-account-payment/shopify.d.ts new file mode 100644 index 0000000..fc6f969 --- /dev/null +++ b/extensions/customer-account-payment/shopify.d.ts @@ -0,0 +1,7 @@ +import '@shopify/ui-extensions'; + +//@ts-ignore +declare module './src/CustomerAccount.tsx' { + const shopify: import('@shopify/ui-extensions/customer-account.order.page.render').Api; + const globalThis: { shopify: typeof shopify }; +} diff --git a/extensions/customer-account-payment/shopify.extension.toml b/extensions/customer-account-payment/shopify.extension.toml new file mode 100644 index 0000000..e51fa00 --- /dev/null +++ b/extensions/customer-account-payment/shopify.extension.toml @@ -0,0 +1,15 @@ +api_version = "2026-01" + +[[extensions]] +name = "Invoice payment instructions (account)" +handle = "customer-account-payment" +type = "ui_extension" +uid = "linumiq-customer-account-payment" + + [[extensions.targeting]] + target = "customer-account.order.page.render" + module = "./src/CustomerAccount.tsx" + + [extensions.capabilities] + network_access = true + api_access = false diff --git a/extensions/customer-account-payment/src/CustomerAccount.tsx b/extensions/customer-account-payment/src/CustomerAccount.tsx new file mode 100644 index 0000000..d729018 --- /dev/null +++ b/extensions/customer-account-payment/src/CustomerAccount.tsx @@ -0,0 +1,110 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; +import { useEffect, useState } from "preact/hooks"; + +const APP_URL_PROD = "https://invoice-app.linumiq.com"; +const APP_URL_DEV = "https://invoice-app-dev.linumiq.com"; +const DEV_SHOPS = new Set(["linumiq-dev.myshopify.com"]); + +function resolveAppUrl(shopify: any): string { + const shop: string | undefined = + shopify?.shop?.myshopifyDomain ?? shopify?.shop?.value?.myshopifyDomain; + if (shop && DEV_SHOPS.has(shop)) return APP_URL_DEV; + return APP_URL_PROD; +} + +interface PaymentInstructions { + language: "de" | "en"; + heading: string; + giroCodeUrl: string; + recipient: string; + bankName: string; + iban: string; + bic: string; + amountFormatted: string; + reference: string; + dueDateFormatted: string | null; + instructions: string; + labels: { + recipient: string; + bank: string; + iban: string; + bic: string; + amount: string; + reference: string; + }; +} + +export default async () => { + render(, document.body); +}; + +function Extension() { + const shopify = (globalThis as any).shopify; + const [data, setData] = useState(null); + const [done, setDone] = useState(false); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const orderId: string | undefined = shopify?.order?.value?.id; + if (!orderId) { + setDone(true); + return; + } + const token: string = await shopify.sessionToken.get(); + const appUrl = resolveAppUrl(shopify); + const res = await fetch( + `${appUrl}/api/public/payment-info?orderId=${encodeURIComponent(orderId)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) { + setDone(true); + return; + } + const json = (await res.json()) as { + showPaymentInstructions: boolean; + payload?: PaymentInstructions; + }; + if (cancelled) return; + if (json.showPaymentInstructions && json.payload) { + setData(json.payload); + } + } catch { + // swallow; render nothing + } finally { + if (!cancelled) setDone(true); + } + } + load(); + return () => { + cancelled = true; + }; + }, []); + + if (!done || !data) { + return null; + } + + return ( + + {data.instructions} + + + + {data.labels.recipient}: {data.recipient} + {data.bankName ? ( + {data.labels.bank}: {data.bankName} + ) : null} + {data.labels.iban}: {data.iban} + {data.bic ? ( + {data.labels.bic}: {data.bic} + ) : null} + {data.labels.amount}: {data.amountFormatted} + {data.labels.reference}: {data.reference} + + + + ); +} diff --git a/extensions/customer-account-payment/tsconfig.json b/extensions/customer-account-payment/tsconfig.json new file mode 100644 index 0000000..84713c4 --- /dev/null +++ b/extensions/customer-account-payment/tsconfig.json @@ -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/**/*"] +}