Add the Neutral template family, template picker and text-only logo allowances

Linea (the vendor's original format.pdf design), Monogram and Serenity are
rebuilt from the reference PDFs as one parametric family on the engine, with
the user's logo in the measured slot, a home for every GST field and
A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and
a generated Ms Madi outline for Serenity's vertical word. Text-only logo
placeholders (Monolith, Tangerine) use a doc-derived allowance box. A
Carbon TemplatePicker with committed thumbnails opens from the page
setup controls.
This commit is contained in:
2026-10-04 07:27:12 +05:30
parent eddf27629f
commit aa7a407944
61 changed files with 3278 additions and 110 deletions
+39 -6
View File
@@ -1,9 +1,12 @@
import { useState, type ReactNode } from "react";
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react";
import { useState } from "react";
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch, Toggle } from "@carbon/react";
import { Catalog } from "@carbon/icons-react";
import { api } from "../lib/api";
import type { Settings } from "../lib/types";
import type { MarginPreset, PageId } from "../pdf/engine/geometry";
import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs";
import { catalogEntry } from "../pdf/templates/catalog";
import TemplatePicker from "./TemplatePicker";
const PAGES: { id: PageId; label: string }[] = [
{ id: "A4", label: "A4" },
@@ -24,17 +27,17 @@ interface Props {
/** Called after the default was saved so the app reloads its settings. */
onSaved: () => void;
disabled?: boolean;
/** Where a TemplatePicker goes once there is more than one template. */
templateSlot?: ReactNode;
}
/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) {
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled }: Props) {
const [saving, setSaving] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const saved = prefsToJson(parsePrefs(settings.renderPrefs));
const isDefault = prefsToJson(prefs) === saved;
const template = catalogEntry(prefs.templateId);
const saveDefault = async () => {
setSaving(true);
@@ -52,7 +55,12 @@ export default function PageSetupControls({ prefs, onChange, settings, onSaved,
return (
<div className="voiced-page-setup">
<div className="voiced-page-setup__row">
{templateSlot}
<div className="voiced-page-setup__field">
<span className="cds--label">Template</span>
<Button kind="tertiary" size="md" renderIcon={Catalog} disabled={disabled} onClick={() => setPickerOpen(true)}>
{template.name}
</Button>
</div>
<div className="voiced-page-setup__field">
<span className="cds--label">Page size</span>
<ContentSwitcher
@@ -80,6 +88,31 @@ export default function PageSetupControls({ prefs, onChange, settings, onSaved,
</Select>
</div>
</div>
{template.logoOptIn ? (
<div className="voiced-page-setup__row voiced-page-setup__extra">
<Toggle
id="template-logo-show"
size="sm"
labelText="Logo"
labelA="Hidden"
labelB="Shown above the title"
toggled={prefs.logo?.show === true}
disabled={disabled}
onToggle={(on) => onChange({ ...prefs, logo: { ...prefs.logo, show: on } })}
/>
</div>
) : null}
<TemplatePicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
selectedId={template.id}
onSelect={(id) => onChange({ ...prefs, templateId: id })}
isDefault={isDefault}
saving={saving}
error={error}
onSaveDefault={() => void saveDefault()}
disabled={disabled}
/>
<div className="voiced-page-setup__footer">
{saving ? (
<InlineLoading status="active" description="Saving…" />
+77
View File
@@ -0,0 +1,77 @@
import { Button, ClickableTile, ComposedModal, InlineLoading, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
import { CheckmarkFilled } from "@carbon/icons-react";
import { FAMILY_LABELS, TEMPLATE_CATALOG, catalogEntry } from "../pdf/templates/catalog";
interface Props {
open: boolean;
onClose: () => void;
/** The template of the invoice being edited; an unknown id shows as Classic, which is what renders. */
selectedId: string;
/** Applies at once to the live preview. */
onSelect: (id: string) => void;
/** The selection is already the saved default. */
isDefault: boolean;
saving: boolean;
error: string | null;
onSaveDefault: () => void;
/** Issued invoices keep the template they were issued with. */
disabled?: boolean;
}
/** Pick a template from static thumbnails drawn with sample data; the choice previews immediately. */
export default function TemplatePicker({ open, onClose, selectedId, onSelect, isDefault, saving, error, onSaveDefault, disabled }: Props) {
const current = catalogEntry(selectedId).id;
return (
<ComposedModal open={open} onClose={onClose} size="lg" aria-label="Choose a template">
<ModalHeader title="Choose a template" label="Invoice layout" />
<ModalBody hasScrollingContent>
<p className="voiced-muted voiced-template-picker__note">
Thumbnails use sample data. Your choice shows in the preview straight away and is frozen into each invoice when it is issued.
</p>
<div className="voiced-template-grid" role="list">
{TEMPLATE_CATALOG.map((t) => {
const selected = t.id === current;
return (
<div key={t.id} role="listitem">
<ClickableTile
className={`voiced-template-tile${selected ? " voiced-template-tile--selected" : ""}`}
aria-label={`${t.name}${selected ? ", selected" : ""}`}
onClick={() => !disabled && onSelect(t.id)}
>
<img className="voiced-template-tile__thumb" src={t.thumbnail} alt={`${t.name} template with sample data`} loading="lazy" />
<div className="voiced-template-tile__head">
<span className="voiced-template-tile__name">{t.name}</span>
{selected ? <CheckmarkFilled size={20} className="voiced-template-tile__check" aria-label="Selected" /> : null}
</div>
<div className="voiced-template-tile__tags">
<Tag size="sm" type="gray">
{FAMILY_LABELS[t.family]}
</Tag>
{t.badge ? (
<Tag size="sm" type="blue">
{t.badge}
</Tag>
) : null}
</div>
</ClickableTile>
</div>
);
})}
</div>
</ModalBody>
<ModalFooter>
<span className="voiced-muted voiced-template-picker__status">{error ?? (disabled ? "This invoice is issued; its template is fixed." : "")}</span>
{saving ? (
<InlineLoading status="active" description="Saving…" />
) : (
<Button kind="secondary" disabled={isDefault || disabled} onClick={onSaveDefault}>
{isDefault ? "Saved as default" : "Save as default"}
</Button>
)}
<Button kind="primary" onClick={onClose}>
Done
</Button>
</ModalFooter>
</ComposedModal>
);
}