c24d567ae4
Customer reported that on the German invoice PDF the payment method showed up as 'Zahlart: Bank Deposit' while the order-confirmation page on the storefront localized it correctly to 'Bank\u00fc1berweisung'. Cause: Shopify's Admin GraphQL API only ever returns the *English* template name in 'Order.paymentGatewayNames', even when the shop / order locale is German \u2014 the localization happens client-side at checkout but is NOT exposed via the API. So the PDF and the storefront naturally diverge unless we mirror the translations ourselves. Fix: introduce a per-language 'paymentGatewayLabels' map on 'InvoiceStrings' covering the built-in Shopify manual-payment templates (Bank Deposit, Money Order, Cash on Delivery) plus the standard non-manual gateways (Shopify Payments, PayPal, Klarna, Sofort, Giropay, Bogus). 'prettifyGatewayName' now takes this map and looks up the normalized key (lowercased, separators collapsed), falling back to a title-cased rendering for unknown values. DE result: 'Zahlart: Bank\u00fc1berweisung', 'Manuelle Zahlung', 'Nachnahme'. EN result: unchanged. New smoke assertions verify the DE PDF now shows 'Manuelle Zahlung' for the AT B2B fixture's 'manual' gateway and that the raw English 'Manual' no longer appears next to the 'Zahlart' label. Note on other Shopify-sourced strings on the PDF: 'shippingLine.title' (e.g. 'Standard') is similarly merchant/locale-dependent, but unlike gateway names it's fully customizable per-shop in Shopify Admin and is not a fixed enum we can translate \u2014 left untouched pending an explicit report. Product titles, discount codes and addresses are likewise merchant-/customer-supplied and flow through verbatim by design.
668 lines
22 KiB
TypeScript
668 lines
22 KiB
TypeScript
/* eslint-disable react/no-unknown-property */
|
|
import {
|
|
Document,
|
|
Image,
|
|
Link,
|
|
Page,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "@react-pdf/renderer";
|
|
import React from "react";
|
|
|
|
import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format";
|
|
import { getStrings, paymentStatusLabel as getPaymentStatusLabel } from "../i18n";
|
|
import type { InvoiceLanguage } from "../i18n";
|
|
import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types";
|
|
|
|
// Brand blue chosen to roughly match the reference invoice. This is not
|
|
// pixel-perfect; merchants can tweak via a future setting if needed.
|
|
const BRAND_BLUE = "#1E8FCD";
|
|
const TEXT_DARK = "#1F2933";
|
|
const TEXT_MUTED = "#6B7280";
|
|
const TABLE_BORDER = "#E5E7EB";
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
paddingTop: 40,
|
|
paddingBottom: 110, // leaves room for fixed footer
|
|
paddingHorizontal: 40,
|
|
fontSize: 9,
|
|
fontFamily: "Helvetica",
|
|
color: TEXT_DARK,
|
|
lineHeight: 1.4,
|
|
},
|
|
headerRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
marginBottom: 24,
|
|
},
|
|
logo: {
|
|
maxHeight: 50,
|
|
maxWidth: 180,
|
|
objectFit: "contain",
|
|
},
|
|
senderLine: {
|
|
fontSize: 7,
|
|
color: TEXT_MUTED,
|
|
marginBottom: 4,
|
|
textDecoration: "underline",
|
|
},
|
|
recipientBlock: {
|
|
width: "55%",
|
|
},
|
|
recipientBlockFull: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 20,
|
|
},
|
|
recipientName: {
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 10,
|
|
},
|
|
shippingAddressBlock: {
|
|
marginTop: 10,
|
|
paddingTop: 6,
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: TABLE_BORDER,
|
|
},
|
|
shippingAddressHeading: {
|
|
fontFamily: "Helvetica-Bold",
|
|
color: BRAND_BLUE,
|
|
fontSize: 8,
|
|
marginBottom: 2,
|
|
},
|
|
metaBlock: {
|
|
width: "45%",
|
|
},
|
|
metaBlockHeader: {
|
|
width: "50%",
|
|
},
|
|
metaTable: {
|
|
flexDirection: "column",
|
|
},
|
|
metaRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 2,
|
|
},
|
|
metaLabel: {
|
|
color: TEXT_MUTED,
|
|
},
|
|
metaValue: {
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
unitOriginalStrike: {
|
|
color: TEXT_MUTED,
|
|
textDecoration: "line-through",
|
|
fontSize: 7,
|
|
},
|
|
invoiceNumberBig: {
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 14,
|
|
},
|
|
title: {
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 18,
|
|
marginTop: 20,
|
|
marginBottom: 12,
|
|
},
|
|
paragraph: {
|
|
marginBottom: 6,
|
|
},
|
|
table: {
|
|
marginTop: 10,
|
|
borderTopWidth: 0,
|
|
},
|
|
tableHeader: {
|
|
flexDirection: "row",
|
|
backgroundColor: BRAND_BLUE,
|
|
color: "#FFFFFF",
|
|
fontFamily: "Helvetica-Bold",
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 4,
|
|
},
|
|
tableRow: {
|
|
flexDirection: "row",
|
|
borderBottomWidth: 0.5,
|
|
borderBottomColor: TABLE_BORDER,
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 4,
|
|
},
|
|
colPos: { width: "8%" },
|
|
colDescription: { width: "44%" },
|
|
colQty: { width: "16%", textAlign: "right" },
|
|
colUnit: { width: "16%", textAlign: "right" },
|
|
colTotal: { width: "16%", textAlign: "right" },
|
|
descriptionCell: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
gap: 6,
|
|
},
|
|
productIcon: {
|
|
width: 28,
|
|
height: 28,
|
|
objectFit: "contain",
|
|
borderWidth: 0.5,
|
|
borderColor: TABLE_BORDER,
|
|
borderRadius: 2,
|
|
},
|
|
productIconPlaceholder: {
|
|
width: 28,
|
|
height: 28,
|
|
},
|
|
descriptionText: {
|
|
flex: 1,
|
|
},
|
|
itemTitle: {
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
itemSku: {
|
|
color: TEXT_MUTED,
|
|
fontSize: 7,
|
|
marginTop: 1,
|
|
},
|
|
totalsBlock: {
|
|
marginTop: 10,
|
|
alignSelf: "flex-end",
|
|
width: "50%",
|
|
// Match the table rows' horizontal padding so the right-aligned amounts
|
|
// line up perfectly with the "Total" column above.
|
|
paddingHorizontal: 4,
|
|
},
|
|
totalRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
paddingVertical: 3,
|
|
},
|
|
totalLabel: {
|
|
color: TEXT_DARK,
|
|
},
|
|
totalLabelBlue: {
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
totalValue: {
|
|
textAlign: "right",
|
|
},
|
|
totalValueBoldBlue: {
|
|
textAlign: "right",
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
noticeBlock: {
|
|
marginTop: 10,
|
|
padding: 6,
|
|
backgroundColor: "#F3F4F6",
|
|
fontSize: 8,
|
|
},
|
|
giroBlock: {
|
|
marginTop: 20,
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
gap: 10,
|
|
},
|
|
giroImage: {
|
|
width: 90,
|
|
height: 90,
|
|
},
|
|
giroCaption: {
|
|
fontFamily: "Helvetica-Bold",
|
|
color: BRAND_BLUE,
|
|
fontSize: 9,
|
|
marginBottom: 4,
|
|
},
|
|
giroDetails: {
|
|
fontSize: 8,
|
|
color: TEXT_DARK,
|
|
lineHeight: 1.4,
|
|
},
|
|
closing: {
|
|
marginTop: 24,
|
|
},
|
|
footer: {
|
|
position: "absolute",
|
|
bottom: 30,
|
|
left: 40,
|
|
right: 40,
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: BRAND_BLUE,
|
|
paddingTop: 6,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
fontSize: 7,
|
|
color: TEXT_DARK,
|
|
},
|
|
footerCol: { width: "23%" },
|
|
footerHeading: {
|
|
fontFamily: "Helvetica-Bold",
|
|
color: BRAND_BLUE,
|
|
fontSize: 7,
|
|
marginBottom: 3,
|
|
},
|
|
pageIndicator: {
|
|
position: "absolute",
|
|
bottom: 12,
|
|
right: 40,
|
|
fontSize: 7,
|
|
color: TEXT_MUTED,
|
|
},
|
|
stornoBanner: {
|
|
backgroundColor: "#B91C1C",
|
|
color: "#FFFFFF",
|
|
fontFamily: "Helvetica-Bold",
|
|
padding: 6,
|
|
marginBottom: 10,
|
|
fontSize: 11,
|
|
textAlign: "center",
|
|
},
|
|
});
|
|
|
|
interface DocProps {
|
|
invoice: InvoiceViewModel;
|
|
}
|
|
|
|
export function InvoiceDocument({ invoice }: DocProps) {
|
|
const t = getStrings(invoice.language);
|
|
const cur = invoice.currency;
|
|
|
|
return (
|
|
<Document
|
|
title={`${invoice.kind === "offer" ? t.offer : t.invoice} ${invoice.number}`}
|
|
author={invoice.issuer.companyName}
|
|
creator="LinumIQ Invoice"
|
|
>
|
|
<Page size="A4" style={styles.page}>
|
|
{invoice.kind === "storno" && (
|
|
<Text style={styles.stornoBanner}>
|
|
{t.stornoInvoice}
|
|
{invoice.cancelsNumber ? ` — ${t.stornoReference(invoice.cancelsNumber)}` : ""}
|
|
</Text>
|
|
)}
|
|
|
|
<View style={styles.headerRow}>
|
|
{invoice.issuer.logoDataUrl ? (
|
|
<Image src={invoice.issuer.logoDataUrl} style={styles.logo} />
|
|
) : (
|
|
<View />
|
|
)}
|
|
<View style={styles.metaBlockHeader}>
|
|
<View style={styles.metaTable}>
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
|
|
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
|
</View>
|
|
{invoice.kind !== "offer" && (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
|
|
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
|
|
</View>
|
|
)}
|
|
{invoice.recipientVatId ? (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.customerVatId}</Text>
|
|
<Text style={styles.metaValue}>{invoice.recipientVatId}</Text>
|
|
</View>
|
|
) : null}
|
|
{invoice.kind === "invoice" && invoice.paymentGatewayNames.length > 0 && (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.paymentMethodLabel}</Text>
|
|
<Text style={styles.metaValue}>
|
|
{invoice.paymentGatewayNames.map((n) => prettifyGatewayName(n, t.paymentGatewayLabels)).join(", ")}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{invoice.kind === "invoice" && (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.paymentStatusLabel}</Text>
|
|
<Text style={styles.metaValue}>
|
|
{getPaymentStatusLabel(invoice.paymentStatus, t)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.discountCodeLabel}</Text>
|
|
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
|
|
</View>
|
|
) : null}
|
|
{invoice.kind === "invoice" && invoice.isPickup ? (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.pickupLocationLabel}</Text>
|
|
<Text style={styles.metaValue}>
|
|
{invoice.pickupLocationName ?? t.pickupLabel}
|
|
</Text>
|
|
</View>
|
|
) : invoice.kind === "invoice" && invoice.shippingMethod ? (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
|
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
|
|
</View>
|
|
) : null}
|
|
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
|
|
<View key={tr.number} style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>
|
|
{t.trackingLabel}
|
|
{tr.company ? ` (${tr.company})` : ""}
|
|
</Text>
|
|
{tr.url ? (
|
|
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
|
|
) : (
|
|
<Text style={styles.metaValue}>{tr.number}</Text>
|
|
)}
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.recipientBlockFull}>
|
|
<View style={styles.recipientBlock}>
|
|
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
|
<Recipient recipient={invoice.recipient} />
|
|
</View>
|
|
{invoice.separateShippingAddress ? (
|
|
<View style={styles.recipientBlock}>
|
|
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
|
|
<Recipient recipient={invoice.separateShippingAddress} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
<Text style={styles.title}>
|
|
{invoice.kind === "storno"
|
|
? t.stornoInvoice
|
|
: invoice.kind === "offer"
|
|
? t.offer
|
|
: t.invoice}{" "}
|
|
Nr. {invoice.number}
|
|
{invoice.kind === "invoice"
|
|
&& invoice.orderName
|
|
// Suppress the redundant "· Bestellnummer: #1004" suffix when
|
|
// the invoice number is just the Shopify order number with the
|
|
// configured prefix (default numbering mode) — they'd carry
|
|
// identical trailing digits and only confuse the customer.
|
|
&& invoice.number.replace(/\D+/g, "") !== invoice.orderName.replace(/\D+/g, "")
|
|
? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
|
|
: ""}
|
|
</Text>
|
|
|
|
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
|
|
<Text style={styles.paragraph}>{t.thankYouLine}</Text>
|
|
|
|
<View style={styles.table}>
|
|
<View style={styles.tableHeader}>
|
|
<Text style={styles.colPos}>{t.position}</Text>
|
|
<Text style={styles.colDescription}>{t.description}</Text>
|
|
<Text style={styles.colQty}>{t.quantity}</Text>
|
|
<Text style={styles.colUnit}>{t.unitPrice}</Text>
|
|
<Text style={styles.colTotal}>{t.totalPrice}</Text>
|
|
</View>
|
|
{invoice.lines.map((line) => (
|
|
<LineRow key={line.position} line={line} language={invoice.language} currency={cur} />
|
|
))}
|
|
</View>
|
|
|
|
<View style={styles.totalsBlock}>
|
|
<View style={styles.totalRow}>
|
|
<Text style={styles.totalLabelBlue}>{t.netTotal}</Text>
|
|
<Text style={[styles.totalValue, { color: BRAND_BLUE, fontFamily: "Helvetica-Bold" }]}>
|
|
{formatMoney(invoice.totals.net, cur, invoice.language)}
|
|
</Text>
|
|
</View>
|
|
{invoice.totals.vatBreakdown.map((v) => (
|
|
<View key={`vat-${v.ratePct}`} style={styles.totalRow}>
|
|
<Text style={styles.totalLabel}>{t.vatLine(formatTaxRate(v.ratePct, invoice.language))}</Text>
|
|
<Text style={styles.totalValue}>{formatMoney(v.tax, cur, invoice.language)}</Text>
|
|
</View>
|
|
))}
|
|
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
|
|
<Text style={styles.totalLabelBlue}>{t.grossTotal}</Text>
|
|
<Text style={styles.totalValueBoldBlue}>
|
|
{formatMoney(invoice.totals.gross, cur, invoice.language)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{invoice.notices.length > 0 && (
|
|
<View style={styles.noticeBlock}>
|
|
{invoice.notices.map((n) => (
|
|
<Text key={n.kind}>
|
|
{n.kind === "reverseCharge" && t.reverseChargeNotice}
|
|
{n.kind === "export" && t.exportNotice}
|
|
{n.kind === "kleinunternehmer" && t.kleinunternehmerNotice}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{invoice.kind === "offer" ? (
|
|
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
|
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
|
|
</Text>
|
|
) : !invoice.paid && (
|
|
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
|
{invoice.dueDate
|
|
? t.paymentTerms(
|
|
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
|
|
formatDate(invoice.dueDate, invoice.language),
|
|
)
|
|
: t.paymentTermsImmediate}
|
|
</Text>
|
|
)}
|
|
|
|
{invoice.giroCodePngDataUrl && !invoice.paid && (
|
|
<View style={styles.giroBlock}>
|
|
<Image src={invoice.giroCodePngDataUrl} style={styles.giroImage} />
|
|
<View>
|
|
<Text style={styles.giroCaption}>{t.giroCodeCaption}</Text>
|
|
<Text style={styles.giroDetails}>
|
|
{t.recipientLabel}: {[invoice.issuer.companyName, invoice.issuer.legalForm].filter(Boolean).join(" ")}
|
|
</Text>
|
|
{invoice.issuer.bankName ? (
|
|
<Text style={styles.giroDetails}>{t.bankLabel}: {invoice.issuer.bankName}</Text>
|
|
) : null}
|
|
<Text style={styles.giroDetails}>{t.ibanLabel}: {invoice.issuer.iban}</Text>
|
|
{invoice.issuer.bic ? (
|
|
<Text style={styles.giroDetails}>{t.bicLabel}: {invoice.issuer.bic}</Text>
|
|
) : null}
|
|
<Text style={styles.giroDetails}>
|
|
{t.amountLabel}: {formatMoney(invoice.totals.gross, cur, invoice.language)}
|
|
</Text>
|
|
<Text style={styles.giroDetails}>
|
|
{t.referenceLabel}: {invoice.number}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.closing}>
|
|
<Text>{t.closing}</Text>
|
|
</View>
|
|
|
|
<Footer issuer={invoice.issuer} language={invoice.language} />
|
|
|
|
<Text
|
|
style={styles.pageIndicator}
|
|
render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`}
|
|
fixed
|
|
/>
|
|
</Page>
|
|
</Document>
|
|
);
|
|
}
|
|
|
|
function senderInline(issuer: IssuerData): string {
|
|
return [
|
|
[issuer.companyName, issuer.legalForm].filter(Boolean).join(" "),
|
|
issuer.addressLine1,
|
|
[issuer.postalCode, issuer.city].filter(Boolean).join(" "),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" - ");
|
|
}
|
|
|
|
function Header(_args: { issuer: IssuerData }) {
|
|
// Deprecated: header rendering is now inlined in InvoiceDocument so the
|
|
// logo and meta block can share a single row at the top of the page.
|
|
return null;
|
|
}
|
|
void Header;
|
|
|
|
function Recipient({ recipient }: { recipient: RecipientData }) {
|
|
const lines: string[] = [];
|
|
if (recipient.company) lines.push(recipient.company);
|
|
if (recipient.name && recipient.name !== recipient.company) lines.push(recipient.name);
|
|
if (recipient.addressLine1) lines.push(recipient.addressLine1);
|
|
if (recipient.addressLine2) lines.push(recipient.addressLine2);
|
|
const cityLine = [recipient.postalCode, recipient.city].filter(Boolean).join(" ");
|
|
if (cityLine) lines.push(cityLine);
|
|
if (recipient.countryCode) lines.push(recipient.countryCode);
|
|
|
|
return (
|
|
<View>
|
|
{lines.map((l, i) => (
|
|
<Text key={i} style={i === 0 ? styles.recipientName : undefined}>
|
|
{l}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function LineRow({
|
|
line,
|
|
language,
|
|
currency,
|
|
}: {
|
|
line: InvoiceLine;
|
|
language: InvoiceLanguage;
|
|
currency: string;
|
|
}) {
|
|
const t = getStrings(language);
|
|
return (
|
|
<View style={styles.tableRow}>
|
|
<Text style={styles.colPos}>{line.position}</Text>
|
|
<View style={[styles.colDescription, styles.descriptionCell]}>
|
|
{line.imageDataUrl ? (
|
|
<Image src={line.imageDataUrl} style={styles.productIcon} />
|
|
) : (
|
|
<View style={styles.productIconPlaceholder} />
|
|
)}
|
|
<View style={styles.descriptionText}>
|
|
<Text style={styles.itemTitle}>{line.title}</Text>
|
|
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
|
|
</View>
|
|
</View>
|
|
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
|
|
<View style={styles.colUnit}>
|
|
{line.originalUnitPriceNet != null ? (
|
|
<Text style={styles.unitOriginalStrike}>
|
|
{formatMoney(line.originalUnitPriceNet, currency, language)}
|
|
</Text>
|
|
) : null}
|
|
<Text>{formatMoney(line.unitPriceNet, currency, language)}</Text>
|
|
</View>
|
|
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLanguage }) {
|
|
const t = getStrings(language);
|
|
return (
|
|
<View style={styles.footer} fixed>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.addressHeading}</Text>
|
|
<Text>{[issuer.companyName, issuer.legalForm].filter(Boolean).join(" ")}</Text>
|
|
{issuer.addressLine1 ? <Text>{issuer.addressLine1}</Text> : null}
|
|
{issuer.addressLine2 ? <Text>{issuer.addressLine2}</Text> : null}
|
|
<Text>{[issuer.postalCode, issuer.city].filter(Boolean).join(" ")}</Text>
|
|
{issuer.countryCode ? <Text>{issuer.countryCode}</Text> : null}
|
|
</View>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.contactHeading}</Text>
|
|
{issuer.phone ? <Text>{t.phoneLabel}: {issuer.phone}</Text> : null}
|
|
{issuer.email ? (
|
|
<Text>
|
|
{t.emailLabel}: <Link src={`mailto:${issuer.email}`}>{issuer.email}</Link>
|
|
</Text>
|
|
) : null}
|
|
{issuer.website ? (
|
|
<Text>
|
|
{t.webLabel}: <Link src={normaliseWebUrl(issuer.website)}>{issuer.website}</Link>
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.legalHeading}</Text>
|
|
{issuer.registrationCourt ? (
|
|
<Text>{t.legalCourtLabel}: {issuer.registrationCourt}</Text>
|
|
) : null}
|
|
{issuer.registrationNo ? <Text>{t.fnLabel}: {issuer.registrationNo}</Text> : null}
|
|
{issuer.vatId ? <Text>{t.vatIdLabel}: {issuer.vatId}</Text> : null}
|
|
{issuer.taxNumber ? <Text>{t.taxNumberLabel}: {issuer.taxNumber}</Text> : null}
|
|
{issuer.ownerName ? <Text>{t.ownerLabel}: {issuer.ownerName}</Text> : null}
|
|
</View>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.bankHeading}</Text>
|
|
{issuer.bankName ? <Text>{t.bankLabel}: {issuer.bankName}</Text> : null}
|
|
{issuer.iban ? <Text>{t.ibanLabel}: {issuer.iban}</Text> : null}
|
|
{issuer.bic ? <Text>{t.bicLabel}: {issuer.bic}</Text> : null}
|
|
{pickFooterNote(issuer, language) ? <Text>{pickFooterNote(issuer, language)}</Text> : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Picks the footer note for the rendered language. English falls back to the
|
|
* German `footerNote` when `footerNoteEn` is empty (so existing single-language
|
|
* setups keep working). German always uses `footerNote`.
|
|
*/
|
|
function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, language: InvoiceLanguage): string {
|
|
if (language === "en") {
|
|
return issuer.footerNoteEn?.trim() || issuer.footerNote || "";
|
|
}
|
|
return issuer.footerNote || "";
|
|
}
|
|
|
|
/**
|
|
* Make a website URL safe for `<Link src="...">` — adds an `https://` scheme
|
|
* when the user typed something like `linumiq.com` or `www.linumiq.com`.
|
|
*/
|
|
function normaliseWebUrl(url: string): string {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return trimmed;
|
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
return `https://${trimmed.replace(/^\/\//, "")}`;
|
|
}
|
|
|
|
/**
|
|
* Turn a Shopify payment-gateway machine name (e.g. `shopify_payments`,
|
|
* `manual`, `bogus`) or a built-in manual-payment template name (e.g.
|
|
* `Bank Deposit`, `Money Order`) into the localized customer-facing label
|
|
* shown on the invoice. The Shopify Admin API only exposes English
|
|
* template names — see `InvoiceStrings.paymentGatewayLabels` for the
|
|
* rationale.
|
|
*
|
|
* Lookup is keyed on the *normalized* name (lowercased, separators
|
|
* collapsed). Unknown gateways fall back to a title-cased rendering
|
|
* of the raw name so we never silently print empty meta-rows.
|
|
*/
|
|
function prettifyGatewayName(
|
|
name: string,
|
|
labels: Record<string, string>,
|
|
): string {
|
|
const key = name.trim().toLowerCase().replace(/[_\-]+/g, " ").replace(/\s+/g, " ");
|
|
if (labels[key]) return labels[key];
|
|
return key
|
|
.split(" ")
|
|
.filter(Boolean)
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(" ");
|
|
}
|