From d8bd0cd449c671013a99cc9cf588ae3024334403 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 16:48:24 +0200 Subject: [PATCH] i18n: close the admin translation coverage gaps Recurring pattern of components and strings shipped without translation coverage, found across unrelated feature areas. +212 keys each to en.json and de.json, provably additive (flattened-key diff: removed=0, changed=0; formatting round-trips byte-identically). Genuinely un-wired components (grep -c useTranslation == 0), now wired: BulkArchiveModal (8 strings, count-pluralised), WebhookDeliveriesPage (27), CMSEditor's TipTap toolbar/link dialog/status bar/help modal (64). Hardcoded strings fixed in code: ImageSecurityTab's 4 spinbutton hints, ProjectsListPage's unlocalized status enum. Keys-only (component already calls t() correctly): General "Time format", Branding Social Media + Promotional Banner, Quotes detail/editor, cms.showInFooter. Two corrections to the report's attribution: - BlockLibraryPage was NOT un-wired -- it calls t() on every string with English defaults; all 32 contracts.blocks.* keys were simply absent from both locale files, so everything fell back to the JSX default. Same for ContractsListPage, where the report cited 3 missing keys and there are actually 9 (all 5 table column headers plus the pagination line). - CustomerDetailPage has full t() coverage; its single English "Contracts" was a missing customer.nav.contracts key behind a dynamic labelKey. Locale convention followed: i18next.config.ts manages en/de/nl/pt/ru/fr, but only en and de are kept at parity (5198/5200 keys); the rest are ~50% partial and rely on fallbackLng 'en'. Added to en + de only rather than inventing 212x6 unreviewable translations. Also added the 25 missing businessProfile.* keys (PDF-letterhead section, bank-accounts QR disclaimer). That component already calls t(), so those strings localize as soon as the keys exist; no wiring needed. Refs testplan REPORT.md #15a. --- .../src/components/admin/BulkArchiveModal.tsx | 29 +- frontend/src/components/admin/CMSEditor.tsx | 150 +++++----- .../settings/tabs/ImageSecurityTab.tsx | 8 +- frontend/src/i18n/locales/de.json | 260 +++++++++++++++++- frontend/src/i18n/locales/en.json | 260 +++++++++++++++++- .../src/pages/admin/WebhookDeliveriesPage.tsx | 98 ++++--- .../pages/admin/projects/ProjectsListPage.tsx | 2 +- 7 files changed, 664 insertions(+), 143 deletions(-) diff --git a/frontend/src/components/admin/BulkArchiveModal.tsx b/frontend/src/components/admin/BulkArchiveModal.tsx index 879c3956..f67b7b8c 100644 --- a/frontend/src/components/admin/BulkArchiveModal.tsx +++ b/frontend/src/components/admin/BulkArchiveModal.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Archive, AlertTriangle, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Button, Card } from '../common'; import type { Event } from '../../types'; @@ -18,18 +19,25 @@ export const BulkArchiveModal: React.FC = ({ selectedEvents, isLoading = false, }) => { + const { t } = useTranslation(); + if (!isOpen) return null; + const count = selectedEvents.length; + return (
-

Confirm Bulk Archive

+

+ {t('events.bulkArchive.title', 'Confirm Bulk Archive')} +

@@ -40,21 +48,22 @@ export const BulkArchiveModal: React.FC = ({

- You are about to archive {selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}. - This action will: + {t('events.bulkArchive.intro', 'You are about to archive {{count}} events. This action will:', { count })}

    -
  • Create a ZIP archive of all photos for each event
  • -
  • Make the galleries inaccessible to guests
  • -
  • Remove the events from active listings
  • -
  • Free up storage space by compressing photos
  • +
  • {t('events.bulkArchive.effectZip', 'Create a ZIP archive of all photos for each event')}
  • +
  • {t('events.bulkArchive.effectInaccessible', 'Make the galleries inaccessible to guests')}
  • +
  • {t('events.bulkArchive.effectDelisted', 'Remove the events from active listings')}
  • +
  • {t('events.bulkArchive.effectStorage', 'Free up storage space by compressing photos')}
-

Events to be archived:

+

+ {t('events.bulkArchive.listHeading', 'Events to be archived:')} +

    {selectedEvents.map((event) => (
  • @@ -72,7 +81,7 @@ export const BulkArchiveModal: React.FC = ({ onClick={onClose} disabled={isLoading} > - Cancel + {t('common.cancel', 'Cancel')}
diff --git a/frontend/src/components/admin/CMSEditor.tsx b/frontend/src/components/admin/CMSEditor.tsx index fa09016a..e99dd87c 100644 --- a/frontend/src/components/admin/CMSEditor.tsx +++ b/frontend/src/components/admin/CMSEditor.tsx @@ -1,4 +1,5 @@ import React, { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; @@ -52,6 +53,7 @@ interface CMSEditorProps { type ViewMode = 'edit' | 'preview' | 'split'; export const CMSEditor: React.FC = ({ content, onChange, onSave, isSaving }) => { + const { t } = useTranslation(); const [linkUrl, setLinkUrl] = useState(''); const [showLinkDialog, setShowLinkDialog] = useState(false); const [viewMode, setViewMode] = useState('edit'); @@ -91,7 +93,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, }, }), Placeholder.configure({ - placeholder: 'Start typing your content here...', + placeholder: t('cms.editor.placeholder', 'Start typing your content here...'), }), CharacterCount.configure({ limit: null, @@ -193,15 +195,15 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave,
@@ -213,20 +215,22 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, isLoading={isSaving} leftIcon={} > - Save + {t('cms.editor.save', 'Save')} )} - + setShowHelp(true)} - title="Help & Keyboard Shortcuts" + title={t('cms.editor.help', 'Help & Keyboard Shortcuts')} > - + @@ -240,7 +244,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} - title="Heading 1 (Ctrl+Alt+1)" + title={t('cms.editor.tool.heading1', "Heading 1 (Ctrl+Alt+1)")} > @@ -248,7 +252,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} - title="Heading 2 (Ctrl+Alt+2)" + title={t('cms.editor.tool.heading2', "Heading 2 (Ctrl+Alt+2)")} > @@ -256,7 +260,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} - title="Heading 3 (Ctrl+Alt+3)" + title={t('cms.editor.tool.heading3', "Heading 3 (Ctrl+Alt+3)")} > @@ -264,7 +268,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleHeading({ level: 4 }).run()} active={editor.isActive('heading', { level: 4 })} - title="Heading 4 (Ctrl+Alt+4)" + title={t('cms.editor.tool.heading4', "Heading 4 (Ctrl+Alt+4)")} > @@ -272,7 +276,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleHeading({ level: 5 }).run()} active={editor.isActive('heading', { level: 5 })} - title="Heading 5 (Ctrl+Alt+5)" + title={t('cms.editor.tool.heading5', "Heading 5 (Ctrl+Alt+5)")} > @@ -280,7 +284,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleHeading({ level: 6 }).run()} active={editor.isActive('heading', { level: 6 })} - title="Heading 6 (Ctrl+Alt+6)" + title={t('cms.editor.tool.heading6', "Heading 6 (Ctrl+Alt+6)")} > @@ -290,7 +294,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} - title="Bold (Ctrl+B)" + title={t('cms.editor.tool.bold', "Bold (Ctrl+B)")} > @@ -298,7 +302,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} - title="Italic (Ctrl+I)" + title={t('cms.editor.tool.italic', "Italic (Ctrl+I)")} > @@ -306,7 +310,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} - title="Inline Code (Ctrl+E)" + title={t('cms.editor.tool.inlineCode', "Inline Code (Ctrl+E)")} > @@ -314,7 +318,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} - title="Code Block (Ctrl+Alt+C)" + title={t('cms.editor.tool.codeBlock', "Code Block (Ctrl+Alt+C)")} > @@ -324,7 +328,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} - title="Bullet List (Ctrl+Shift+8)" + title={t('cms.editor.tool.bulletList', "Bullet List (Ctrl+Shift+8)")} > @@ -332,7 +336,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} - title="Numbered List (Ctrl+Shift+9)" + title={t('cms.editor.tool.numberedList', "Numbered List (Ctrl+Shift+9)")} > @@ -340,7 +344,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} - title="Blockquote (Ctrl+Shift+B)" + title={t('cms.editor.tool.blockquote', "Blockquote (Ctrl+Shift+B)")} > @@ -350,14 +354,14 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, setShowLinkDialog(true)} active={editor.isActive('link')} - title="Add Link (Ctrl+K)" + title={t('cms.editor.tool.addLink', "Add Link (Ctrl+K)")} > editor.chain().focus().setHorizontalRule().run()} - title="Horizontal Rule" + title={t('cms.editor.tool.horizontalRule', "Horizontal Rule")} > @@ -367,7 +371,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} - title="Align Left" + title={t('cms.editor.tool.alignLeft', "Align Left")} > @@ -375,7 +379,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} - title="Align Center" + title={t('cms.editor.tool.alignCenter', "Align Center")} > @@ -383,7 +387,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} - title="Align Right" + title={t('cms.editor.tool.alignRight', "Align Right")} > @@ -391,7 +395,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} - title="Justify" + title={t('cms.editor.tool.justify', "Justify")} > @@ -400,7 +404,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().clearNodes().unsetAllMarks().run()} - title="Clear Formatting" + title={t('cms.editor.tool.clearFormatting', "Clear Formatting")} > @@ -410,7 +414,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().undo().run()} disabled={!editor.can().undo()} - title="Undo (Ctrl+Z)" + title={t('cms.editor.tool.undo', "Undo (Ctrl+Z)")} > @@ -418,7 +422,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, editor.chain().focus().redo().run()} disabled={!editor.can().redo()} - title="Redo (Ctrl+Y)" + title={t('cms.editor.tool.redo', "Redo (Ctrl+Y)")} > @@ -434,16 +438,16 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addLink()} - placeholder="Enter URL..." + placeholder={t('cms.editor.linkUrlPlaceholder', 'Enter URL...')} className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500" autoFocus /> - +
)} @@ -475,11 +479,11 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, {/* Status Bar */}
- {wordCount} words - {charCount} characters + {t('cms.editor.wordCount', '{{count}} words', { count: wordCount })} + {t('cms.editor.charCount', '{{count}} characters', { count: charCount })}
- Press Shift+Enter for line break, Enter for new paragraph + {t('cms.editor.lineBreakHint', 'Press Shift+Enter for line break, Enter for new paragraph')}
@@ -489,68 +493,70 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave,
-

Editor Help & Keyboard Shortcuts

- +

+ {t('cms.editor.helpTitle', 'Editor Help & Keyboard Shortcuts')} +

+
-

Text Formatting

+

{t('cms.editor.helpFormatting', 'Text Formatting')}

-
Ctrl+B - Bold
-
Ctrl+I - Italic
-
Ctrl+E - Inline code
-
Ctrl+K - Add link
+
Ctrl+B - {t('cms.editor.tool.boldShort', 'Bold')}
+
Ctrl+I - {t('cms.editor.tool.italicShort', 'Italic')}
+
Ctrl+E - {t('cms.editor.tool.inlineCodeShort', 'Inline code')}
+
Ctrl+K - {t('cms.editor.tool.addLinkShort', 'Add link')}
- +
-

Headings

+

{t('cms.editor.helpHeadings', 'Headings')}

-
Ctrl+Alt+1 - Heading 1
-
Ctrl+Alt+2 - Heading 2
-
Ctrl+Alt+3 - Heading 3
-
Ctrl+Alt+4 - Heading 4
-
Ctrl+Alt+5 - Heading 5
-
Ctrl+Alt+6 - Heading 6
+
Ctrl+Alt+1 - {t('cms.editor.tool.heading1Short', 'Heading 1')}
+
Ctrl+Alt+2 - {t('cms.editor.tool.heading2Short', 'Heading 2')}
+
Ctrl+Alt+3 - {t('cms.editor.tool.heading3Short', 'Heading 3')}
+
Ctrl+Alt+4 - {t('cms.editor.tool.heading4Short', 'Heading 4')}
+
Ctrl+Alt+5 - {t('cms.editor.tool.heading5Short', 'Heading 5')}
+
Ctrl+Alt+6 - {t('cms.editor.tool.heading6Short', 'Heading 6')}
- +
-

Lists & Blocks

+

{t('cms.editor.helpLists', 'Lists & Blocks')}

-
Ctrl+Shift+8 - Bullet list
-
Ctrl+Shift+9 - Numbered list
-
Ctrl+Shift+B - Blockquote
-
Ctrl+Alt+C - Code block
+
Ctrl+Shift+8 - {t('cms.editor.tool.bulletListShort', 'Bullet list')}
+
Ctrl+Shift+9 - {t('cms.editor.tool.numberedListShort', 'Numbered list')}
+
Ctrl+Shift+B - {t('cms.editor.tool.blockquoteShort', 'Blockquote')}
+
Ctrl+Alt+C - {t('cms.editor.tool.codeBlockShort', 'Code block')}
- +
-

Text Alignment

+

{t('cms.editor.helpAlignment', 'Text Alignment')}

-
Click alignment buttons in toolbar
-
Works on paragraphs and headings
+
{t('cms.editor.helpAlignmentClick', 'Click alignment buttons in toolbar')}
+
{t('cms.editor.helpAlignmentScope', 'Works on paragraphs and headings')}
- +
-

Line Breaks

+

{t('cms.editor.helpLineBreaks', 'Line Breaks')}

-
Enter - New paragraph
-
Shift+Enter - Line break (preserves formatting)
+
Enter - {t('cms.editor.helpNewParagraph', 'New paragraph')}
+
Shift+Enter - {t('cms.editor.helpLineBreak', 'Line break (preserves formatting)')}
- +
-

Navigation

+

{t('cms.editor.helpNavigation', 'Navigation')}

-
Ctrl+Z - Undo
-
Ctrl+Y - Redo
+
Ctrl+Z - {t('cms.editor.tool.undoShort', 'Undo')}
+
Ctrl+Y - {t('cms.editor.tool.redoShort', 'Redo')}
- +
- +
diff --git a/frontend/src/features/settings/tabs/ImageSecurityTab.tsx b/frontend/src/features/settings/tabs/ImageSecurityTab.tsx index 74778410..cc464b75 100644 --- a/frontend/src/features/settings/tabs/ImageSecurityTab.tsx +++ b/frontend/src/features/settings/tabs/ImageSecurityTab.tsx @@ -159,7 +159,7 @@ export const ImageSecurityTab: React.FC = () => { onChange={(e) => handleChange('default_image_quality', parseInt(e.target.value) || 85)} className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" /> -

1-100, higher = better quality

+

{t('settings.imageSecurity.imageQualityHelp', '1-100, higher = better quality')}

@@ -174,7 +174,7 @@ export const ImageSecurityTab: React.FC = () => { onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)} className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" /> -

1-10, higher = more protection

+

{t('settings.imageSecurity.fragmentationLevelHelp', '1-10, higher = more protection')}

@@ -282,7 +282,7 @@ export const ImageSecurityTab: React.FC = () => { onChange={(e) => handleChange('suspicious_activity_threshold', parseInt(e.target.value) || 10)} className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" /> -

Violations before flagging as suspicious

+

{t('settings.imageSecurity.suspiciousActivityThresholdHelp', 'Violations before flagging as suspicious')}

@@ -297,7 +297,7 @@ export const ImageSecurityTab: React.FC = () => { onChange={(e) => handleChange('auto_block_threshold', parseInt(e.target.value) || 50)} className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" /> -

Violations before auto-blocking IP

+

{t('settings.imageSecurity.autoBlockThresholdHelp', 'Violations before auto-blocking IP')}

diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 6ae92488..d635b04a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1527,6 +1527,18 @@ "title": "Galerie-E-Mail senden", "description": "Sendet den Galerie-Link für \"{{eventName}}\" an {{recipient}}.", "passwordHelp": "Die E-Mail enthält genau diesen Text. Geben Sie das Galerie-Passwort erneut ein (oder wählen Sie ein neues) — das Backend hasht es neu, damit der Login weiter funktioniert." + }, + "bulkArchive": { + "title": "Massen-Archivierung bestätigen", + "intro_one": "Sie sind dabei, {{count}} Veranstaltung zu archivieren. Dabei wird:", + "intro_other": "Sie sind dabei, {{count}} Veranstaltungen zu archivieren. Dabei wird:", + "effectZip": "für jede Veranstaltung ein ZIP-Archiv aller Fotos erstellt", + "effectInaccessible": "der Zugriff der Gäste auf die Galerien gesperrt", + "effectDelisted": "die Veranstaltung aus den aktiven Listen entfernt", + "effectStorage": "durch Komprimieren der Fotos Speicherplatz freigegeben", + "listHeading": "Zu archivierende Veranstaltungen:", + "submit_one": "{{count}} Veranstaltung archivieren", + "submit_other": "{{count}} Veranstaltungen archivieren" } }, "settings": { @@ -1572,7 +1584,11 @@ "accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben", "accountSaveButton": "Kontodaten speichern", "accountSaveSuccess": "Kontodaten aktualisiert", - "siteUrlInvalid": "Geben Sie die vollständige Adresse inklusive http:// oder https:// an, zum Beispiel https://galerie.example.com" + "siteUrlInvalid": "Geben Sie die vollständige Adresse inklusive http:// oder https:// an, zum Beispiel https://galerie.example.com", + "timeFormat": "Zeitformat", + "timeFormat24h": "24-Stunden (14:30)", + "timeFormat12h": "12-Stunden (2:30 PM)", + "timeFormatHelp": "Steuert die Darstellung von Uhrzeiten in der Administration, auf kundenseitigen Seiten und in PDFs. Gespeichert wird weiterhin im 24-Stunden-Format (HH:mm) — nur die Anzeige ändert sich." }, "publicSite": { "badge": "Öffentliche Landingpage", @@ -1724,7 +1740,11 @@ "blockSuspiciousIps": "Verdächtige IPs automatisch blockieren", "logEvents": "Sicherheitsereignisse in Datenbank protokollieren", "infoTitle": "Über Bildschutz", - "infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz." + "infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz.", + "imageQualityHelp": "1–100, höher = bessere Qualität", + "fragmentationLevelHelp": "1–10, höher = mehr Schutz", + "suspiciousActivityThresholdHelp": "Verstöße, bevor als verdächtig markiert wird", + "autoBlockThresholdHelp": "Verstöße, bevor die IP automatisch gesperrt wird" }, "seo": { "title": "SEO & Robots", @@ -2014,7 +2034,41 @@ "createError": "Webhook konnte nicht erstellt werden", "updateError": "Webhook konnte nicht aktualisiert werden", "deletedToast": "Webhook gelöscht", - "deleteError": "Webhook konnte nicht gelöscht werden" + "deleteError": "Webhook konnte nicht gelöscht werden", + "deliveries": { + "back": "Zurück zu den Einstellungen", + "notFound": "Webhook nicht gefunden.", + "sendTest": "Testereignis senden", + "sendTestHelp": "Sendet eine künstliche Zustellung mit Beispiel-Payload an Ihren Empfänger — ohne echte Seiteneffekte.", + "refresh": "Aktualisieren", + "send": "Senden", + "eventType": "Ereignistyp", + "total_one": "{{count}} gesamt", + "total_other": "{{count}} gesamt", + "empty": "Noch keine Zustellungen. Erstellen Sie eine Veranstaltung oder senden Sie ein Testereignis, um hier etwas zu sehen.", + "colTime": "Zeit", + "colEvent": "Ereignis", + "colStatus": "Status", + "colAttempts": "Versuche", + "colHttp": "HTTP", + "colLatency": "Latenz", + "replay": "Erneut senden", + "replayEnqueued": "Erneute Zustellung eingereiht", + "replayError": "Erneutes Senden fehlgeschlagen", + "testEnqueued": "Testereignis eingereiht", + "testError": "Testereignis konnte nicht gesendet werden", + "detailTitle": "Zustellung #{{id}}", + "lastError": "Letzter Fehler", + "responseStatus": "Antwortstatus", + "responseBody": "Antwort-Body (auf 1 KB gekürzt)", + "payload": "Payload (signierter Body)", + "status": { + "all": "alle", + "pending": "ausstehend", + "success": "erfolgreich", + "failed": "fehlgeschlagen" + } + } }, "sectionLabel": "Einstellungsbereich", "navAriaLabel": "Einstellungsnavigation", @@ -2690,7 +2744,16 @@ "alignment": "Ausrichtung", "alignLeft": "Links", "alignCenter": "Zentriert (Standard – wie der Footer)", - "alignRight": "Rechts" + "alignRight": "Rechts", + "title": "Werbebanner in der Galerie", + "help": "Markdown, das über oder unter der Galerie-Fußzeile angezeigt wird (z. B. Saisonangebot, Print-Rabatt). Einstellungen auf Veranstaltungsebene haben Vorrang.", + "position": "Position", + "aboveFooter": "Über der Fußzeile", + "belowFooter": "Unter der Fußzeile", + "content": "Inhalt (Markdown)", + "placeholder": "**Frühlingsaktion**: 20 % Rabatt auf Abzüge mit dem Code SPRING — zum [Print-Shop](https://example.com).", + "markdownHelp": "Fett, kursiv, Links, Listen und Überschriften werden unterstützt. HTML wird entfernt.", + "preview": "Vorschau" }, "infoBanner": { "title": "Info-Banner der Galerie", @@ -2699,6 +2762,11 @@ "placeholder": "Über die Menü-Schaltfläche oben links lassen sich die Fotos filtern.", "markdownHelp": "Fett, kursiv, Links, Listen und Überschriften werden unterstützt. HTML wird entfernt.", "preview": "Vorschau" + }, + "socialMedia": { + "title": "Soziale Medien", + "help": "Hinterlegen Sie URLs, um Social-Media-Symbole in der Galerie-Fußzeile anzuzeigen. Lassen Sie ein Feld leer, um das jeweilige Symbol auszublenden.", + "whatsappHelp": "Eine wa.me-URL oder eine Telefonnummer mit Ländervorwahl (wird umgewandelt)." } }, "admin": { @@ -3621,7 +3689,77 @@ "noLogo": "keine Überschreibung", "replaceLogo": "Logo ersetzen", "uploadLogo": "Logo hochladen", - "clearLogo": "Seitenstandard verwenden" + "clearLogo": "Seitenstandard verwenden", + "editor": { + "placeholder": "Beginnen Sie hier mit Ihrem Inhalt …", + "viewEdit": "Bearbeiten", + "viewPreview": "Vorschau", + "viewSplit": "Geteilt", + "save": "Speichern", + "help": "Hilfe & Tastenkürzel", + "enterFullscreen": "Vollbild", + "exitFullscreen": "Vollbild beenden", + "linkUrlPlaceholder": "URL eingeben …", + "addLink": "Link einfügen", + "wordCount_one": "{{count}} Wort", + "wordCount_other": "{{count}} Wörter", + "charCount_one": "{{count}} Zeichen", + "charCount_other": "{{count}} Zeichen", + "lineBreakHint": "Umschalt+Enter für einen Zeilenumbruch, Enter für einen neuen Absatz", + "helpTitle": "Editor-Hilfe & Tastenkürzel", + "helpFormatting": "Textformatierung", + "helpHeadings": "Überschriften", + "helpLists": "Listen & Blöcke", + "helpAlignment": "Textausrichtung", + "helpAlignmentClick": "Ausrichtungs-Schaltflächen in der Symbolleiste anklicken", + "helpAlignmentScope": "Funktioniert für Absätze und Überschriften", + "helpLineBreaks": "Zeilenumbrüche", + "helpNewParagraph": "Neuer Absatz", + "helpLineBreak": "Zeilenumbruch (Formatierung bleibt erhalten)", + "helpNavigation": "Navigation", + "tool": { + "heading1": "Überschrift 1 (Strg+Alt+1)", + "heading2": "Überschrift 2 (Strg+Alt+2)", + "heading3": "Überschrift 3 (Strg+Alt+3)", + "heading4": "Überschrift 4 (Strg+Alt+4)", + "heading5": "Überschrift 5 (Strg+Alt+5)", + "heading6": "Überschrift 6 (Strg+Alt+6)", + "bold": "Fett (Strg+B)", + "italic": "Kursiv (Strg+I)", + "inlineCode": "Inline-Code (Strg+E)", + "codeBlock": "Codeblock (Strg+Alt+C)", + "bulletList": "Aufzählung (Strg+Umschalt+8)", + "numberedList": "Nummerierte Liste (Strg+Umschalt+9)", + "blockquote": "Zitatblock (Strg+Umschalt+B)", + "addLink": "Link einfügen (Strg+K)", + "horizontalRule": "Trennlinie", + "alignLeft": "Linksbündig", + "alignCenter": "Zentriert", + "alignRight": "Rechtsbündig", + "justify": "Blocksatz", + "clearFormatting": "Formatierung entfernen", + "undo": "Rückgängig (Strg+Z)", + "redo": "Wiederherstellen (Strg+Y)", + "boldShort": "Fett", + "italicShort": "Kursiv", + "inlineCodeShort": "Inline-Code", + "addLinkShort": "Link einfügen", + "heading1Short": "Überschrift 1", + "heading2Short": "Überschrift 2", + "heading3Short": "Überschrift 3", + "heading4Short": "Überschrift 4", + "heading5Short": "Überschrift 5", + "heading6Short": "Überschrift 6", + "bulletListShort": "Aufzählung", + "numberedListShort": "Nummerierte Liste", + "blockquoteShort": "Zitatblock", + "codeBlockShort": "Codeblock", + "undoShort": "Rückgängig", + "redoShort": "Wiederherstellen" + } + }, + "showInFooter": "In der Galerie-Fußzeile anzeigen", + "showInFooterHelp": "Wenn deaktiviert, wird diese Seite in der öffentlichen Galerie-Fußzeile ausgeblendet. Die Seite selbst bleibt über ihre direkte URL erreichbar." }, "validation": { "eventNameRequired": "Veranstaltungsname ist erforderlich", @@ -4325,7 +4463,8 @@ "bills": "Rechnungen", "profile": "Profil", "soon": "Bald", - "new": "Neu" + "new": "Neu", + "contracts": "Verträge" }, "comingSoon": { "tag": "Demnächst" @@ -5007,6 +5146,10 @@ }, "error": { "customerMismatch": "Das gehört zu einem anderen Kunden als dieses Projekt." + }, + "status": { + "active": "Aktiv", + "archived": "Archiviert" } }, "calendar": { @@ -5373,7 +5516,14 @@ "ccPdfEmailPlaceholder": "name@example.com", "ccPdfPickFromAdmins": "Aus Admins auswählen:", "ccPdfCustom": "Eigene E-Mail-Adresse", - "internalNotes": "Interne Notizen (nicht auf der PDF)" + "internalNotes": "Interne Notizen (nicht auf der PDF)", + "eventType": "Anlassart", + "eventTypeNone": "— Standard verwenden —", + "eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.", + "bookingWorkflow": "Buchungs-Workflow (bei Annahme)", + "bookingWorkflowNone": "— Keiner —", + "bookingWorkflowDisabled": "(deaktiviert)", + "bookingWorkflowHint": "Der Ablauf, der startet, wenn der Kunde annimmt. Auf „Keiner“ lassen, um keinen Buchungsablauf auszuführen. Der Ablauf muss aktiviert sein, damit er ausgelöst wird." }, "responseWindowOpen": "Offen bis {{at}}", "section": { @@ -5415,7 +5565,17 @@ "after_event": "nach dem Anlass", "after_delivery": "nach Lieferung", "fixed_date": "an festem Datum" - } + }, + "acceptOnBehalf": "Im Namen annehmen", + "confirmAcceptOnBehalf": "Dieses Angebot im Namen des Kunden als angenommen markieren? Nur verwenden, wenn er mündlich zugestimmt hat (z. B. am Telefon).", + "acceptedOnBehalfToast": "Angebot als angenommen markiert.", + "convertToInvoice": "Nur in Rechnung umwandeln", + "confirmConvertToInvoice": "Dieses Angebot nur in Rechnung(en) umwandeln? Es wird keine Galerie / kein Anlass erstellt.", + "convertedToInvoiceToast": "{{count}} Rechnung(en) aus diesem Angebot erstellt", + "convertToContract": "In Vertrag umwandeln", + "confirmConvertToContract": "Aus diesem Angebot einen Vertrag entwerfen? Kunde und Administrator unterzeichnen beide, bevor Anlass / Rechnung erstellt werden.", + "convertedToContractToast": "Vertrag aus diesem Angebot entworfen.", + "contractAlreadyLinkedToast": "Aus diesem Angebot wurde bereits ein Vertrag entworfen." }, "crm": { "lineItems": { @@ -5663,7 +5823,22 @@ "defaultLocale": "Standardsprache", "timezone": "Zeitzone (IANA)", "defaultQrFormat": "Standard-QR-Format", - "footerLine": "Fusszeile" + "footerLine": "Fusszeile", + "timezoneSystemDefault": "Systemstandard", + "pdfLogoUpload": "Logo für den PDF-Briefkopf (PNG, JPEG oder SVG)", + "pdfLogoUploadHelp": "Wird auf jedem Angebots- und Rechnungs-PDF verwendet. SVG wird akzeptiert und automatisch in PNG umgewandelt. Ohne Eintrag greift der Renderer auf das globale Branding-Logo zurück.", + "pdfLogoHeight": "Höhe des PDF-Logos (pt, 24–200)", + "pdfFoldingMarks": "Falzmarken am Rand der PDF-Seite", + "pdfShowLogo": "Logo im PDF-Briefkopf anzeigen", + "pdfShowLogoHelp": "Wenn deaktiviert, wird das Logo auf jedem PDF unterdrückt — auch wenn ein Logo hochgeladen ist.", + "pdfShowCompanyName": "Firmennamen im PDF-Briefkopf anzeigen", + "pdfShowCompanyNameHelp": "Wenn deaktiviert, entfällt die Zeile mit dem Firmennamen (nützlich, wenn das Logo bereits eine Wortmarke mit dem Namen ist).", + "pdfCompanyNameInline": "Firmennamen direkt über der Adresse darstellen", + "pdfCompanyNameInlineHelp": "Wenn aktiviert, erscheint der Firmenname als einfache Zeile direkt über der Strassenadresse (gleiche Grösse und Schriftstärke). Wenn deaktiviert, wird er als fetter Titel unter dem Logo dargestellt.", + "pdfQuoteShowNetDays": "Zahlungsfrist (Netto-Tage) auf Angebots-PDFs anzeigen", + "pdfQuoteShowNetDaysHelp": "Wenn aktiviert, enthalten Angebots-PDFs die Zeile „X Tage ab Rechnungsdatum.“ im Block mit den Zahlungsbedingungen. Auf Rechnungen wird diese Zeile unabhängig davon immer angezeigt.", + "pdfQuoteShowSkonto": "Skonto / Frühzahlerrabatt auf Angebots-PDFs anzeigen", + "pdfQuoteShowSkontoHelp": "Wenn aktiviert, enthalten Angebots-PDFs das Skonto-Angebot und die Zeile „Betrag mit Skonto“. Auf Rechnungen werden diese unabhängig davon immer angezeigt." }, "qrFormat": { "none": "Kein QR", @@ -5681,7 +5856,21 @@ "makeDefault": "Als Standard", "confirmDelete": "Diese Bankverbindung wirklich löschen?" }, - "noBanks": "Noch keine Bankverbindungen hinterlegt." + "noBanks": "Noch keine Bankverbindungen hinterlegt.", + "foldingMarks": { + "none": "Keine", + "half": "Hälfte (148,5 mm) — für C5-Couverts", + "third": "Drittel (105 + 210 mm) — für DL- / DIN-lang-Couverts", + "both": "Alle drei Marken" + }, + "qrDisclaimer": { + "title": "QR-Rechnung / Bankdaten — vor dem Produktivbetrieb prüfen", + "body": "Picpeak ist Open Source. Wir erzeugen den QR-Code und den IBAN-Block aus den von Ihnen eingegebenen Werten — wir prüfen sie nicht. Drucken Sie eine Testrechnung und scannen Sie sie mit der App Ihrer Bank, bevor Sie echte Rechnungen versenden. Für Fehler, die aus dem Versand einer Rechnung mit falschen Daten entstehen, übernehmen wir keine Haftung." + }, + "logoUploadedToast": "PDF-Logo hochgeladen.", + "logoConfirmClear": "PDF-Logo entfernen? Der Renderer greift dann auf das Branding-Logo zurück, sofern eines hinterlegt ist.", + "logoClearedToast": "PDF-Logo entfernt.", + "bankUpdatedToast": "Bankverbindung aktualisiert." }, "crmSettings": { "savedToast": "CRM-Einstellungen gespeichert.", @@ -5971,7 +6160,18 @@ "cancelled": "Storniert" }, "list": { - "new": "Neuer Vertrag" + "new": "Neuer Vertrag", + "blocksLibrary": "Bausteinbibliothek", + "searchPlaceholder": "Nach Nummer, Titel oder Kunde suchen …", + "empty": "Noch keine Verträge.", + "table": { + "number": "Nummer", + "customer": "Kunde", + "title": "Titel", + "issueDate": "Ausgestellt", + "status": "Status" + }, + "pagination": "Seite {{page}} von {{total}} · {{count}} Verträge" }, "detail": { "previewPdf": "PDF-Vorschau", @@ -6098,6 +6298,44 @@ "titleNew": "Neuer Vertrag", "titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller", "validUntil": "Unterzeichnen bis (optional)" + }, + "blocks": { + "back": "Zurück zu den Verträgen", + "title": "Vertragsbaustein-Bibliothek", + "hideInactive": "Inaktive ausblenden", + "disclaimerTitle": "Nur Beispiele — lassen Sie sie von Ihrem Anwalt prüfen", + "disclaimerBody": "Die mitgelieferten „System“-Bausteine stammen vom picpeak-Maintainer, nicht von einem Anwalt. Sie sind ausschließlich als Ausgangspunkt gedacht — lassen Sie jeden Baustein, den Sie versenden wollen, von Ihrem eigenen Anwalt prüfen und anpassen. Änderungen an System-Bausteinen bleiben erhalten; ersetzen Sie den mitgelieferten Text einfach durch die anwaltlich geprüfte Fassung.", + "sidebarHeading": "Bausteine", + "new": "Neuer Baustein", + "systemBadge": "System", + "empty": "Noch keine Bausteine.", + "selectPrompt": "Wählen Sie links einen Baustein aus oder erstellen Sie einen neuen, um mit der Bearbeitung zu beginnen.", + "active": "Aktiv", + "inactive": "Inaktiv", + "delete": "Löschen", + "deleteConfirm": "Diesen Baustein löschen?", + "save": "Speichern", + "noTranslation": "Noch keine Übersetzung", + "createdToast": "Baustein erstellt.", + "createError": "Erstellen fehlgeschlagen", + "updatedToast": "Baustein aktualisiert.", + "updateError": "Aktualisieren fehlgeschlagen", + "deletedToast": "Baustein gelöscht.", + "deleteError": "Löschen fehlgeschlagen", + "dialog": { + "createTitle": "Neuer Baustein", + "editTitle": "Baustein bearbeiten", + "name": "Name", + "section": "Abschnitt", + "description": "Beschreibung (interner Hinweis)", + "body": "Text", + "placeholderHint": "Sie können {{customer_name}}, {{event_name}}, {{event_date}}, {{net_days}}, {{skonto_percent}}, {{skonto_within_days}}, {{cancellation_30d_percent}}, {{currency}}, {{issuer_company_name}}, {{issuer_address}}, {{contract_number}} und {{source_quote_number}} als Platzhalter verwenden — sie werden beim Rendern des Vertrags ersetzt." + }, + "quoteLineItems": { + "calloutTitle": "Automatisch erzeugte Tabelle folgt auf den Text", + "calloutBody": "Wenn dieser Baustein in einem aus einem Angebot erstellten Vertrag enthalten ist, fügt das PDF direkt nach dem obigen Text eine echte Tabelle mit den Positionen des Ausgangsangebots ein (Nr., Beschreibung, Menge, Einzelpreis, Gesamt). Unterpositionen werden eingerückt mit einem ↳ dargestellt. Verträge ohne Ausgangsangebot lassen die Tabelle weg und zeigen nur den Text.", + "previewExample": "Beispielausgabe:" + } } }, "messages": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9a3688be..5dbe103c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1068,6 +1068,18 @@ "title": "Send gallery email", "description": "Sends the gallery link for \"{{eventName}}\" to {{recipient}}.", "passwordHelp": "The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the login still works." + }, + "bulkArchive": { + "title": "Confirm Bulk Archive", + "intro_one": "You are about to archive {{count}} event. This action will:", + "intro_other": "You are about to archive {{count}} events. This action will:", + "effectZip": "Create a ZIP archive of all photos for each event", + "effectInaccessible": "Make the galleries inaccessible to guests", + "effectDelisted": "Remove the events from active listings", + "effectStorage": "Free up storage space by compressing photos", + "listHeading": "Events to be archived:", + "submit_one": "Archive {{count}} event", + "submit_other": "Archive {{count}} events" } }, "settings": { @@ -1113,7 +1125,11 @@ "accountEmailInvalid": "Enter a valid email address", "accountSaveButton": "Save account details", "accountSaveSuccess": "Account details updated", - "siteUrlInvalid": "Enter the full address including http:// or https://, for example https://gallery.example.com" + "siteUrlInvalid": "Enter the full address including http:// or https://, for example https://gallery.example.com", + "timeFormat": "Time format", + "timeFormat24h": "24-hour (14:30)", + "timeFormat12h": "12-hour (2:30 PM)", + "timeFormatHelp": "Controls how times render across admin views, customer-facing pages, and PDFs. Storage stays 24-hour (HH:mm); only the display switches." }, "publicSite": { "badge": "Public Landing", @@ -1328,7 +1344,11 @@ "blockSuspiciousIps": "Automatically block suspicious IPs", "logEvents": "Log security events to database", "infoTitle": "About Image Protection", - "infoText": "These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection." + "infoText": "These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection.", + "imageQualityHelp": "1-100, higher = better quality", + "fragmentationLevelHelp": "1-10, higher = more protection", + "suspiciousActivityThresholdHelp": "Violations before flagging as suspicious", + "autoBlockThresholdHelp": "Violations before auto-blocking IP" }, "moderation": { "title": "Moderation", @@ -1555,7 +1575,41 @@ "createError": "Failed to create webhook", "updateError": "Failed to update webhook", "deletedToast": "Webhook deleted", - "deleteError": "Failed to delete webhook" + "deleteError": "Failed to delete webhook", + "deliveries": { + "back": "Back to Settings", + "notFound": "Webhook not found.", + "sendTest": "Send test event", + "sendTestHelp": "Fires a synthetic delivery to your receiver with a stub payload, no actual side effects.", + "refresh": "Refresh", + "send": "Send", + "eventType": "Event type", + "total_one": "{{count}} total", + "total_other": "{{count}} total", + "empty": "No deliveries yet. Create an event or send a test event to see something here.", + "colTime": "Time", + "colEvent": "Event", + "colStatus": "Status", + "colAttempts": "Attempts", + "colHttp": "HTTP", + "colLatency": "Latency", + "replay": "Replay", + "replayEnqueued": "Replay enqueued", + "replayError": "Failed to replay", + "testEnqueued": "Test event enqueued", + "testError": "Failed to send test", + "detailTitle": "Delivery #{{id}}", + "lastError": "Last error", + "responseStatus": "Response status", + "responseBody": "Response body (truncated to 1KB)", + "payload": "Payload (signed body)", + "status": { + "all": "all", + "pending": "pending", + "success": "success", + "failed": "failed" + } + } }, "sectionLabel": "Settings section", "navAriaLabel": "Settings navigation", @@ -2265,7 +2319,16 @@ "alignment": "Alignment", "alignLeft": "Left", "alignCenter": "Center (default — matches footer)", - "alignRight": "Right" + "alignRight": "Right", + "title": "Gallery Promotional Banner", + "help": "Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.", + "position": "Position", + "aboveFooter": "Above footer", + "belowFooter": "Below footer", + "content": "Content (markdown)", + "placeholder": "**Spring offer**: 20% off prints with code SPRING — see the [print shop](https://example.com).", + "markdownHelp": "Bold, italic, links, lists, and headings supported. HTML is stripped.", + "preview": "Preview" }, "infoBanner": { "title": "Gallery Info Banner", @@ -2274,6 +2337,11 @@ "placeholder": "Use the menu button in the top-left corner to filter the photos.", "markdownHelp": "Bold, italic, links, lists, and headings supported. HTML is stripped.", "preview": "Preview" + }, + "socialMedia": { + "title": "Social Media", + "help": "Add URLs to render social-media icons in the gallery footer. Leave a field empty to hide that icon.", + "whatsappHelp": "A wa.me URL or a phone number with country code (will be converted)." } }, "admin": { @@ -3177,7 +3245,77 @@ "noLogo": "no override", "replaceLogo": "Replace Logo", "uploadLogo": "Upload Logo", - "clearLogo": "Use site default" + "clearLogo": "Use site default", + "editor": { + "placeholder": "Start typing your content here...", + "viewEdit": "Edit", + "viewPreview": "Preview", + "viewSplit": "Split", + "save": "Save", + "help": "Help & Keyboard Shortcuts", + "enterFullscreen": "Enter Fullscreen", + "exitFullscreen": "Exit Fullscreen", + "linkUrlPlaceholder": "Enter URL...", + "addLink": "Add Link", + "wordCount_one": "{{count}} words", + "wordCount_other": "{{count}} words", + "charCount_one": "{{count}} characters", + "charCount_other": "{{count}} characters", + "lineBreakHint": "Press Shift+Enter for line break, Enter for new paragraph", + "helpTitle": "Editor Help & Keyboard Shortcuts", + "helpFormatting": "Text Formatting", + "helpHeadings": "Headings", + "helpLists": "Lists & Blocks", + "helpAlignment": "Text Alignment", + "helpAlignmentClick": "Click alignment buttons in toolbar", + "helpAlignmentScope": "Works on paragraphs and headings", + "helpLineBreaks": "Line Breaks", + "helpNewParagraph": "New paragraph", + "helpLineBreak": "Line break (preserves formatting)", + "helpNavigation": "Navigation", + "tool": { + "heading1": "Heading 1 (Ctrl+Alt+1)", + "heading2": "Heading 2 (Ctrl+Alt+2)", + "heading3": "Heading 3 (Ctrl+Alt+3)", + "heading4": "Heading 4 (Ctrl+Alt+4)", + "heading5": "Heading 5 (Ctrl+Alt+5)", + "heading6": "Heading 6 (Ctrl+Alt+6)", + "bold": "Bold (Ctrl+B)", + "italic": "Italic (Ctrl+I)", + "inlineCode": "Inline Code (Ctrl+E)", + "codeBlock": "Code Block (Ctrl+Alt+C)", + "bulletList": "Bullet List (Ctrl+Shift+8)", + "numberedList": "Numbered List (Ctrl+Shift+9)", + "blockquote": "Blockquote (Ctrl+Shift+B)", + "addLink": "Add Link (Ctrl+K)", + "horizontalRule": "Horizontal Rule", + "alignLeft": "Align Left", + "alignCenter": "Align Center", + "alignRight": "Align Right", + "justify": "Justify", + "clearFormatting": "Clear Formatting", + "undo": "Undo (Ctrl+Z)", + "redo": "Redo (Ctrl+Y)", + "boldShort": "Bold", + "italicShort": "Italic", + "inlineCodeShort": "Inline code", + "addLinkShort": "Add link", + "heading1Short": "Heading 1", + "heading2Short": "Heading 2", + "heading3Short": "Heading 3", + "heading4Short": "Heading 4", + "heading5Short": "Heading 5", + "heading6Short": "Heading 6", + "bulletListShort": "Bullet list", + "numberedListShort": "Numbered list", + "blockquoteShort": "Blockquote", + "codeBlockShort": "Code block", + "undoShort": "Undo", + "redoShort": "Redo" + } + }, + "showInFooter": "Show in gallery footer", + "showInFooterHelp": "When off, this page is hidden from the public gallery footer. The page itself remains accessible at its direct URL." }, "eventTypes": { "title": "Event Types", @@ -4325,7 +4463,8 @@ "bills": "Invoices", "profile": "Profile", "soon": "Soon", - "new": "New" + "new": "New", + "contracts": "Contracts" }, "comingSoon": { "tag": "Coming soon" @@ -5007,6 +5146,10 @@ }, "error": { "customerMismatch": "That belongs to a different customer than this project." + }, + "status": { + "active": "Active", + "archived": "Archived" } }, "calendar": { @@ -5365,7 +5508,14 @@ "ccPdfEmailPlaceholder": "name@example.com", "ccPdfPickFromAdmins": "Pick from admins:", "ccPdfCustom": "Custom email", - "internalNotes": "Internal notes (not on PDF)" + "internalNotes": "Internal notes (not on PDF)", + "eventType": "Event type", + "eventTypeNone": "— Use default —", + "eventTypeHint": "Used for the event created when this quote is accepted.", + "bookingWorkflow": "Booking workflow (on acceptance)", + "bookingWorkflowNone": "— None —", + "bookingWorkflowDisabled": "(disabled)", + "bookingWorkflowHint": "The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire." }, "responseWindowOpen": "Open until {{at}}", "section": { @@ -5415,7 +5565,17 @@ "after_event": "after event", "after_delivery": "after delivery", "fixed_date": "on fixed date" - } + }, + "acceptOnBehalf": "Accept on behalf", + "confirmAcceptOnBehalf": "Mark this quote as accepted on behalf of the customer? Use only when they have verbally agreed (e.g. on the phone).", + "acceptedOnBehalfToast": "Quote marked as accepted.", + "convertToInvoice": "Convert to invoice only", + "confirmConvertToInvoice": "Convert this quote into invoice(s) only? No gallery / event will be created.", + "convertedToInvoiceToast": "{{count}} invoice(s) created from this quote", + "convertToContract": "Convert to contract", + "confirmConvertToContract": "Draft a contract from this quote? The customer + admin will both sign before event / invoice creation.", + "convertedToContractToast": "Contract drafted from this quote.", + "contractAlreadyLinkedToast": "A contract was already drafted from this quote." }, "crm": { "lineItems": { @@ -5661,7 +5821,22 @@ "defaultLocale": "Default locale", "timezone": "Timezone (IANA)", "defaultQrFormat": "Default invoice QR", - "footerLine": "PDF footer line" + "footerLine": "PDF footer line", + "timezoneSystemDefault": "System default", + "pdfLogoUpload": "PDF letterhead logo (PNG, JPEG, or SVG)", + "pdfLogoUploadHelp": "Used on every quote and invoice PDF. SVG is accepted and rasterised to PNG automatically. When empty, the renderer falls back to the global Branding logo.", + "pdfLogoHeight": "PDF logo height (pt, 24-200)", + "pdfFoldingMarks": "Folding marks on PDF page edge", + "pdfShowLogo": "Show logo in PDF letterhead", + "pdfShowLogoHelp": "When off, the logo image is suppressed on every PDF even if a logo is uploaded.", + "pdfShowCompanyName": "Show company name in PDF letterhead", + "pdfShowCompanyNameHelp": "When off, the company-name line is suppressed (useful if the logo is a wordmark already containing the name).", + "pdfCompanyNameInline": "Render company name inline with address", + "pdfCompanyNameInlineHelp": "When on, the company name appears as a plain line directly above the street address (same size + weight). When off, it renders as a bold title under the logo.", + "pdfQuoteShowNetDays": "Show net payment days on quote PDFs", + "pdfQuoteShowNetDaysHelp": "When on, quote PDFs include the \"X days from invoice date.\" line in the payment conditions block. Invoices always show this row regardless.", + "pdfQuoteShowSkonto": "Show Skonto / early-payment discount on quote PDFs", + "pdfQuoteShowSkontoHelp": "When on, quote PDFs include the Skonto offer and the \"Amount with discount\" line. Invoices always show these regardless." }, "qrFormat": { "none": "None", @@ -5679,7 +5854,21 @@ "makeDefault": "Make default", "confirmDelete": "Remove this bank account?" }, - "noBanks": "No bank accounts configured yet." + "noBanks": "No bank accounts configured yet.", + "foldingMarks": { + "none": "None", + "half": "Half (148.5mm) — for C5 envelopes", + "third": "Thirds (105 + 210mm) — for DL / DIN long envelopes", + "both": "All three marks" + }, + "qrDisclaimer": { + "title": "QR-bill / bank data — verify before going live", + "body": "Picpeak is open source. We render the QR code and IBAN block from the values you typed — we don't validate them. Print a test invoice and scan it with your bank's app before sending real invoices. We are not responsible for any mistakes that come from sending an invoice with bad data on it." + }, + "logoUploadedToast": "PDF logo uploaded.", + "logoConfirmClear": "Remove the PDF logo? The renderer will fall back to the Branding logo if one is set.", + "logoClearedToast": "PDF logo removed.", + "bankUpdatedToast": "Bank account updated." }, "crmSettings": { "savedToast": "CRM settings saved.", @@ -5969,7 +6158,18 @@ "cancelled": "Cancelled" }, "list": { - "new": "New contract" + "new": "New contract", + "blocksLibrary": "Block library", + "searchPlaceholder": "Search by number, title or customer…", + "empty": "No contracts yet.", + "table": { + "number": "Number", + "customer": "Customer", + "title": "Title", + "issueDate": "Issued", + "status": "Status" + }, + "pagination": "Page {{page}} of {{total}} · {{count}} contracts" }, "detail": { "previewPdf": "Preview PDF", @@ -6096,6 +6296,44 @@ "titleNew": "New contract", "titlePlaceholder": "e.g. Wedding contract Doe / Müller", "validUntil": "Sign by (optional)" + }, + "blocks": { + "back": "Back to contracts", + "title": "Contract block library", + "hideInactive": "Hide inactive", + "disclaimerTitle": "Examples only — have your lawyer review", + "disclaimerBody": "The seeded \"System\" blocks are written by the picpeak maintainer, not by a lawyer. They are intended as starting points only — review and adapt every block you intend to send with your own lawyer. Edits to system blocks are persisted; replace the seeded body text with the lawyer-reviewed version in place.", + "sidebarHeading": "Blocks", + "new": "New block", + "systemBadge": "System", + "empty": "No blocks yet.", + "selectPrompt": "Select a block on the left or create a new one to start editing.", + "active": "Active", + "inactive": "Inactive", + "delete": "Delete", + "deleteConfirm": "Delete this block?", + "save": "Save", + "noTranslation": "No translation yet", + "createdToast": "Block created.", + "createError": "Create failed", + "updatedToast": "Block updated.", + "updateError": "Update failed", + "deletedToast": "Block deleted.", + "deleteError": "Delete failed", + "dialog": { + "createTitle": "New block", + "editTitle": "Edit block", + "name": "Name", + "section": "Section", + "description": "Description (admin hint)", + "body": "Body", + "placeholderHint": "You can use {{customer_name}}, {{event_name}}, {{event_date}}, {{net_days}}, {{skonto_percent}}, {{skonto_within_days}}, {{cancellation_30d_percent}}, {{currency}}, {{issuer_company_name}}, {{issuer_address}}, {{contract_number}}, {{source_quote_number}} as placeholders — substituted when the contract is rendered." + }, + "quoteLineItems": { + "calloutTitle": "Auto-generated table follows the body", + "calloutBody": "When this block is included in a contract that was created from a quote, the PDF inserts a real table of the source quote's line items (#, Description, Qty, Unit, Total) immediately after the body text above. Sub-items render indented with a ↳ marker. Contracts without a source quote skip the table and render only the body.", + "previewExample": "Example rendered output:" + } } }, "messages": { diff --git a/frontend/src/pages/admin/WebhookDeliveriesPage.tsx b/frontend/src/pages/admin/WebhookDeliveriesPage.tsx index 02384bca..27e163b5 100644 --- a/frontend/src/pages/admin/WebhookDeliveriesPage.tsx +++ b/frontend/src/pages/admin/WebhookDeliveriesPage.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react'; import { Button, Card, Loading } from '../../components/common'; @@ -64,6 +65,7 @@ function statusBadge(status: string) { export const WebhookDeliveriesPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const webhookId = parseInt(id || '', 10); + const { t } = useTranslation(); const { formatDateTime: fmtDateTime } = useLocalizedDate(); const [filter, setFilter] = useState('all'); @@ -111,15 +113,15 @@ export const WebhookDeliveriesPage: React.FC = () => { mutationFn: async (deliveryId: number) => api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`), invalidateKeys: [['admin-webhook-deliveries', webhookId]], - successMessage: 'Replay enqueued', - errorMessage: () => 'Failed to replay', + successMessage: t('settings.webhooks.deliveries.replayEnqueued', 'Replay enqueued'), + errorMessage: () => t('settings.webhooks.deliveries.replayError', 'Failed to replay'), }); const testMutation = useMutationWithToast({ mutationFn: async () => api.post(`/admin/webhooks/${webhookId}/test`, { event_type: testEventType }), invalidateKeys: [['admin-webhook-deliveries', webhookId]], - successMessage: 'Test event enqueued', - errorMessage: 'Failed to send test', + successMessage: t('settings.webhooks.deliveries.testEnqueued', 'Test event enqueued'), + errorMessage: t('settings.webhooks.deliveries.testError', 'Failed to send test'), onSuccess: () => { testDialog.close(); }, @@ -136,8 +138,12 @@ export const WebhookDeliveriesPage: React.FC = () => { if (!webhook) { return (
-

Webhook not found.

- ← Back to settings +

+ {t('settings.webhooks.deliveries.notFound', 'Webhook not found.')} +

+ + ← {t('settings.webhooks.deliveries.back', 'Back to Settings')} +
); } @@ -154,7 +160,7 @@ export const WebhookDeliveriesPage: React.FC = () => { className="inline-flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 mb-2" > - Back to Settings + {t('settings.webhooks.deliveries.back', 'Back to Settings')}

{webhook.name}

{webhook.url}

@@ -173,7 +179,7 @@ export const WebhookDeliveriesPage: React.FC = () => { leftIcon={} onClick={() => testDialog.open()} > - Send test event + {t('settings.webhooks.deliveries.sendTest', 'Send test event')} @@ -198,29 +204,34 @@ export const WebhookDeliveriesPage: React.FC = () => { : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200' }`} > - {s} + {t(`settings.webhooks.deliveries.status.${s}`, s)} ))} - {total} total + + {t('settings.webhooks.deliveries.total', '{{count}} total', { count: total })} + {deliveriesQuery.isLoading ? ( ) : deliveries.length === 0 ? (

- No deliveries yet. Create an event or send a test event to see something here. + {t( + 'settings.webhooks.deliveries.empty', + 'No deliveries yet. Create an event or send a test event to see something here.' + )}

) : (
- - - - - - + + + + + + @@ -240,7 +251,7 @@ export const WebhookDeliveriesPage: React.FC = () => { {d.status === 'success' && } {d.status === 'pending' && } {d.status === 'failed' && } - {d.status} + {t(`settings.webhooks.deliveries.status.${d.status}`, d.status)} @@ -257,7 +268,7 @@ export const WebhookDeliveriesPage: React.FC = () => { replayMutation.mutate(d.id); }} > - Replay + {t('settings.webhooks.deliveries.replay', 'Replay')} )} @@ -279,9 +290,9 @@ export const WebhookDeliveriesPage: React.FC = () => {

- Delivery #{openDeliveryId} + {t('settings.webhooks.deliveries.detailTitle', 'Delivery #{{id}}', { id: openDeliveryId })}

-
@@ -291,18 +302,24 @@ export const WebhookDeliveriesPage: React.FC = () => { ) : (
- Event type + + {t('settings.webhooks.deliveries.eventType', 'Event type')} + {detailQuery.data.event_type}
- Status + + {t('settings.webhooks.deliveries.colStatus', 'Status')} + - {detailQuery.data.status} + {t(`settings.webhooks.deliveries.status.${detailQuery.data.status}`, detailQuery.data.status)}
{detailQuery.data.last_error && (
- Last error + + {t('settings.webhooks.deliveries.lastError', 'Last error')} +
                       {detailQuery.data.last_error}
                     
@@ -310,20 +327,26 @@ export const WebhookDeliveriesPage: React.FC = () => { )} {detailQuery.data.response_status != null && (
- Response status + + {t('settings.webhooks.deliveries.responseStatus', 'Response status')} + {detailQuery.data.response_status}
)} {detailQuery.data.response_body && (
- Response body (truncated to 1KB) + + {t('settings.webhooks.deliveries.responseBody', 'Response body (truncated to 1KB)')} +
                       {detailQuery.data.response_body}
                     
)}
- Payload (signed body) + + {t('settings.webhooks.deliveries.payload', 'Payload (signed body)')} +
                     {JSON.stringify(detailQuery.data.payload, null, 2)}
                   
@@ -338,11 +361,18 @@ export const WebhookDeliveriesPage: React.FC = () => { {testDialog.isOpen && (
testDialog.close()}>
e.stopPropagation()}> -

Send test event

+

+ {t('settings.webhooks.deliveries.sendTest', 'Send test event')} +

- Fires a synthetic delivery to your receiver with a stub payload, no actual side effects. + {t( + 'settings.webhooks.deliveries.sendTestHelp', + 'Fires a synthetic delivery to your receiver with a stub payload, no actual side effects.' + )}

- +
- +
diff --git a/frontend/src/pages/admin/projects/ProjectsListPage.tsx b/frontend/src/pages/admin/projects/ProjectsListPage.tsx index 4fdf415d..0cab2bb8 100644 --- a/frontend/src/pages/admin/projects/ProjectsListPage.tsx +++ b/frontend/src/pages/admin/projects/ProjectsListPage.tsx @@ -139,7 +139,7 @@ export const ProjectsListPage: React.FC = () => {
TimeEventStatusAttemptsHTTPLatency{t('settings.webhooks.deliveries.colTime', 'Time')}{t('settings.webhooks.deliveries.colEvent', 'Event')}{t('settings.webhooks.deliveries.colStatus', 'Status')}{t('settings.webhooks.deliveries.colAttempts', 'Attempts')}{t('settings.webhooks.deliveries.colHttp', 'HTTP')}{t('settings.webhooks.deliveries.colLatency', 'Latency')}
{d.attempt_count}{formatValuation(p)} - {p.status} + {t(`projects.status.${p.status}`, p.status)} {p.updatedAt ? format(p.updatedAt) : '—'}