feat(customer-account): payment extension for order page (shares /api/public/payment-info; dual auth)
This commit is contained in:
@@ -6,10 +6,13 @@ import { getStrings, pickLanguage } from "../services/invoice/i18n";
|
|||||||
import { signGiroCodeUrl } from "../services/invoice/signedUrl";
|
import { signGiroCodeUrl } from "../services/invoice/signedUrl";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public endpoint consumed by the checkout / thank-you UI extension to fetch
|
* Public endpoint consumed by the checkout / thank-you UI extension AND by
|
||||||
* payment instructions (GiroCode + bank details) for an order.
|
* 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
|
* The shop domain is derived from `sessionToken.dest`; the order id is read
|
||||||
* from the `?orderId=` query parameter (numeric or GID, both accepted).
|
* 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.
|
* - the shop has an IBAN configured.
|
||||||
*/
|
*/
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { sessionToken, cors } = await authenticate.public.checkout(request);
|
let sessionToken: { dest?: string } | null = null;
|
||||||
const shop = (sessionToken.dest ?? "").toString().replace(/^https?:\/\//, "");
|
let cors: <T>(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) {
|
if (!shop) {
|
||||||
return cors(Response.json({ showPaymentInstructions: false, error: "no-shop" }, { status: 400 }));
|
return cors(Response.json({ showPaymentInstructions: false, error: "no-shop" }, { status: 400 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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(<Extension />, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const shopify = (globalThis as any).shopify;
|
||||||
|
const [data, setData] = useState<PaymentInstructions | null>(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 (
|
||||||
|
<s-section heading={data.heading}>
|
||||||
|
<s-paragraph>{data.instructions}</s-paragraph>
|
||||||
|
<s-grid gridTemplateColumns="200px 1fr" gap="base" alignItems="start">
|
||||||
|
<s-image src={data.giroCodeUrl} alt="GiroCode" inlineSize="fill" aspectRatio="1" />
|
||||||
|
<s-stack direction="block" gap="small-200">
|
||||||
|
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
|
||||||
|
{data.bankName ? (
|
||||||
|
<s-text>{data.labels.bank}: {data.bankName}</s-text>
|
||||||
|
) : null}
|
||||||
|
<s-text>{data.labels.iban}: {data.iban}</s-text>
|
||||||
|
{data.bic ? (
|
||||||
|
<s-text>{data.labels.bic}: {data.bic}</s-text>
|
||||||
|
) : null}
|
||||||
|
<s-text>{data.labels.amount}: {data.amountFormatted}</s-text>
|
||||||
|
<s-text>{data.labels.reference}: {data.reference}</s-text>
|
||||||
|
</s-stack>
|
||||||
|
</s-grid>
|
||||||
|
</s-section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user