diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx new file mode 100644 index 000000000..8e8684350 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -0,0 +1,191 @@ +import { + ActionIcon, + ColorInput, + Group, + NumberInput, + Paper, + SegmentedControl, + Select, + Stack, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; + +interface Props { + block: TemplateFieldPlacement | null; + variables: TemplateVariable[]; + onChange: (block: TemplateFieldPlacement) => void; + onDelete: (id: string) => void; + disabled: boolean; +} + +/** Everything about the selected block that is not its position on the page. */ +export function BlockPropertiesPanel({ + block, + variables, + onChange, + onDelete, + disabled, +}: Props) { + const { t } = useTranslation(); + + if (!block) { + return ( + + + {t('designer.noBlockSelected', 'Select a block on the page to edit it.')} + + + ); + } + + const isLiteral = block.variable === null; + + return ( + + + + + {t('designer.blockProperties', 'Selected block')} + + + onDelete(block.id)} + > + + + + + + + onChange( + value === 'text' + ? { ...block, variable: null, text: block.text ?? '' } + : { ...block, variable: variables[0]?.key ?? 'companyName' }, + ) + } + data={[ + { value: 'variable', label: t('designer.blockVariable', 'Variable') }, + { value: 'text', label: t('designer.blockText', 'Fixed text') }, + ]} + /> + + {isLiteral ? ( + onChange({ ...block, text: e.currentTarget.value })} + disabled={disabled} + /> + ) : ( + ({ - value: type.id, - label: localized(type.name) || type.key, - }))} + data={groupedTypeOptions(licenseTypes, localized, t)} value={typeId} onChange={onTypeChange} - w={280} + searchable + nothingFoundMessage={t('designer.noTypeMatch', 'No licence type matches')} + maxDropdownHeight={340} + w={340} /> {/* Validity lives beside the design because it is the other half of diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx index 422814958..70e603189 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx @@ -1,23 +1,56 @@ -import { Button, Group, Image, Paper, Stack, Text, TextInput } from '@mantine/core'; +import { + Button, + Group, + Image, + NumberInput, + Paper, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; import { IconPhotoUp, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; +import type { TemplateLogoCorner, TemplateLogoPlacement } from '@ema-platform/api'; interface Props { backgroundUrl: string; onBackgroundChange: (url: string) => void; + logoUrl: string; + onLogoChange: (url: string) => void; + logoPlacement: TemplateLogoPlacement; + onLogoPlacementChange: (placement: TemplateLogoPlacement) => void; + landscape: boolean; + onLandscapeChange: (landscape: boolean) => void; disabled: boolean; } +const CORNERS: { value: TemplateLogoCorner; labelKey: string; fallback: string }[] = [ + { value: 'TOP_LEFT', labelKey: 'designer.cornerTopLeft', fallback: 'Top left' }, + { value: 'TOP_CENTER', labelKey: 'designer.cornerTopCenter', fallback: 'Top centre' }, + { value: 'TOP_RIGHT', labelKey: 'designer.cornerTopRight', fallback: 'Top right' }, + { value: 'BOTTOM_LEFT', labelKey: 'designer.cornerBottomLeft', fallback: 'Bottom left' }, + { value: 'BOTTOM_RIGHT', labelKey: 'designer.cornerBottomRight', fallback: 'Bottom right' }, +]; + /** - * The artwork a certificate is printed on. + * Page-level settings: orientation, the artwork a certificate is printed on, + * and the authority's logo. * - * 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. + * Orientation belongs here rather than beside the version name because it is a + * property of the page, and it decides the shape of the canvas everything else + * is positioned on. */ export function TemplateBackgroundPanel({ backgroundUrl, onBackgroundChange, + logoUrl, + onLogoChange, + logoPlacement, + onLogoPlacementChange, + landscape, + onLandscapeChange, disabled, }: Props) { const { t } = useTranslation(); @@ -27,16 +60,44 @@ export function TemplateBackgroundPanel({
- {t('designer.background', 'Background artwork')} + {t('designer.pageSetup', 'Page setup')} {t( - 'designer.backgroundHint', - 'Printed underneath the template. Certificate data is drawn on top, so the same artwork serves every certificate.', + 'designer.pageSetupHint', + 'Orientation, background artwork and the authority logo. Certificate data is drawn on top, so the same page serves every certificate.', )}
+ +
+ + {t('designer.orientation', 'Orientation')} + + onLandscapeChange(value === 'landscape')} + disabled={disabled} + data={[ + { + value: 'portrait', + label: t('designer.portrait', 'Portrait'), + }, + { + value: 'landscape', + label: t('designer.landscape', 'Landscape'), + }, + ]} + /> + + {landscape + ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') + : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + +
+
+
)} + + + onLogoChange(e.currentTarget.value)} + disabled={disabled} + style={{ flex: 1 }} + /> + {logoUrl && ( + + )} + + + {logoUrl && ( + +