93aec2f368
- Drop wireTransferGatewayNames from ShopSettings (new migration). - Replace string-matching with a GraphQL query against Order.transactions[].manualPaymentGateway, the first-class flag Shopify exposes for any merchant-defined manual payment method. - Both webhook handlers now fetch the order on the fly to classify it, removing the configurable gateway-names field from settings.
544 lines
21 KiB
TypeScript
544 lines
21 KiB
TypeScript
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";
|
|
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
|
|
import {
|
|
deleteStoredLogo,
|
|
storeUploadedLogo,
|
|
} from "../services/invoice/logoCache.server";
|
|
import { RichTextEditor } from "../components/RichTextEditor";
|
|
import {
|
|
DEFAULT_EMAIL_BODY_DE,
|
|
DEFAULT_EMAIL_BODY_EN,
|
|
DEFAULT_EMAIL_SUBJECT_DE,
|
|
DEFAULT_EMAIL_SUBJECT_EN,
|
|
} from "../services/invoice/emailTemplates";
|
|
|
|
interface SettingsFieldErrors {
|
|
vatId?: string;
|
|
iban?: string;
|
|
bic?: string;
|
|
smtpPort?: string;
|
|
paymentTermDays?: string;
|
|
invoiceSeed?: string;
|
|
logo?: 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 },
|
|
});
|
|
let logoPreviewDataUrl: string | null = null;
|
|
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
|
|
const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } });
|
|
if (cached) {
|
|
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
|
|
}
|
|
} else if (settings.logoUrl) {
|
|
// External HTTPS URL — fine to display directly in the editor.
|
|
logoPreviewDataUrl = settings.logoUrl;
|
|
}
|
|
return { settings, logoPreviewDataUrl };
|
|
};
|
|
|
|
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.";
|
|
}
|
|
|
|
// --- Logo handling --------------------------------------------------------
|
|
// The settings page allows three actions on the logo:
|
|
// 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`.
|
|
// 2. Remove the current logo (`removeLogo=on`).
|
|
// 3. Provide an external URL via the `logoUrl` field.
|
|
// If a file is uploaded it wins over a manually-entered URL.
|
|
// Look up the existing logoUrl so we don't accidentally clear it when
|
|
// the user just edited unrelated fields (the visible URL field is hidden
|
|
// for stored uploads, so it submits empty in that case).
|
|
const existing = await db.shopSettings.findUnique({
|
|
where: { shopDomain: session.shop },
|
|
select: { logoUrl: true },
|
|
});
|
|
const submittedLogoUrl = str("logoUrl");
|
|
const removeLogo = bool("removeLogo");
|
|
const logoFile = form.get("logoFile");
|
|
const hasUpload =
|
|
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
|
|
|
|
let resolvedLogoUrl = submittedLogoUrl || existing?.logoUrl || "";
|
|
|
|
if (removeLogo && !hasUpload) {
|
|
await deleteStoredLogo(session.shop);
|
|
resolvedLogoUrl = "";
|
|
} else if (hasUpload) {
|
|
const file = logoFile as File;
|
|
const buf = Buffer.from(await file.arrayBuffer());
|
|
const stored = await storeUploadedLogo(session.shop, buf, file.type);
|
|
if (!stored.ok) {
|
|
errors.logo = stored.error ?? "Failed to store uploaded logo.";
|
|
} else {
|
|
resolvedLogoUrl = STORED_LOGO_SENTINEL;
|
|
}
|
|
}
|
|
|
|
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"),
|
|
footerNoteEn: str("footerNoteEn"),
|
|
kleinunternehmer: bool("kleinunternehmer"),
|
|
logoUrl: resolvedLogoUrl,
|
|
smtpHost: str("smtpHost"),
|
|
smtpPort: smtpPort ?? 587,
|
|
smtpSecure: bool("smtpSecure"),
|
|
smtpUser: str("smtpUser"),
|
|
smtpPassword: str("smtpPassword"),
|
|
smtpFromName: str("smtpFromName"),
|
|
smtpFromEmail: str("smtpFromEmail"),
|
|
smtpReplyTo: str("smtpReplyTo"),
|
|
emailSubjectDe: str("emailSubjectDe"),
|
|
emailBodyHtmlDe: str("emailBodyHtmlDe"),
|
|
emailSubjectEn: str("emailSubjectEn"),
|
|
emailBodyHtmlEn: str("emailBodyHtmlEn"),
|
|
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
|
|
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
|
|
};
|
|
|
|
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, logoPreviewDataUrl } = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
const nav = useNavigation();
|
|
const isSaving = nav.state === "submitting";
|
|
const errors = actionData?.errors ?? {};
|
|
const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL;
|
|
// Show the URL field only when not using a stored upload — keeps the UI
|
|
// simpler and avoids the sentinel value leaking into the input.
|
|
const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl;
|
|
|
|
return (
|
|
<s-page heading="Invoice settings">
|
|
<s-section>
|
|
<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>
|
|
</s-section>
|
|
|
|
<Form method="post" encType="multipart/form-data">
|
|
<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 (German)" name="footerNote" defaultValue={settings.footerNote} helpText="Shown at the bottom of every German PDF invoice (e.g. „Vielen Dank für Ihren Auftrag.“)." />
|
|
<Field label="Footer note (English)" name="footerNoteEn" defaultValue={settings.footerNoteEn} helpText="Shown on English PDF invoices. Falls back to the German note when empty." />
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Logo">
|
|
<s-stack direction="block" gap="base">
|
|
<s-paragraph>
|
|
The logo is rendered in the top-right corner of every invoice
|
|
PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or
|
|
provide a publicly reachable HTTPS URL — uploads take precedence.
|
|
</s-paragraph>
|
|
{errors.logo && <s-banner tone="critical">{errors.logo}</s-banner>}
|
|
{logoPreviewDataUrl && (
|
|
<s-stack direction="block" gap="small">
|
|
<s-text>Current logo:</s-text>
|
|
<img
|
|
src={logoPreviewDataUrl}
|
|
alt="Current invoice logo"
|
|
style={{ maxWidth: "240px", maxHeight: "120px", border: "1px solid #ddd", padding: "8px", background: "#fff" }}
|
|
/>
|
|
<s-checkbox name="removeLogo" label="Remove current logo on save" />
|
|
</s-stack>
|
|
)}
|
|
<s-text>Upload a new logo:</s-text>
|
|
<input type="file" name="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" />
|
|
<Field
|
|
label="Or external logo URL (PNG/JPG/WebP, served from Shopify Files or any HTTPS URL)"
|
|
name="logoUrl"
|
|
defaultValue={visibleLogoUrl}
|
|
/>
|
|
</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-section heading="Email templates">
|
|
<s-stack direction="block" gap="base">
|
|
<s-paragraph>
|
|
These templates are used when sending the invoice PDF by email.
|
|
Leave a field empty to fall back to the built-in default.
|
|
</s-paragraph>
|
|
|
|
<Field
|
|
label="Subject (German)"
|
|
name="emailSubjectDe"
|
|
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
|
|
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
|
/>
|
|
<RichTextEditor
|
|
name="emailBodyHtmlDe"
|
|
label="Body (German)"
|
|
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
|
|
variables={EMAIL_VARS}
|
|
minHeight={220}
|
|
logoDataUrl={logoPreviewDataUrl}
|
|
/>
|
|
|
|
<Field
|
|
label="Subject (English)"
|
|
name="emailSubjectEn"
|
|
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
|
|
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
|
/>
|
|
<RichTextEditor
|
|
name="emailBodyHtmlEn"
|
|
label="Body (English)"
|
|
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
|
|
variables={EMAIL_VARS}
|
|
minHeight={220}
|
|
logoDataUrl={logoPreviewDataUrl}
|
|
/>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Automations">
|
|
<s-stack direction="block" gap="base">
|
|
<s-paragraph>
|
|
These trigger directly from Shopify order webhooks — no Shopify
|
|
Flow required (Flow is gated to Plus stores for custom apps).
|
|
When an automation fires, the invoice is generated (if it doesn't
|
|
already exist) and emailed to the customer using the SMTP and
|
|
email-template settings above. "Wire-transfer" is detected via
|
|
Shopify's <code>OrderTransaction.manualPaymentGateway</code> flag,
|
|
so any merchant-defined manual payment method (Überweisung, Cash
|
|
on Delivery, Money Order, …) qualifies.
|
|
</s-paragraph>
|
|
<Toggle
|
|
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
|
|
name="autoEmailOnWireTransferPlaced"
|
|
checked={settings.autoEmailOnWireTransferPlaced}
|
|
/>
|
|
<Toggle
|
|
label='Auto-email the invoice when an order is fulfilled and is NOT a wire-transfer order (e.g. the customer paid by card and we send the invoice with the shipment).'
|
|
name="autoEmailOnFulfilledNonWireTransfer"
|
|
checked={settings.autoEmailOnFulfilledNonWireTransfer}
|
|
/>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section>
|
|
<s-stack direction="block" gap="base">
|
|
{actionData?.ok && (
|
|
<s-banner tone="success" heading="Settings saved">
|
|
Your changes are now live and will be used for the next invoice.
|
|
</s-banner>
|
|
)}
|
|
{actionData && !actionData.ok && (
|
|
<s-banner tone="critical" heading="Please fix the highlighted errors">
|
|
Some fields below need attention before settings can be saved.
|
|
</s-banner>
|
|
)}
|
|
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
|
|
{isSaving ? <s-text tone="neutral">Saving…</s-text> : null}
|
|
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
|
|
Save settings
|
|
</s-button>
|
|
</s-stack>
|
|
</s-stack>
|
|
</s-section>
|
|
</Form>
|
|
</s-page>
|
|
);
|
|
}
|
|
|
|
const EMAIL_VARS = [
|
|
{ token: "{{invoiceNumber}}" },
|
|
{ token: "{{customerName}}" },
|
|
{ token: "{{customerFirstName}}" },
|
|
{ token: "{{orderName}}" },
|
|
{ token: "{{totalGross}}" },
|
|
{ token: "{{dueDate}}" },
|
|
{ token: "{{companyName}}" },
|
|
{ token: "{{ownerName}}" },
|
|
{ token: "{{shopEmail}}" },
|
|
{ token: "{{shopWebsite}}" },
|
|
];
|
|
|
|
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>
|
|
);
|
|
}
|