diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx
new file mode 100644
index 000000000..fc6dff825
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx
@@ -0,0 +1,92 @@
+import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
+import { IconPlus } from '@tabler/icons-react';
+import { useTranslation } from 'react-i18next';
+import { useLocalized, type LicenseType } from '@ema-platform/api';
+
+interface Props {
+ licenseTypes: LicenseType[];
+ typeId: string | null;
+ onTypeChange: (id: string | null) => void;
+ validityMonths: number;
+ onValidityChange: (months: number) => void;
+ currentValidityMonths?: number | null;
+ canEdit: boolean;
+ savingValidity: boolean;
+ onSaveValidity: () => void;
+ onNewVersion: () => void;
+}
+
+/** Licence type, certificate validity, and the entry point for a new version. */
+export function DesignerToolbar({
+ licenseTypes,
+ typeId,
+ onTypeChange,
+ validityMonths,
+ onValidityChange,
+ currentValidityMonths,
+ canEdit,
+ savingValidity,
+ onSaveValidity,
+ onNewVersion,
+}: Props) {
+ const { t } = useTranslation();
+ const localized = useLocalized();
+
+ return (
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/NewVersionModal.tsx b/apps/backoffice/src/app/features/certificate-designer/components/NewVersionModal.tsx
new file mode 100644
index 000000000..be0f59876
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-designer/components/NewVersionModal.tsx
@@ -0,0 +1,51 @@
+import { Button, Modal, Stack, Text, TextInput } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { ModalFooter } from '@ema-platform/ui';
+
+interface Props {
+ opened: boolean;
+ name: string;
+ onNameChange: (value: string) => void;
+ creating: boolean;
+ onClose: () => void;
+ onCreate: () => void;
+}
+
+/** Starts a draft from the live design, or the built-in layout if there is none. */
+export function NewVersionModal({
+ opened,
+ name,
+ onNameChange,
+ creating,
+ onClose,
+ onCreate,
+}: Props) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ onNameChange(e.currentTarget.value)}
+ withAsterisk
+ />
+
+ {t(
+ 'designer.newHint',
+ 'Starts from the live design, or the built-in layout if this type has none.',
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateActionBar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateActionBar.tsx
new file mode 100644
index 000000000..3c0ff1711
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateActionBar.tsx
@@ -0,0 +1,101 @@
+import { ActionIcon, Button, Group, Tooltip } from '@mantine/core';
+import {
+ IconDeviceFloppy,
+ IconEye,
+ IconRosetteDiscountCheck,
+ IconTrash,
+} from '@tabler/icons-react';
+import { useTranslation } from 'react-i18next';
+
+interface Props {
+ hasSource: boolean;
+ hasSelection: boolean;
+ isPublished: boolean;
+ dirty: boolean;
+ canEdit: boolean;
+ canPublish: boolean;
+ saving: boolean;
+ publishing: boolean;
+ onPreview: () => void;
+ onSave: () => void;
+ onPublish: () => void;
+ onArchive: () => void;
+ onDelete: () => void;
+}
+
+/** Preview, save, publish, withdraw and delete for the selected version. */
+export function TemplateActionBar({
+ hasSource,
+ hasSelection,
+ isPublished,
+ dirty,
+ canEdit,
+ canPublish,
+ saving,
+ publishing,
+ onPreview,
+ onSave,
+ onPublish,
+ onArchive,
+ onDelete,
+}: Props) {
+ const { t } = useTranslation();
+
+ const publishHint = !canPublish
+ ? t('designer.noPublishPermission', 'You cannot publish designs')
+ : dirty
+ ? t('designer.saveFirst', 'Save your changes first')
+ : t('designer.publishHint', 'Makes this the live certificate design');
+
+ return (
+
+ }
+ onClick={onPreview}
+ disabled={!hasSource}
+ >
+ {t('designer.preview', 'Preview PDF')}
+
+ }
+ loading={saving}
+ disabled={!canEdit || isPublished || !dirty}
+ onClick={onSave}
+ >
+ {t('designer.save', 'Save draft')}
+
+
+
+ }
+ loading={publishing}
+ disabled={!canPublish || isPublished || dirty || !hasSelection}
+ onClick={onPublish}
+ >
+ {t('designer.publish', 'Publish')}
+
+
+
+
+ {hasSelection && isPublished && canPublish && (
+
+ )}
+ {hasSelection && !isPublished && canEdit && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx
new file mode 100644
index 000000000..422814958
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx
@@ -0,0 +1,84 @@
+import { Button, Group, Image, Paper, Stack, Text, TextInput } from '@mantine/core';
+import { IconPhotoUp, IconTrash } from '@tabler/icons-react';
+import { useTranslation } from 'react-i18next';
+
+interface Props {
+ backgroundUrl: string;
+ onBackgroundChange: (url: string) => void;
+ disabled: boolean;
+}
+
+/**
+ * The artwork a certificate is printed on.
+ *
+ * A background, not the certificate itself — the number, QR code and holder's
+ * name stay in the template layer above it, so one design serves every
+ * certificate it issues.
+ */
+export function TemplateBackgroundPanel({
+ backgroundUrl,
+ onBackgroundChange,
+ disabled,
+}: Props) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('designer.background', 'Background artwork')}
+
+
+ {t(
+ 'designer.backgroundHint',
+ 'Printed underneath the template. Certificate data is drawn on top, so the same artwork serves every certificate.',
+ )}
+
+
+
+
+ onBackgroundChange(e.currentTarget.value)}
+ disabled={disabled}
+ style={{ flex: 1 }}
+ />
+ {backgroundUrl && (
+ }
+ disabled={disabled}
+ onClick={() => onBackgroundChange('')}
+ >
+ {t('designer.removeBackground', 'Remove')}
+
+ )}
+
+
+ {backgroundUrl ? (
+
+ ) : (
+
+
+
+
+ {t('designer.noBackground', 'No artwork — the design renders as HTML only.')}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx
new file mode 100644
index 000000000..fa9c366c5
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx
@@ -0,0 +1,73 @@
+import type { RefObject } from 'react';
+import { Group, Paper, Switch, Text, TextInput, Textarea } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+interface Props {
+ name: string;
+ onNameChange: (value: string) => void;
+ source: string;
+ onSourceChange: (value: string) => void;
+ landscape: boolean;
+ onLandscapeChange: (value: boolean) => void;
+ editorRef: RefObject;
+ disabled: boolean;
+ isPublished: boolean;
+}
+
+/** Name, orientation and the Handlebars source for the selected version. */
+export function TemplateEditor({
+ name,
+ onNameChange,
+ source,
+ onSourceChange,
+ landscape,
+ onLandscapeChange,
+ editorRef,
+ disabled,
+ isPublished,
+}: Props) {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+ onNameChange(e.currentTarget.value)}
+ disabled={disabled}
+ style={{ flex: 1 }}
+ />
+ onLandscapeChange(e.currentTarget.checked)}
+ disabled={disabled}
+ />
+
+
+ {isPublished && (
+
+
+ {t(
+ 'designer.publishedLocked',
+ 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
+ )}
+
+
+ )}
+
+