Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
df8261b
fix(vouchers): share text fitting rules
Boym323 Sep 26, 2026
38575ba
fix(vouchers): align editor text fitting with renderer
Boym323 Sep 26, 2026
5379681
fix(vouchers): clean preview fitting wrapper
Boym323 Sep 26, 2026
876a716
fix(vouchers): classify template text overflow
Boym323 Sep 26, 2026
144ae40
fix(vouchers): make PDF text fitting match editor
Boym323 Sep 26, 2026
fcc2bbb
refactor(vouchers): remove duplicate PDF fitting helpers
Boym323 Sep 26, 2026
c82b29e
fix(vouchers): fail publish preflight on text overflow
Boym323 Sep 26, 2026
54c02e6
fix(vouchers): match editor text insets to PDF
Boym323 Sep 26, 2026
526566d
fix(vouchers): keep dynamic content inside trim box
Boym323 Sep 26, 2026
97e0726
fix(vouchers): allow activation from inactive printed templates
Boym323 Sep 26, 2026
7ea0844
fix(vouchers): show inactive template state correctly
Boym323 Sep 26, 2026
609ac62
fix(vouchers): add optimistic draft concurrency guard
Boym323 Sep 26, 2026
9ae1904
fix(vouchers): return and validate template revision
Boym323 Sep 26, 2026
859b993
fix(vouchers): protect editor saves with revision token
Boym323 Sep 26, 2026
3bbe16b
fix(vouchers): pass draft revision into layout editor
Boym323 Sep 26, 2026
d9682b5
test(vouchers): cover shared text fitting rules
Boym323 Sep 26, 2026
03637a3
test(vouchers): cover editor/PDF fitting parity hooks
Boym323 Sep 26, 2026
20fea5b
test(vouchers): reject dynamic content in bleed
Boym323 Sep 26, 2026
89d098e
test(vouchers): keep inactive printed stock activatable
Boym323 Sep 26, 2026
5a1ec2c
test(vouchers): cover optimistic template save conflicts
Boym323 Sep 26, 2026
3a353e0
test(vouchers): expose strict overflow render option
Boym323 Sep 26, 2026
4e1be2d
test(vouchers): reject overflowing publish fixtures
Boym323 Sep 26, 2026
a41113f
fix(vouchers): make overflow preflight actionable
Boym323 Sep 26, 2026
18566b7
fix(vouchers): surface publish preflight failure reason
Boym323 Sep 26, 2026
4fc07bb
docs(vouchers): document template hardening fixes
Boym323 Sep 26, 2026
1bbbc98
fix(vouchers): render layout CMYK color in editor preview
Boym323 Sep 26, 2026
1999660
test(vouchers): assert CMYK preview parity
Boym323 Sep 26, 2026
84d52fe
fix(vouchers): always test exact minimum font size
Boym323 Sep 26, 2026
7d7d66b
fix(vouchers): fit multiline text against baseline clearance
Boym323 Sep 26, 2026
35a7290
test(vouchers): cover baseline-aware text fitting
Boym323 Sep 26, 2026
881f58d
test(vouchers): exercise production text fitter directly
Boym323 Sep 26, 2026
94767b0
refactor(vouchers): remove obsolete PDF text fitter
Boym323 Sep 26, 2026
46855a8
Oprav zpětnou kompatibilitu voucherových layoutů
Boym323 Sep 26, 2026
eac36ec
Zachovej render historických voucherových šablon
Boym323 Sep 26, 2026
fbc4115
Umožni opravu starších voucherových draftů
Boym323 Sep 26, 2026
83f7d85
Povol klonování historických voucherových layoutů
Boym323 Sep 26, 2026
21b27bd
Přidej test historických voucherových layoutů
Boym323 Sep 26, 2026
ae4b9c0
Zohledni baseline při fittingu voucherového textu
Boym323 Sep 26, 2026
f455258
Přidej regresní test vertikálního fittingu
Boym323 Sep 26, 2026
30a9e3d
Oprav mock ukládání voucherové šablony v testu
Boym323 Sep 26, 2026
72e05a2
Upřesni změny voucherového layoutu v changelogu
Boym323 Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Formát je inspirovaný Keep a Changelog.
- Voucher Template Editor nyní automaticky využívá dostupný prostor canvasu, zobrazuje resize handles jen u aktivní oblasti, formátuje číselné hodnoty bez floating-point artefaktů a nabízí kompaktnější toggle ovládání vodítek a spadávky.
- Resize voucherových oblastí nyní používá pouze čtyři rohové handles; QR oblast drží čtvercový poměr stran včetně ručních rozměrů a textové oblasti lze měnit nezávisle v obou osách.
- Toggle `Vodítka` nyní skutečně řídí render středových os a `Spadávka` řídí viditelný 3mm bleed overlay; oba pracovní overlaye zůstávají mimo uložený layout a PDF export.
- Voucher editor a PDF renderer nyní sdílejí pravidla fittingu textu včetně výšky oblasti, insetů, baseline a automatického řádkování; publish preflight odmítne overflow a nové uložení či publikace drží dynamický obsah uvnitř TrimBoxu, zatímco historické layouty zůstávají renderovatelné pro již vydané kusy.
- Uložení draftu šablony používá optimistic concurrency přes `updatedAt`, neaktivní šablony se správně označují v UI a již vytištěné převzaté kusy lze aktivovat i po deaktivaci jejich immutable designu.

## [3.30.0] - 2026-09-23

Expand Down
13 changes: 8 additions & 5 deletions src/app/(admin)/admin/vouchery/sablony/[templateId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { VoucherTemplateLayoutEditor } from "@/features/admin/components/voucher
import { VoucherTemplateOverflowMenu } from "@/features/admin/components/voucher-template-overflow-menu";
import { AdminStatePill } from "@/features/admin/components/admin-state-pill";
import { AdminVoucherTemplatePreview } from "@/features/admin/components/admin-voucher-template-preview";
import { voucherTemplateLayoutSchema } from "@/features/vouchers/lib/voucher-template-layout";
import { voucherTemplateStoredLayoutSchema } from "@/features/vouchers/lib/voucher-template-layout";
import { requireVoucherTemplateById } from "@/features/vouchers/lib/voucher-template-repository";
import { requireRole } from "@/lib/auth/session";

Expand All @@ -27,6 +27,9 @@ export default async function VoucherTemplateDetailPage({ params }: { params: Pr
}

const templateId = template.id;
const statusLabel = template.status === "DRAFT" ? "Draft" : template.status === "PUBLISHED" ? "Publikováno" : "Neaktivní";
const statusDescription = template.status === "DRAFT" ? "v přípravě" : template.status === "PUBLISHED" ? "publikovaná" : "neaktivní";
const statusTone = template.status === "DRAFT" ? "accent" : template.status === "PUBLISHED" ? "active" : "muted";
const previewUrl = template.previewStoragePath && template.masterSha256
? `/api/admin/voucher-templates/${template.id}/preview?v=${template.updatedAt.getTime()}`
: undefined;
Expand All @@ -35,7 +38,7 @@ export default async function VoucherTemplateDetailPage({ params }: { params: Pr
<AdminPageShell
eyebrow="Vouchery / Šablony"
title={template.label}
description={`${template.key} · verze šablony ${template.status === "DRAFT" ? "v přípravě" : "publikovaná"}`}
description={`${template.key} · verze šablony ${statusDescription}`}
denseIntro
headerActions={
<div className="flex flex-wrap justify-end gap-2">
Expand All @@ -60,12 +63,12 @@ export default async function VoucherTemplateDetailPage({ params }: { params: Pr
<span className={`h-2.5 w-2.5 rounded-full ${template.masterStoragePath ? "bg-emerald-300" : "bg-white/30"}`} aria-hidden="true" />
<div><p className="text-sm font-semibold text-white">PDF master {template.masterStoragePath ? "je nahrán" : "chybí"}</p><p className="mt-0.5 text-xs text-white/55">{template.masterStoragePath ? "Náhled a layout jsou připravené k úpravám." : "Nahrajte PDF, aby bylo možné zkontrolovat finální vzhled."}</p></div>
</div>
<AdminStatePill tone={template.status === "DRAFT" ? "accent" : "active"}>{template.status === "DRAFT" ? "Draft" : "Publikováno"}</AdminStatePill>
<AdminStatePill tone={statusTone}>{statusLabel}</AdminStatePill>
</div>
{template.status === "PUBLISHED" ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-4"><p className="text-sm text-white/60">Publikovaná verze zůstává neměnná. Pro další úpravy vytvořte novou verzi.</p><DeactivateVoucherTemplateForm templateId={templateId} /></div> : null}
{template.status !== "DRAFT" ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-4"><p className="text-sm text-white/60">{template.status === "PUBLISHED" ? "Publikovaná verze zůstává neměnná. Pro další úpravy vytvořte novou verzi." : "Neaktivní verze zůstává zachovaná pro již vystavené a vytištěné vouchery. Pro nové použití vytvořte novou verzi."}</p>{template.status === "PUBLISHED" ? <DeactivateVoucherTemplateForm templateId={templateId} /> : null}</div> : null}
{template.status === "DRAFT" ? <form action={sendTestVoucherTemplateEmailAction} className="mt-4 flex flex-col gap-2 border-t border-white/10 pt-4 sm:flex-row sm:items-end"><input type="hidden" name="templateId" value={template.id} /><label className="min-w-0 flex-1 text-xs text-white/65">Testovací e-mail<input required type="email" name="recipientEmail" placeholder="např. studio@ppstudio.cz" className="mt-1 min-h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-[var(--color-accent)]/70" /></label><button className="inline-flex min-h-10 items-center justify-center rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/30 hover:text-white">Odeslat test</button></form> : null}
</section>
{template.status === "DRAFT" ? <VoucherTemplateLayoutEditor templateId={template.id} initialLayout={voucherTemplateLayoutSchema.parse(template.layout)} previewSrc={previewUrl} /> : <section className="grid gap-5 rounded-[var(--radius-panel)] border border-white/10 bg-white/[0.035] p-4 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(16rem,0.8fr)] md:items-center"><AdminVoucherTemplatePreview src={previewUrl} alt={`Náhled šablony ${template.label}`} /><div><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-accent-soft)]">Publikovaná verze</p><h3 className="mt-2 font-display text-2xl text-white">Layout je uzamčený</h3><p className="mt-3 text-sm leading-6 text-white/65">Přesné souřadnice a typografie této verze zůstávají zachované pro vystavené vouchery. Nová verze začne jako kopie tohoto layoutu.</p><details className="mt-5"><summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.16em] text-white/55">Technické údaje layoutu</summary><pre className="mt-3 max-h-64 overflow-auto rounded-xl border border-white/8 bg-black/20 p-3 text-xs text-white/55">{JSON.stringify(template.layout, null, 2)}</pre></details></div></section>}
{template.status === "DRAFT" ? <VoucherTemplateLayoutEditor templateId={template.id} initialLayout={voucherTemplateStoredLayoutSchema.parse(template.layout)} initialUpdatedAt={template.updatedAt.toISOString()} previewSrc={previewUrl} /> : <section className="grid gap-5 rounded-[var(--radius-panel)] border border-white/10 bg-white/[0.035] p-4 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(16rem,0.8fr)] md:items-center"><AdminVoucherTemplatePreview src={previewUrl} alt={`Náhled šablony ${template.label}`} /><div><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-accent-soft)]">{statusLabel} verze</p><h3 className="mt-2 font-display text-2xl text-white">Layout je uzamčený</h3><p className="mt-3 text-sm leading-6 text-white/65">Přesné souřadnice a typografie této verze zůstávají zachované pro vystavené vouchery. Nová verze začne jako kopie tohoto layoutu.</p><details className="mt-5"><summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.16em] text-white/55">Technické údaje layoutu</summary><pre className="mt-3 max-h-64 overflow-auto rounded-xl border border-white/8 bg-black/20 p-3 text-xs text-white/55">{JSON.stringify(template.layout, null, 2)}</pre></details></div></section>}
</div>
</AdminPageShell>
);
Expand Down
10 changes: 7 additions & 3 deletions src/features/admin/actions/voucher-template-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@ test("template lifecycle actions vyžadují OWNER a volají domain operace", asy
deleteVoucherTemplateDraft: async (id: string, actor: string) => { calls.push(["delete", id, actor]); },
publishVoucherTemplate: async (id: string, actor: string) => { calls.push(["publish", id, actor]); },
replaceVoucherTemplateMaster: async () => undefined,
updateVoucherTemplateDraft: async (_id: string, input: { layout: typeof defaultVoucherTemplateLayout }) => { savedLayout = input.layout; },
updateVoucherTemplateDraft: async (_id: string, input: { layout: typeof defaultVoucherTemplateLayout }) => {
savedLayout = input.layout;
return { updatedAt: new Date("2026-09-26T06:00:00.000Z") };
},
},
});
t.mock.module("@/features/vouchers/lib/voucher-template-repository", {
exports: {
requireVoucherTemplateById: async () => ({ id: "draft-1", status: "DRAFT", key: "classic-v1", label: "Klasický" }),
requireVoucherTemplateById: async () => ({ id: "draft-1", status: "DRAFT", key: "classic-v1", label: "Klasický", allowedTypes: ["VALUE", "SERVICE"] }),
resolveVoucherTemplate: async () => ({ id: "draft-1" }),
},
});
Expand All @@ -53,7 +56,8 @@ test("template lifecycle actions vyžadují OWNER a volají domain operace", asy
await actions.cloneVoucherTemplateVersionAction("template-1");
await actions.deleteVoucherTemplateDraftAction("template-1");
const editedLayout = { ...defaultVoucherTemplateLayout, serviceArea: { ...defaultVoucherTemplateLayout.serviceArea, typography: { ...defaultVoucherTemplateLayout.serviceArea.typography, preferredFontSizePt: 12, minFontSizePt: 7, fontWeight: "regular" as const, alignment: "center" as const } } };
await actions.saveVoucherTemplateLayoutAction("template-1", editedLayout);
const saveResult = await actions.saveVoucherTemplateLayoutAction("template-1", editedLayout);
assert.equal(saveResult.updatedAt, "2026-09-26T06:00:00.000Z");
assert.equal(savedLayout?.serviceArea.typography.preferredFontSizePt, 12);
assert.equal(savedLayout?.serviceArea.typography.minFontSizePt, 7);
assert.equal(savedLayout?.serviceArea.typography.fontWeight, "regular");
Expand Down
13 changes: 11 additions & 2 deletions src/features/admin/actions/voucher-template-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,23 @@ export async function uploadVoucherTemplateMasterAction(formData: FormData) {
revalidatePath("/admin/nastaveni");
}

export async function saveVoucherTemplateLayoutAction(templateId: string, layout: VoucherTemplateLayoutV1) {
export async function saveVoucherTemplateLayoutAction(templateId: string, layout: VoucherTemplateLayoutV1, expectedUpdatedAt?: string) {
const session = await requireRole([AdminRole.OWNER]);
const parsed = voucherTemplateLayoutSchema.safeParse(layout);
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Layout šablony obsahuje neplatné hodnoty.");
const parsedRevision = expectedUpdatedAt ? z.iso.datetime().safeParse(expectedUpdatedAt) : null;
if (expectedUpdatedAt && !parsedRevision?.success) throw new Error("Revize draftu není platná.");
const { requireVoucherTemplateById } = await import("@/features/vouchers/lib/voucher-template-repository");
const template = await requireVoucherTemplateById(templateId);
await updateVoucherTemplateDraft(templateId, { layout, allowedTypes: template.allowedTypes, label: template.label, actorUserId: session.sub });
const updated = await updateVoucherTemplateDraft(templateId, {
layout,
allowedTypes: template.allowedTypes,
label: template.label,
actorUserId: session.sub,
expectedUpdatedAt: parsedRevision?.success ? new Date(parsedRevision.data) : undefined,
});
revalidatePath(`/admin/vouchery/sablony/${templateId}`);
return { updatedAt: updated.updatedAt.toISOString() };
}

export async function publishVoucherTemplateAction(templateId: string) {
Expand Down
Loading
Loading