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.
This commit is contained in:
@@ -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<BulkArchiveModalProps> = ({
|
||||
selectedEvents,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const count = selectedEvents.length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Confirm Bulk Archive</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('events.bulkArchive.title', 'Confirm Bulk Archive')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
disabled={isLoading}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
@@ -40,21 +48,22 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-neutral-700">
|
||||
<p className="mb-2">
|
||||
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>.
|
||||
This action will:
|
||||
{t('events.bulkArchive.intro', 'You are about to archive {{count}} events. This action will:', { count })}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-neutral-600">
|
||||
<li>Create a ZIP archive of all photos for each event</li>
|
||||
<li>Make the galleries inaccessible to guests</li>
|
||||
<li>Remove the events from active listings</li>
|
||||
<li>Free up storage space by compressing photos</li>
|
||||
<li>{t('events.bulkArchive.effectZip', 'Create a ZIP archive of all photos for each event')}</li>
|
||||
<li>{t('events.bulkArchive.effectInaccessible', 'Make the galleries inaccessible to guests')}</li>
|
||||
<li>{t('events.bulkArchive.effectDelisted', 'Remove the events from active listings')}</li>
|
||||
<li>{t('events.bulkArchive.effectStorage', 'Free up storage space by compressing photos')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.bulkArchive.listHeading', 'Events to be archived:')}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{selectedEvents.map((event) => (
|
||||
<li key={event.id} className="text-sm text-neutral-600">
|
||||
@@ -72,7 +81,7 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -80,7 +89,7 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
|
||||
isLoading={isLoading}
|
||||
leftIcon={<Archive className="w-4 h-4" />}
|
||||
>
|
||||
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''}
|
||||
{t('events.bulkArchive.submit', 'Archive {{count}} events', { count })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
|
||||
const { t } = useTranslation();
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('edit');
|
||||
@@ -91,7 +93,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ 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<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}>
|
||||
<Edit3 className="w-4 h-4 inline-block mr-1" />
|
||||
Edit
|
||||
{t('cms.editor.viewEdit', 'Edit')}
|
||||
</button>
|
||||
<button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}>
|
||||
<Eye className="w-4 h-4 inline-block mr-1" />
|
||||
Preview
|
||||
{t('cms.editor.viewPreview', 'Preview')}
|
||||
</button>
|
||||
<button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}>
|
||||
<Columns className="w-4 h-4 inline-block mr-1" />
|
||||
Split
|
||||
{t('cms.editor.viewSplit', 'Split')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -213,20 +215,22 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
isLoading={isSaving}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save
|
||||
{t('cms.editor.save', 'Save')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowHelp(true)}
|
||||
title="Help & Keyboard Shortcuts"
|
||||
title={t('cms.editor.help', 'Help & Keyboard Shortcuts')}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
title={isFullscreen
|
||||
? t('cms.editor.exitFullscreen', 'Exit Fullscreen')
|
||||
: t('cms.editor.enterFullscreen', 'Enter Fullscreen')}
|
||||
active={isFullscreen}
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
@@ -240,7 +244,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -248,7 +252,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -256,7 +260,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -264,7 +268,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Heading4 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -272,7 +276,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Heading5 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -280,7 +284,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Heading6 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -290,7 +294,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
title={t('cms.editor.tool.bold', "Bold (Ctrl+B)")}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -298,7 +302,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
title={t('cms.editor.tool.italic', "Italic (Ctrl+I)")}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -306,7 +310,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline Code (Ctrl+E)"
|
||||
title={t('cms.editor.tool.inlineCode', "Inline Code (Ctrl+E)")}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -314,7 +318,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -324,7 +328,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -332,7 +336,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => 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)")}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -340,7 +344,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Blockquote (Ctrl+Shift+B)"
|
||||
title={t('cms.editor.tool.blockquote', "Blockquote (Ctrl+Shift+B)")}
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -350,14 +354,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title="Add Link (Ctrl+K)"
|
||||
title={t('cms.editor.tool.addLink', "Add Link (Ctrl+K)")}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
title={t('cms.editor.tool.horizontalRule', "Horizontal Rule")}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -367,7 +371,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
active={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
title={t('cms.editor.tool.alignLeft', "Align Left")}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -375,7 +379,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
active={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
title={t('cms.editor.tool.alignCenter', "Align Center")}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -383,7 +387,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
active={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
title={t('cms.editor.tool.alignRight', "Align Right")}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -391,7 +395,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
active={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Justify"
|
||||
title={t('cms.editor.tool.justify', "Justify")}
|
||||
>
|
||||
<AlignJustify className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -400,7 +404,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
title={t('cms.editor.tool.clearFormatting', "Clear Formatting")}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -410,7 +414,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
title={t('cms.editor.tool.undo', "Undo (Ctrl+Z)")}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -418,7 +422,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Y)"
|
||||
title={t('cms.editor.tool.redo', "Redo (Ctrl+Y)")}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
@@ -434,16 +438,16 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ 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
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
<Button size="sm" onClick={addLink}>{t('cms.editor.addLink', 'Add Link')}</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setShowLinkDialog(false);
|
||||
setLinkUrl('');
|
||||
}}>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -475,11 +479,11 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 dark:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-300">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{wordCount} words</span>
|
||||
<span>{charCount} characters</span>
|
||||
<span>{t('cms.editor.wordCount', '{{count}} words', { count: wordCount })}</span>
|
||||
<span>{t('cms.editor.charCount', '{{count}} characters', { count: charCount })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Press Shift+Enter for line break, Enter for new paragraph
|
||||
{t('cms.editor.lineBreakHint', 'Press Shift+Enter for line break, Enter for new paragraph')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,68 +493,70 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{t('cms.editor.helpTitle', 'Editor Help & Keyboard Shortcuts')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Formatting</h3>
|
||||
<h3 className="font-semibold mb-2">{t('cms.editor.helpFormatting', 'Text Formatting')}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+B</kbd> - Bold</div>
|
||||
<div><kbd>Ctrl+I</kbd> - Italic</div>
|
||||
<div><kbd>Ctrl+E</kbd> - Inline code</div>
|
||||
<div><kbd>Ctrl+K</kbd> - Add link</div>
|
||||
<div><kbd>Ctrl+B</kbd> - {t('cms.editor.tool.boldShort', 'Bold')}</div>
|
||||
<div><kbd>Ctrl+I</kbd> - {t('cms.editor.tool.italicShort', 'Italic')}</div>
|
||||
<div><kbd>Ctrl+E</kbd> - {t('cms.editor.tool.inlineCodeShort', 'Inline code')}</div>
|
||||
<div><kbd>Ctrl+K</kbd> - {t('cms.editor.tool.addLinkShort', 'Add link')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Headings</h3>
|
||||
<h3 className="font-semibold mb-2">{t('cms.editor.helpHeadings', 'Headings')}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
|
||||
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
|
||||
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
|
||||
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
|
||||
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
|
||||
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
|
||||
<div><kbd>Ctrl+Alt+1</kbd> - {t('cms.editor.tool.heading1Short', 'Heading 1')}</div>
|
||||
<div><kbd>Ctrl+Alt+2</kbd> - {t('cms.editor.tool.heading2Short', 'Heading 2')}</div>
|
||||
<div><kbd>Ctrl+Alt+3</kbd> - {t('cms.editor.tool.heading3Short', 'Heading 3')}</div>
|
||||
<div><kbd>Ctrl+Alt+4</kbd> - {t('cms.editor.tool.heading4Short', 'Heading 4')}</div>
|
||||
<div><kbd>Ctrl+Alt+5</kbd> - {t('cms.editor.tool.heading5Short', 'Heading 5')}</div>
|
||||
<div><kbd>Ctrl+Alt+6</kbd> - {t('cms.editor.tool.heading6Short', 'Heading 6')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
|
||||
<h3 className="font-semibold mb-2">{t('cms.editor.helpLists', 'Lists & Blocks')}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
|
||||
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
|
||||
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
|
||||
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
|
||||
<div><kbd>Ctrl+Shift+8</kbd> - {t('cms.editor.tool.bulletListShort', 'Bullet list')}</div>
|
||||
<div><kbd>Ctrl+Shift+9</kbd> - {t('cms.editor.tool.numberedListShort', 'Numbered list')}</div>
|
||||
<div><kbd>Ctrl+Shift+B</kbd> - {t('cms.editor.tool.blockquoteShort', 'Blockquote')}</div>
|
||||
<div><kbd>Ctrl+Alt+C</kbd> - {t('cms.editor.tool.codeBlockShort', 'Code block')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Alignment</h3>
|
||||
<h3 className="font-semibold mb-2">{t('cms.editor.helpAlignment', 'Text Alignment')}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>Click alignment buttons in toolbar</div>
|
||||
<div>Works on paragraphs and headings</div>
|
||||
<div>{t('cms.editor.helpAlignmentClick', 'Click alignment buttons in toolbar')}</div>
|
||||
<div>{t('cms.editor.helpAlignmentScope', 'Works on paragraphs and headings')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Line Breaks</h3>
|
||||
<h3 className="font-semibold mb-2">{t('cms.editor.helpLineBreaks', 'Line Breaks')}</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><kbd>Enter</kbd> - New paragraph</div>
|
||||
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
|
||||
<div><kbd>Enter</kbd> - {t('cms.editor.helpNewParagraph', 'New paragraph')}</div>
|
||||
<div><kbd>Shift+Enter</kbd> - {t('cms.editor.helpLineBreak', 'Line break (preserves formatting)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Navigation</h3>
|
||||
<h3 className="font-semibold mb-2">{t('cms.editor.helpNavigation', 'Navigation')}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Z</kbd> - Undo</div>
|
||||
<div><kbd>Ctrl+Y</kbd> - Redo</div>
|
||||
<div><kbd>Ctrl+Z</kbd> - {t('cms.editor.tool.undoShort', 'Undo')}</div>
|
||||
<div><kbd>Ctrl+Y</kbd> - {t('cms.editor.tool.redoShort', 'Redo')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={() => setShowHelp(false)}>Close</Button>
|
||||
<Button onClick={() => setShowHelp(false)}>{t('common.close', 'Close')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">1-100, higher = better quality</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.imageSecurity.imageQualityHelp', '1-100, higher = better quality')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">1-10, higher = more protection</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.imageSecurity.fragmentationLevelHelp', '1-10, higher = more protection')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Violations before flagging as suspicious</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.imageSecurity.suspiciousActivityThresholdHelp', 'Violations before flagging as suspicious')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Violations before auto-blocking IP</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.imageSecurity.autoBlockThresholdHelp', 'Violations before auto-blocking IP')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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": "[email protected]",
|
||||
"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": {
|
||||
|
||||
@@ -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": "[email protected]",
|
||||
"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": {
|
||||
|
||||
@@ -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<StatusFilter>('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 (
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">Webhook not found.</p>
|
||||
<Link to="/admin/settings" className="text-accent hover:underline">← Back to settings</Link>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.webhooks.deliveries.notFound', 'Webhook not found.')}
|
||||
</p>
|
||||
<Link to="/admin/settings" className="text-accent hover:underline">
|
||||
← {t('settings.webhooks.deliveries.back', 'Back to Settings')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Settings
|
||||
{t('settings.webhooks.deliveries.back', 'Back to Settings')}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{webhook.name}</h1>
|
||||
<p className="text-sm font-mono text-neutral-500 dark:text-neutral-400 mt-1 break-all">{webhook.url}</p>
|
||||
@@ -173,7 +179,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
onClick={() => testDialog.open()}
|
||||
>
|
||||
Send test event
|
||||
{t('settings.webhooks.deliveries.sendTest', 'Send test event')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -181,7 +187,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
onClick={() => deliveriesQuery.refetch()}
|
||||
>
|
||||
Refresh
|
||||
{t('settings.webhooks.deliveries.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-neutral-500">{total} total</span>
|
||||
<span className="ml-auto text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.total', '{{count}} total', { count: total })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{deliveriesQuery.isLoading ? (
|
||||
<Loading size="md" />
|
||||
) : deliveries.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
|
||||
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.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th className="py-2 pr-3">Time</th>
|
||||
<th className="py-2 pr-3">Event</th>
|
||||
<th className="py-2 pr-3">Status</th>
|
||||
<th className="py-2 pr-3">Attempts</th>
|
||||
<th className="py-2 pr-3">HTTP</th>
|
||||
<th className="py-2 pr-3">Latency</th>
|
||||
<th className="py-2 pr-3">{t('settings.webhooks.deliveries.colTime', 'Time')}</th>
|
||||
<th className="py-2 pr-3">{t('settings.webhooks.deliveries.colEvent', 'Event')}</th>
|
||||
<th className="py-2 pr-3">{t('settings.webhooks.deliveries.colStatus', 'Status')}</th>
|
||||
<th className="py-2 pr-3">{t('settings.webhooks.deliveries.colAttempts', 'Attempts')}</th>
|
||||
<th className="py-2 pr-3">{t('settings.webhooks.deliveries.colHttp', 'HTTP')}</th>
|
||||
<th className="py-2 pr-3">{t('settings.webhooks.deliveries.colLatency', 'Latency')}</th>
|
||||
<th className="py-2 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -240,7 +251,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
{d.status === 'success' && <CheckCircle2 className="w-3 h-3 inline mr-1" />}
|
||||
{d.status === 'pending' && <Clock className="w-3 h-3 inline mr-1" />}
|
||||
{d.status === 'failed' && <AlertCircle className="w-3 h-3 inline mr-1" />}
|
||||
{d.status}
|
||||
{t(`settings.webhooks.deliveries.status.${d.status}`, d.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs">{d.attempt_count}</td>
|
||||
@@ -257,7 +268,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
replayMutation.mutate(d.id);
|
||||
}}
|
||||
>
|
||||
Replay
|
||||
{t('settings.webhooks.deliveries.replay', 'Replay')}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
@@ -279,9 +290,9 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
<div className="relative ml-auto w-full max-w-2xl h-full bg-white dark:bg-neutral-900 shadow-xl overflow-y-auto p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Delivery #{openDeliveryId}
|
||||
{t('settings.webhooks.deliveries.detailTitle', 'Delivery #{{id}}', { id: openDeliveryId })}
|
||||
</h2>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpenDeliveryId(null)}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpenDeliveryId(null)} aria-label={t('common.close', 'Close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -291,18 +302,24 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<span className="block text-xs text-neutral-500">Event type</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.eventType', 'Event type')}
|
||||
</span>
|
||||
<code className="text-sm">{detailQuery.data.event_type}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-neutral-500">Status</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.colStatus', 'Status')}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${statusBadge(detailQuery.data.status)}`}>
|
||||
{detailQuery.data.status}
|
||||
{t(`settings.webhooks.deliveries.status.${detailQuery.data.status}`, detailQuery.data.status)}
|
||||
</span>
|
||||
</div>
|
||||
{detailQuery.data.last_error && (
|
||||
<div>
|
||||
<span className="block text-xs text-neutral-500">Last error</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.lastError', 'Last error')}
|
||||
</span>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded p-2">
|
||||
{detailQuery.data.last_error}
|
||||
</pre>
|
||||
@@ -310,20 +327,26 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
)}
|
||||
{detailQuery.data.response_status != null && (
|
||||
<div>
|
||||
<span className="block text-xs text-neutral-500">Response status</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.responseStatus', 'Response status')}
|
||||
</span>
|
||||
<code className="text-sm">{detailQuery.data.response_status}</code>
|
||||
</div>
|
||||
)}
|
||||
{detailQuery.data.response_body && (
|
||||
<div>
|
||||
<span className="block text-xs text-neutral-500">Response body (truncated to 1KB)</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.responseBody', 'Response body (truncated to 1KB)')}
|
||||
</span>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words bg-neutral-50 dark:bg-neutral-800 rounded p-2 max-h-40 overflow-y-auto">
|
||||
{detailQuery.data.response_body}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="block text-xs text-neutral-500">Payload (signed body)</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t('settings.webhooks.deliveries.payload', 'Payload (signed body)')}
|
||||
</span>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words bg-neutral-50 dark:bg-neutral-800 rounded p-2 max-h-80 overflow-y-auto">
|
||||
{JSON.stringify(detailQuery.data.payload, null, 2)}
|
||||
</pre>
|
||||
@@ -338,11 +361,18 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
{testDialog.isOpen && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => testDialog.close()}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Send test event</h2>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('settings.webhooks.deliveries.sendTest', 'Send test event')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
|
||||
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.'
|
||||
)}
|
||||
</p>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Event type</label>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.webhooks.deliveries.eventType', 'Event type')}
|
||||
</label>
|
||||
<select
|
||||
value={testEventType}
|
||||
onChange={(e) => setTestEventType(e.target.value)}
|
||||
@@ -351,9 +381,9 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
{WEBHOOK_EVENT_TYPES.map((e) => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => testDialog.close()}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={() => testDialog.close()}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
|
||||
Send
|
||||
{t('settings.webhooks.deliveries.send', 'Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +139,7 @@ export const ProjectsListPage: React.FC = () => {
|
||||
<td className="px-4 py-2 text-right tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{formatValuation(p)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200">
|
||||
{p.status}
|
||||
{t(`projects.status.${p.status}`, p.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400">{p.updatedAt ? format(p.updatedAt) : '—'}</td>
|
||||
|
||||
Reference in New Issue
Block a user