feat(i18n): update translations and improve error handling messages

- Updated site tagline and footer description in multiple languages.
- Enhanced error messages for various scenarios in the API service.
- Added translations for new error codes related to AI features and PDF processing.
- Improved user feedback in the UI components by utilizing i18n for dynamic text.
- Refactored error handling in the API service to map backend error codes to user-friendly messages.
This commit is contained in:
Your Name
2026-04-05 10:12:22 +02:00
parent 8693834230
commit ade7abac46
14 changed files with 607 additions and 88 deletions

View File

@@ -12,7 +12,9 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs" "seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs",
"lint:i18n": "node scripts/check-i18n-keys.mjs",
"lint:hardcoded": "node scripts/check-hardcoded-text.mjs"
}, },
"dependencies": { "dependencies": {
"@microsoft/clarity": "^1.0.2", "@microsoft/clarity": "^1.0.2",

View File

@@ -0,0 +1,127 @@
/**
* check-hardcoded-text.mjs
* Scans shared/layout components for hardcoded English UI strings that should
* be replaced with t() calls.
*
* Heuristic: a JSX text node is flagged when it:
* - contains at least one space (multi-word)
* - is longer than 3 characters
* - starts with an uppercase letter or common English word
* - is NOT already wrapped in {t(...)}
* - is NOT a CSS class, URL, comment, code attribute, or aria-label value
*
* Usage: node scripts/check-hardcoded-text.mjs
* Exit code 1 if any potential hardcoded strings are found.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = join(__dirname, '..');
/** Directories to scan */
const SCAN_DIRS = [
join(ROOT, 'src', 'components', 'shared'),
join(ROOT, 'src', 'components', 'layout'),
];
// Collect .tsx files
function* walkFiles(dir) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
yield* walkFiles(full);
} else if (/\.tsx$/.test(entry)) {
yield full;
}
}
}
/**
* Patterns that indicate the string is NOT a hardcoded UI label:
* - Only digits/punctuation
* - Looks like a URL, path, class name, CSS value
* - Already inside {t(…)}
* - Attribute values like className, href, src, id, type, etc.
*/
const SKIP_RE = [
/^[\d\s.,/:%-]+$/, // pure numbers/punctuation
/^https?:\/\//, // URLs
/^\/[a-z]/, // paths
/^[a-z][-a-z0-9]*$/, // single lowercase word (CSS class, attr value)
/^[a-z][a-zA-Z0-9]*=[a-z]/, // key=value attrs
];
function shouldSkip(str) {
const trimmed = str.trim();
if (trimmed.length <= 3) return true;
if (!/\s/.test(trimmed)) return true; // single word
if (!/[A-Z]/.test(trimmed[0])) return true; // doesn't start with uppercase
for (const re of SKIP_RE) if (re.test(trimmed)) return true;
return false;
}
/**
* Find JSX text content that is hardcoded English.
* Strategy: look for lines where text appears between JSX tags but is NOT
* wrapped in a {…} expression.
*
* Pattern: > Some Text Here < (with optional leading whitespace)
*/
const JSX_TEXT_RE = />\s*([A-Z][^<{}\n]{3,}?)\s*</g;
/**
* Also catch string literals used directly as prop values that look like
* display text: title="Some English Text" (but not className, id, etc.)
*/
const DISPLAY_PROP_RE = /(?:title|label|placeholder|aria-label|alt)="([^"]{4,})"/g;
const findings = [];
for (const dir of SCAN_DIRS) {
for (const file of walkFiles(dir)) {
const rel = relative(ROOT, file).replace(/\\/g, '/');
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (const [lineIdx, line] of lines.entries()) {
// Skip comment lines
if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) continue;
// Skip lines that are already pure t() calls
if (/\{t\(/.test(line)) continue;
// JSX text between tags
let m;
JSX_TEXT_RE.lastIndex = 0;
while ((m = JSX_TEXT_RE.exec(line)) !== null) {
const text = m[1].trim();
if (!shouldSkip(text)) {
findings.push({ file: rel, line: lineIdx + 1, text });
}
}
// Display props with raw English strings
DISPLAY_PROP_RE.lastIndex = 0;
while ((m = DISPLAY_PROP_RE.exec(line)) !== null) {
const text = m[1].trim();
if (!shouldSkip(text)) {
findings.push({ file: rel, line: lineIdx + 1, text: `[prop] ${text}` });
}
}
}
}
}
if (findings.length === 0) {
console.log('✓ No hardcoded UI text found in shared/layout components');
process.exit(0);
} else {
console.warn(`⚠ Found ${findings.length} potential hardcoded string(s):\n`);
for (const { file, line, text } of findings) {
console.warn(` ${file}:${line} → "${text}"`);
}
// Exit 1 to allow failing CI; change to process.exit(0) to make it advisory only
process.exit(1);
}

View File

@@ -0,0 +1,83 @@
/**
* check-i18n-keys.mjs
* Scans all .ts/.tsx files in src/ and verifies every static t('key') call
* exists as a dot-path entry in src/i18n/en.json.
*
* Usage: node scripts/check-i18n-keys.mjs
* Exit code 1 if any missing keys are found.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = join(__dirname, '..');
const SRC = join(ROOT, 'src');
const EN_JSON = join(ROOT, 'src', 'i18n', 'en.json');
// Load en.json and build a flat Set of all dot-paths
function flattenKeys(obj, prefix = '') {
const keys = new Set();
for (const [k, v] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
for (const nested of flattenKeys(v, path)) keys.add(nested);
} else {
keys.add(path);
}
}
return keys;
}
const enJson = JSON.parse(readFileSync(EN_JSON, 'utf8'));
const definedKeys = flattenKeys(enJson);
// Collect all .ts/.tsx files under src/
function* walkFiles(dir) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
yield* walkFiles(full);
} else if (/\.(tsx?|jsx?)$/.test(entry) && !entry.endsWith('.d.ts')) {
yield full;
}
}
}
// Extract static string arguments from t('...') or t("...") calls.
// Matches: t('key'), t("key"), t(`key`), useTranslation().t('key'),
// as well as i18n.t('key') patterns.
const T_CALL_RE = /\bt\(\s*(['"`])([^'"`\s]+)\1/g;
const missing = [];
for (const file of walkFiles(SRC)) {
const rel = relative(ROOT, file).replace(/\\/g, '/');
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (const [lineIdx, line] of lines.entries()) {
let match;
T_CALL_RE.lastIndex = 0;
while ((match = T_CALL_RE.exec(line)) !== null) {
const key = match[2];
// Skip dynamic keys (contain ${) or non-string patterns
if (key.includes('${') || key.includes('(')) continue;
if (!definedKeys.has(key)) {
missing.push({ file: rel, line: lineIdx + 1, key });
}
}
}
}
if (missing.length === 0) {
console.log('✓ All t() keys are present in en.json');
process.exit(0);
} else {
console.error(`✗ Found ${missing.length} missing i18n key(s):\n`);
for (const { file, line, key } of missing) {
console.error(` ${file}:${line} → "${key}"`);
}
process.exit(1);
}

View File

@@ -2,49 +2,64 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowRight, FileText, Layers3 } from 'lucide-react'; import { ArrowRight, FileText, Layers3 } from 'lucide-react';
const FOOTER_TOOLS = { interface FooterTool {
slug: string;
i18nKey: string;
isLanding?: boolean;
isComparison?: boolean;
}
const FOOTER_TOOLS: Record<string, FooterTool[]> = {
PDF: [ PDF: [
{ slug: 'pdf-to-word', label: 'PDF to Word' }, { slug: 'pdf-to-word', i18nKey: 'tools.pdfToWord.title' },
{ slug: 'compress-pdf', label: 'Compress PDF' }, { slug: 'compress-pdf', i18nKey: 'tools.compressPdf.title' },
{ slug: 'merge-pdf', label: 'Merge PDF' }, { slug: 'merge-pdf', i18nKey: 'tools.mergePdf.title' },
{ slug: 'split-pdf', label: 'Split PDF' }, { slug: 'split-pdf', i18nKey: 'tools.splitPdf.title' },
{ slug: 'pdf-to-images', label: 'PDF to Images' }, { slug: 'pdf-to-images', i18nKey: 'tools.pdfToImages.title' },
{ slug: 'protect-pdf', label: 'Protect PDF' }, { slug: 'protect-pdf', i18nKey: 'tools.protectPdf.title' },
{ slug: 'watermark-pdf', label: 'Watermark PDF' }, { slug: 'watermark-pdf', i18nKey: 'tools.watermarkPdf.title' },
{ slug: 'pdf-editor', label: 'PDF Editor' }, { slug: 'pdf-editor', i18nKey: 'tools.pdfEditor.title' },
], ],
'Image & Convert': [ 'Image & Convert': [
{ slug: 'compress-image', label: 'Compress Image' }, { slug: 'compress-image', i18nKey: 'tools.compressImage.title' },
{ slug: 'image-converter', label: 'Image Converter' }, { slug: 'image-converter', i18nKey: 'tools.imageConvert.title' },
{ slug: 'image-resize', label: 'Image Resize' }, { slug: 'image-resize', i18nKey: 'tools.imageResize.title' },
{ slug: 'remove-background', label: 'Remove Background' }, { slug: 'remove-background', i18nKey: 'tools.removeBg.title' },
{ slug: 'word-to-pdf', label: 'Word to PDF' }, { slug: 'word-to-pdf', i18nKey: 'tools.wordToPdf.title' },
{ slug: 'html-to-pdf', label: 'HTML to PDF' }, { slug: 'html-to-pdf', i18nKey: 'tools.htmlToPdf.title' },
{ slug: 'pdf-to-excel', label: 'PDF to Excel' }, { slug: 'pdf-to-excel', i18nKey: 'tools.pdfToExcel.title' },
], ],
'AI & Utility': [ 'AI & Utility': [
{ slug: 'chat-pdf', label: 'Chat with PDF' }, { slug: 'chat-pdf', i18nKey: 'tools.chatPdf.title' },
{ slug: 'summarize-pdf', label: 'Summarize PDF' }, { slug: 'summarize-pdf', i18nKey: 'tools.summarizePdf.title' },
{ slug: 'translate-pdf', label: 'Translate PDF' }, { slug: 'translate-pdf', i18nKey: 'tools.translatePdf.title' },
{ slug: 'ocr', label: 'OCR' }, { slug: 'ocr', i18nKey: 'tools.ocr.title' },
{ slug: 'qr-code', label: 'QR Code Generator' }, { slug: 'qr-code', i18nKey: 'tools.qrCode.title' },
{ slug: 'video-to-gif', label: 'Video to GIF' }, { slug: 'video-to-gif', i18nKey: 'tools.videoToGif.title' },
{ slug: 'word-counter', label: 'Word Counter' }, { slug: 'word-counter', i18nKey: 'tools.wordCounter.title' },
], ],
Guides: [ Guides: [
{ slug: 'best-pdf-tools', label: 'Best PDF Tools', isLanding: true }, { slug: 'best-pdf-tools', i18nKey: 'footer.guides.bestPdfTools', isLanding: true },
{ slug: 'free-pdf-tools-online', label: 'Free PDF Tools Online', isLanding: true }, { slug: 'free-pdf-tools-online', i18nKey: 'footer.guides.freePdfToolsOnline', isLanding: true },
{ slug: 'convert-files-online', label: 'Convert Files Online', isLanding: true }, { slug: 'convert-files-online', i18nKey: 'footer.guides.convertFilesOnline', isLanding: true },
], ],
Comparisons: [ Comparisons: [
{ slug: 'compress-pdf-vs-ilovepdf', label: 'Dociva vs iLovePDF', isComparison: true }, { slug: 'compress-pdf-vs-ilovepdf', i18nKey: 'footer.comparisons.compressPdfVsIlovepdf', isComparison: true },
{ slug: 'merge-pdf-vs-smallpdf', label: 'Dociva vs Smallpdf', isComparison: true }, { slug: 'merge-pdf-vs-smallpdf', i18nKey: 'footer.comparisons.mergePdfVsSmallpdf', isComparison: true },
{ slug: 'pdf-to-word-vs-adobe-acrobat', label: 'Dociva vs Adobe Acrobat', isComparison: true }, { slug: 'pdf-to-word-vs-adobe-acrobat', i18nKey: 'footer.comparisons.pdfToWordVsAdobeAcrobat', isComparison: true },
{ slug: 'compress-image-vs-tinypng', label: 'Dociva vs TinyPNG', isComparison: true }, { slug: 'compress-image-vs-tinypng', i18nKey: 'footer.comparisons.compressImageVsTinypng', isComparison: true },
{ slug: 'ocr-vs-adobe-scan', label: 'Dociva vs Adobe Scan', isComparison: true }, { slug: 'ocr-vs-adobe-scan', i18nKey: 'footer.comparisons.ocrVsAdobeScan', isComparison: true },
], ],
}; };
const CATEGORY_KEYS: Record<string, string> = {
'PDF': 'footer.categories.pdf',
'Image & Convert': 'footer.categories.imageConvert',
'AI & Utility': 'footer.categories.aiUtility',
'Guides': 'footer.categories.guides',
'Comparisons': 'footer.categories.comparisons',
};
export default function Footer() { export default function Footer() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,16 +78,13 @@ export default function Footer() {
{t('common.appName')} {t('common.appName')}
</p> </p>
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
{t('common.siteTagline', 'Online PDF and file workflows')} {t('common.siteTagline')}
</p> </p>
</div> </div>
</div> </div>
<p className="mt-6 max-w-md text-sm leading-7 text-slate-600 dark:text-slate-300"> <p className="mt-6 max-w-md text-sm leading-7 text-slate-600 dark:text-slate-300">
{t( {t('common.footerDescription')}
'common.footerDescription',
'Convert, compress, edit, and automate document work in one browser-based workspace built for speed, clarity, and secure processing.'
)}
</p> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
@@ -96,16 +108,16 @@ export default function Footer() {
{Object.entries(FOOTER_TOOLS).map(([category, tools]) => ( {Object.entries(FOOTER_TOOLS).map(([category, tools]) => (
<div key={category}> <div key={category}>
<h3 className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-slate-500 dark:text-slate-400"> <h3 className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-slate-500 dark:text-slate-400">
{category} {t(CATEGORY_KEYS[category] ?? category)}
</h3> </h3>
<ul className="space-y-2.5"> <ul className="space-y-2.5">
{tools.map((tool) => ( {tools.map((tool) => (
<li key={tool.slug}> <li key={tool.slug}>
<Link <Link
to={(tool as { slug: string; isLanding?: boolean; isComparison?: boolean }).isComparison ? `/compare/${tool.slug}` : (tool as { slug: string; isLanding?: boolean }).isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`} to={tool.isComparison ? `/compare/${tool.slug}` : tool.isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`}
className="text-sm text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400" className="text-sm text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
> >
{tool.label} {t(tool.i18nKey)}
</Link> </Link>
</li> </li>
))} ))}

View File

@@ -110,7 +110,7 @@ export default function Header() {
{t('common.appName')} {t('common.appName')}
</span> </span>
<span className="hidden text-xs font-medium text-slate-500 dark:text-slate-400 sm:block"> <span className="hidden text-xs font-medium text-slate-500 dark:text-slate-400 sm:block">
{t('common.siteTagline', 'Online PDF and file workflows')} {t('common.siteTagline')}
</span> </span>
</div> </div>
</Link> </Link>
@@ -145,7 +145,7 @@ export default function Header() {
className="hidden items-center gap-2 rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:-translate-y-0.5 hover:bg-primary-600 lg:inline-flex dark:bg-white dark:text-slate-950 dark:hover:bg-primary-300" className="hidden items-center gap-2 rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:-translate-y-0.5 hover:bg-primary-600 lg:inline-flex dark:bg-white dark:text-slate-950 dark:hover:bg-primary-300"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
{t('home.startFree', 'Start Free')} {t('home.startFree')}
<ArrowRight className="h-3.5 w-3.5" /> <ArrowRight className="h-3.5 w-3.5" />
</Link> </Link>
) : null} ) : null}

View File

@@ -1,7 +1,8 @@
import { Component, type ReactNode } from 'react'; import { Component, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { withTranslation, type WithTranslation } from 'react-i18next';
interface Props { interface Props extends WithTranslation {
children: ReactNode; children: ReactNode;
fallbackMessage?: string; fallbackMessage?: string;
} }
@@ -10,7 +11,7 @@ interface State {
hasError: boolean; hasError: boolean;
} }
export default class ErrorBoundary extends Component<Props, State> { class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }; state: State = { hasError: false };
static getDerivedStateFromError(): State { static getDerivedStateFromError(): State {
@@ -22,6 +23,7 @@ export default class ErrorBoundary extends Component<Props, State> {
}; };
render() { render() {
const { t } = this.props;
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="mx-auto max-w-lg py-16 text-center"> <div className="mx-auto max-w-lg py-16 text-center">
@@ -29,16 +31,16 @@ export default class ErrorBoundary extends Component<Props, State> {
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" /> <AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div> </div>
<h2 className="mb-2 text-xl font-semibold text-slate-800 dark:text-slate-200"> <h2 className="mb-2 text-xl font-semibold text-slate-800 dark:text-slate-200">
{this.props.fallbackMessage || 'Something went wrong'} {this.props.fallbackMessage || t('common.errors.genericTitle')}
</h2> </h2>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400"> <p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
An unexpected error occurred. Please try again. {t('common.errors.genericDesc')}
</p> </p>
<button <button
onClick={this.handleReset} onClick={this.handleReset}
className="rounded-lg bg-primary-600 px-6 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors" className="rounded-lg bg-primary-600 px-6 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
> >
Try Again {t('common.errors.tryAgain')}
</button> </button>
</div> </div>
); );
@@ -46,3 +48,5 @@ export default class ErrorBoundary extends Component<Props, State> {
return this.props.children; return this.props.children;
} }
} }
export default withTranslation()(ErrorBoundary);

View File

@@ -172,10 +172,10 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
{isUploading ? ( {isUploading ? (
<> <>
<Clock className="h-5 w-5 animate-spin" /> <Clock className="h-5 w-5 animate-spin" />
{t('common.uploading', { defaultValue: 'Uploading...' })} {t('common.uploading')}
</> </>
) : ( ) : (
t('common.convert', { defaultValue: 'Convert' }) t('common.convert')
)} )}
</button> </button>
</div> </div>
@@ -195,8 +195,8 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" /> <CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
<div> <div>
<h2 className="font-semibold text-green-900 dark:text-green-200">Success!</h2> <h2 className="font-semibold text-green-900 dark:text-green-200">{t('result.success')}</h2>
<p className="text-sm text-green-700 dark:text-green-300">Your file is ready</p> <p className="text-sm text-green-700 dark:text-green-300">{t('result.fileReady')}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -208,15 +208,15 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" /> <AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
<div> <div>
<h2 className="font-semibold text-red-900 dark:text-red-200">Error</h2> <h2 className="font-semibold text-red-900 dark:text-red-200">{t('common.error')}</h2>
<p className="text-sm text-red-700 dark:text-red-300">{error || 'Processing failed'}</p> <p className="text-sm text-red-700 dark:text-red-300">{error || t('common.errors.processingFailed')}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
<button onClick={handleReset} className="btn-secondary w-full"> <button onClick={handleReset} className="btn-secondary w-full">
Process Another {t('result.processAnother')}
</button> </button>
</div> </div>
)} )}

View File

@@ -98,7 +98,7 @@ export default function BarcodeGenerator() {
{phase === 'done' && downloadUrl && ( {phase === 'done' && downloadUrl && (
<div className="space-y-4 text-center"> <div className="space-y-4 text-center">
<div className="rounded-2xl bg-white p-6 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"> <div className="rounded-2xl bg-white p-6 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<img src={downloadUrl} alt="Barcode" loading="lazy" decoding="async" className="mx-auto max-w-full" width="300" height="100" style={{aspectRatio:'3/1'}} /> <img src={downloadUrl} alt={t('tools.barcode.altText')} loading="lazy" decoding="async" className="mx-auto max-w-full" width="300" height="100" style={{aspectRatio:'3/1'}} />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<a href={downloadUrl} download className="btn-primary flex-1">{t('common.download')}</a> <a href={downloadUrl} download className="btn-primary flex-1">{t('common.download')}</a>

View File

@@ -143,7 +143,7 @@ export default function ImageResize() {
type="number" type="number"
min="1" min="1"
max="10000" max="10000"
placeholder="e.g. 800" placeholder={t('tools.imageResize.widthPlaceholder')}
value={width} value={width}
onChange={(e) => { onChange={(e) => {
setWidth(e.target.value); setWidth(e.target.value);
@@ -160,7 +160,7 @@ export default function ImageResize() {
type="number" type="number"
min="1" min="1"
max="10000" max="10000"
placeholder="e.g. 600" placeholder={t('tools.imageResize.heightPlaceholder')}
value={height} value={height}
onChange={(e) => { onChange={(e) => {
setHeight(e.target.value); setHeight(e.target.value);

View File

@@ -113,7 +113,7 @@ export default function QrCodeGenerator() {
{phase === 'done' && result && result.status === 'completed' && downloadUrl && ( {phase === 'done' && result && result.status === 'completed' && downloadUrl && (
<div className="space-y-6 text-center"> <div className="space-y-6 text-center">
<div className="rounded-2xl bg-white p-8 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"> <div className="rounded-2xl bg-white p-8 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<img src={downloadUrl} alt="QR Code" loading="lazy" decoding="async" className="mx-auto max-w-[300px] rounded-lg" width={size} height={size} style={{aspectRatio:'1/1'}} /> <img src={downloadUrl} alt={t('tools.qrCode.altText')} loading="lazy" decoding="async" className="mx-auto max-w-[300px] rounded-lg" width={size} height={size} style={{aspectRatio:'1/1'}} />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<a href={downloadUrl} download={result.filename || 'qrcode.png'} <a href={downloadUrl} download={result.filename || 'qrcode.png'}

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Dociva", "appName": "Dociva",
"tagline": "أدوات ملفات مجانية على الإنترنت", "tagline": "أدوات ملفات مجانية على الإنترنت",
@@ -35,6 +35,11 @@
"subject": "الموضوع", "subject": "الموضوع",
"message": "الرسالة", "message": "الرسالة",
"name": "الاسم", "name": "الاسم",
"siteTagline": "سير عمل PDF والملفات عبر الإنترنت",
"footerDescription": "حوّل، اضغط، عدّل، وأتمت عمل المستندات في مساحة عمل واحدة تعمل في المتصفح، مصممة للسرعة والوضوح والمعالجة الآمنة.",
"uploading": "جارٍ الرفع...",
"convert": "تحويل",
"sending": "جارٍ الإرسال...",
"errors": { "errors": {
"fileTooLarge": "حجم الملف كبير جدًا. الحد الأقصى المسموح {{size}} ميجابايت.", "fileTooLarge": "حجم الملف كبير جدًا. الحد الأقصى المسموح {{size}} ميجابايت.",
"invalidFileType": "نوع الملف غير صالح. الأنواع المقبولة: {{types}}", "invalidFileType": "نوع الملف غير صالح. الأنواع المقبولة: {{types}}",
@@ -44,7 +49,19 @@
"rateLimited": "طلبات كثيرة جدًا. يرجى الانتظار لحظة والمحاولة مجددًا.", "rateLimited": "طلبات كثيرة جدًا. يرجى الانتظار لحظة والمحاولة مجددًا.",
"serverError": "حدث خطأ في الخادم. يرجى المحاولة لاحقًا.", "serverError": "حدث خطأ في الخادم. يرجى المحاولة لاحقًا.",
"networkError": "خطأ في الشبكة. يرجى التحقق من اتصالك والمحاولة مرة أخرى.", "networkError": "خطأ في الشبكة. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
"noFileSelected": "لم يتم اختيار ملف. يرجى اختيار ملف للرفع." "noFileSelected": "لم يتم اختيار ملف. يرجى اختيار ملف للرفع.",
"aiUnavailable": "ميزات الذكاء الاصطناعي غير متاحة مؤقتاً. يرجى المحاولة لاحقاً.",
"aiRateLimited": "خدمة الذكاء الاصطناعي مشغولة حالياً. يرجى المحاولة بعد قليل.",
"aiBudgetExceeded": "تم استنفاد حصة معالجة الذكاء الاصطناعي. يرجى المحاولة لاحقاً.",
"pdfEncrypted": "هذا الـ PDF محمي بكلمة مرور. يرجى إلغاء قفله أولاً.",
"pdfTextEmpty": "لم يُعثر على نص قابل للقراءة في هذا الـ PDF.",
"pdfNoTables": "لم تُعثر على جداول في هذا الـ PDF.",
"taskUnavailable": "الخدمة غير متاحة مؤقتاً. يرجى إعادة المحاولة بعد لحظة.",
"translationFailed": "فشلت خدمة الترجمة. يرجى المحاولة مرة أخرى.",
"invalidInput": "مدخلات غير صالحة. يرجى التحقق من إعداداتك والمحاولة مرة أخرى.",
"genericTitle": "حدث خطأ ما",
"genericDesc": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.",
"tryAgain": "حاول مرة أخرى"
} }
}, },
"auth": { "auth": {
@@ -141,7 +158,31 @@
"feature2Title": "دقة يمكنك الوثوق بها", "feature2Title": "دقة يمكنك الوثوق بها",
"feature2Desc": "احصل على ملفات دقيقة وقابلة للتعديل في ثوانٍ بدون فقدان للجودة.", "feature2Desc": "احصل على ملفات دقيقة وقابلة للتعديل في ثوانٍ بدون فقدان للجودة.",
"feature3Title": "أمان مدمج", "feature3Title": "أمان مدمج",
"feature3Desc": "قم بالوصول إلى ملفاتك بأمان، محمية بتشفير تلقائي." "feature3Desc": "قم بالوصول إلى ملفاتك بأمان، محمية بتشفير تلقائي.",
"startFree": "ابدأ مجاناً",
"heroBadge": "سير عمل مستندات حديثة",
"statsToolsLabel": "إجمالي الأدوات",
"statsPdfLabel": "سير عمل PDF",
"statsOtherLabel": "الصور والذكاء الاصطناعي والأدوات",
"statsAccessLabel": "نموذج الوصول",
"statsAccessValue": "بدون تسجيل",
"trustSecure": "حذف تلقائي للملفات",
"trustFast": "نتائج في ثوانٍ",
"trust30Tools": "أكثر من 30 أداة مجانية",
"trustNoSignup": "لا حاجة للتسجيل",
"ctaBrowseTools": "تصفح كل الأدوات",
"quickStartLabel": "نقاط انطلاق شائعة",
"heroUploadEyebrow": "ارفع وابدأ",
"heroUploadTitle": "اختر ملفاً وانطلق مباشرة إلى الأداة المناسبة",
"howItWorksLabel": "عملية بسيطة",
"howItWorksTitle": "حوّل وعدّل في ثلاث خطوات بسيطة",
"toolsDirectoryTitle": "اعثر على الأداة المناسبة بسرعة أكبر",
"otherTools": "أدوات أخرى",
"whyChooseLabel": "لماذا Dociva",
"ctaBannerLabel": "ابدأ اليوم",
"ctaBannerTitle": "هل أنت مستعد لتحويل ملفاتك؟",
"ctaBannerSubtitle": "انضم إلى آلاف المستخدمين الذين يحوّلون ويضغطون ويعدّلون ملفاتهم يومياً — مجاناً تماماً.",
"ctaCreateAccount": "إنشاء حساب مجاني"
}, },
"socialProof": { "socialProof": {
"badge": "موثوق من فرق نشطة", "badge": "موثوق من فرق نشطة",
@@ -173,7 +214,11 @@
"أدوات المستندات بالذكاء الاصطناعي — التحدث مع PDF، التلخيص، الترجمة، استخراج الجداول", "أدوات المستندات بالذكاء الاصطناعي — التحدث مع PDF، التلخيص، الترجمة، استخراج الجداول",
"OCR — استخراج النص من الصور وملفات PDF الممسوحة ضوئياً بالعربية والإنجليزية والفرنسية", "OCR — استخراج النص من الصور وملفات PDF الممسوحة ضوئياً بالعربية والإنجليزية والفرنسية",
"أدوات مساعدة — مولد QR، تحويل فيديو إلى GIF، عداد الكلمات، منظف النصوص" "أدوات مساعدة — مولد QR، تحويل فيديو إلى GIF، عداد الكلمات، منظف النصوص"
] ],
"heroTitle": "تمكين إنتاجية المستندات في كل مكان",
"teamTitle": "فريقنا",
"valuesTitle": "قيمنا",
"ctaText": "هل لديك أسئلة؟ تواصل معنا."
}, },
"contact": { "contact": {
"metaDescription": "تواصل مع فريق Dociva. أبلغ عن خطأ أو اطلب ميزة جديدة أو أرسل لنا رسالة.", "metaDescription": "تواصل مع فريق Dociva. أبلغ عن خطأ أو اطلب ميزة جديدة أو أرسل لنا رسالة.",
@@ -194,7 +239,12 @@
"subjectPlaceholder": "الموضوع", "subjectPlaceholder": "الموضوع",
"successMessage": "تم إرسال رسالتك! سنرد عليك قريباً.", "successMessage": "تم إرسال رسالتك! سنرد عليك قريباً.",
"directEmail": "أو راسلنا مباشرة على", "directEmail": "أو راسلنا مباشرة على",
"responseTime": "نرد عادةً خلال 24-48 ساعة." "responseTime": "نرد عادةً خلال 24-48 ساعة.",
"emailLabel": "البريد الإلكتروني:",
"phoneLabel": "الهاتف:",
"officeLabel": "المكتب:",
"connectTitle": "تواصل معنا",
"faqTitle": "الأسئلة الشائعة"
}, },
"privacy": { "privacy": {
"metaDescription": "سياسة الخصوصية لـ Dociva. تعرّف على كيفية تعاملنا مع ملفاتك وبياناتك بشفافية كاملة.", "metaDescription": "سياسة الخصوصية لـ Dociva. تعرّف على كيفية تعاملنا مع ملفاتك وبياناتك بشفافية كاملة.",
@@ -643,7 +693,9 @@
"height": "الارتفاع (بكسل)", "height": "الارتفاع (بكسل)",
"quality": "الجودة", "quality": "الجودة",
"lockAspect": "قفل نسبة العرض للارتفاع", "lockAspect": "قفل نسبة العرض للارتفاع",
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع." "aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع.",
"widthPlaceholder": "مثال: 800",
"heightPlaceholder": "مثال: 600"
}, },
"imageToSvg": { "imageToSvg": {
"title": "تحويل الصورة إلى SVG", "title": "تحويل الصورة إلى SVG",
@@ -989,7 +1041,8 @@
"shortDesc": "إنشاء رمز QR", "shortDesc": "إنشاء رمز QR",
"dataLabel": "نص أو رابط", "dataLabel": "نص أو رابط",
"dataPlaceholder": "أدخل نصاً أو رابطاً أو أي بيانات...", "dataPlaceholder": "أدخل نصاً أو رابطاً أو أي بيانات...",
"sizeLabel": "الحجم" "sizeLabel": "الحجم",
"altText": "رمز QR المُولَّد"
}, },
"htmlToPdf": { "htmlToPdf": {
"title": "HTML إلى PDF", "title": "HTML إلى PDF",
@@ -1124,7 +1177,29 @@
"dataLabel": "بيانات الباركود", "dataLabel": "بيانات الباركود",
"dataPlaceholder": "أدخل البيانات للترميز...", "dataPlaceholder": "أدخل البيانات للترميز...",
"typeLabel": "نوع الباركود", "typeLabel": "نوع الباركود",
"formatLabel": "تنسيق الإخراج" "formatLabel": "تنسيق الإخراج",
"altText": "باركود مُنشأ"
}
},
"footer": {
"categories": {
"pdf": "PDF",
"imageConvert": "الصور والتحويل",
"aiUtility": "الذكاء الاصطناعي والأدوات",
"guides": "الأدلة",
"comparisons": "المقارنات"
},
"guides": {
"bestPdfTools": "أفضل أدوات PDF",
"freePdfToolsOnline": "أدوات PDF مجانية عبر الإنترنت",
"convertFilesOnline": "تحويل الملفات عبر الإنترنت"
},
"comparisons": {
"compressPdfVsIlovepdf": "Dociva مقابل iLovePDF",
"mergePdfVsSmallpdf": "Dociva مقابل Smallpdf",
"pdfToWordVsAdobeAcrobat": "Dociva مقابل Adobe Acrobat",
"compressImageVsTinypng": "Dociva مقابل TinyPNG",
"ocrVsAdobeScan": "Dociva مقابل Adobe Scan"
} }
}, },
"account": { "account": {
@@ -1208,7 +1283,10 @@
"newSize": "الحجم الجديد", "newSize": "الحجم الجديد",
"reduction": "نسبة التقليل", "reduction": "نسبة التقليل",
"downloadReady": "ملفك جاهز للتحميل.", "downloadReady": "ملفك جاهز للتحميل.",
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة." "linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة.",
"success": "تم بنجاح!",
"fileReady": "ملفك جاهز",
"processAnother": "معالجة ملف آخر"
}, },
"downloadGate": { "downloadGate": {
"title": "سجّل لتحميل ملفك", "title": "سجّل لتحميل ملفك",

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Dociva", "appName": "Dociva",
"tagline": "Free Online File Tools", "tagline": "Free Online File Tools",
@@ -35,6 +35,11 @@
"subject": "Subject", "subject": "Subject",
"message": "Message", "message": "Message",
"name": "Name", "name": "Name",
"siteTagline": "Online PDF and file workflows",
"footerDescription": "Convert, compress, edit, and automate document work in one browser-based workspace built for speed, clarity, and secure processing.",
"uploading": "Uploading...",
"convert": "Convert",
"sending": "Sending...",
"errors": { "errors": {
"fileTooLarge": "File is too large. Maximum size is {{size}}MB.", "fileTooLarge": "File is too large. Maximum size is {{size}}MB.",
"invalidFileType": "Invalid file type. Accepted: {{types}}", "invalidFileType": "Invalid file type. Accepted: {{types}}",
@@ -44,7 +49,19 @@
"rateLimited": "Too many requests. Please wait a moment and try again.", "rateLimited": "Too many requests. Please wait a moment and try again.",
"serverError": "A server error occurred. Please try again later.", "serverError": "A server error occurred. Please try again later.",
"networkError": "Network error. Please check your connection and try again.", "networkError": "Network error. Please check your connection and try again.",
"noFileSelected": "No file selected. Please choose a file to upload." "noFileSelected": "No file selected. Please choose a file to upload.",
"aiUnavailable": "AI features are temporarily unavailable. Please try again later.",
"aiRateLimited": "AI service is currently busy. Please try again shortly.",
"aiBudgetExceeded": "AI processing quota exceeded. Please try again later.",
"pdfEncrypted": "This PDF is password-protected. Please unlock it first.",
"pdfTextEmpty": "No readable text found in this PDF.",
"pdfNoTables": "No tables found in this PDF.",
"taskUnavailable": "Service temporarily unavailable. Please retry in a moment.",
"translationFailed": "Translation service failed. Please try again.",
"invalidInput": "Invalid input. Please check your settings and try again.",
"genericTitle": "Something went wrong",
"genericDesc": "An unexpected error occurred. Please try again.",
"tryAgain": "Try Again"
} }
}, },
"auth": { "auth": {
@@ -141,7 +158,31 @@
"feature2Title": "Accuracy you can trust", "feature2Title": "Accuracy you can trust",
"feature2Desc": "Get pixel-perfect, editable files in seconds with zero quality loss.", "feature2Desc": "Get pixel-perfect, editable files in seconds with zero quality loss.",
"feature3Title": "Built-in security", "feature3Title": "Built-in security",
"feature3Desc": "Access files securely, protected by automatic encryption." "feature3Desc": "Access files securely, protected by automatic encryption.",
"startFree": "Start Free",
"heroBadge": "Modern document workflows",
"statsToolsLabel": "Total tools",
"statsPdfLabel": "PDF workflows",
"statsOtherLabel": "Image, AI & utility",
"statsAccessLabel": "Access model",
"statsAccessValue": "No signup",
"trustSecure": "Files auto-deleted",
"trustFast": "Results in seconds",
"trust30Tools": "30+ free tools",
"trustNoSignup": "No sign-up needed",
"ctaBrowseTools": "Browse All Tools",
"quickStartLabel": "Popular starting points",
"heroUploadEyebrow": "Upload and start",
"heroUploadTitle": "Choose a file and jump straight into the right tool",
"howItWorksLabel": "Simple process",
"howItWorksTitle": "Convert and edit in three simple steps",
"toolsDirectoryTitle": "Find the right tool faster",
"otherTools": "Other Tools",
"whyChooseLabel": "Why Dociva",
"ctaBannerLabel": "Get started today",
"ctaBannerTitle": "Ready to convert your files?",
"ctaBannerSubtitle": "Join thousands of users who convert, compress, and edit their files every day — completely free.",
"ctaCreateAccount": "Create Free Account"
}, },
"socialProof": { "socialProof": {
"badge": "Trusted by active teams", "badge": "Trusted by active teams",
@@ -173,7 +214,11 @@
"AI document tools — chat with PDFs, summarize, translate, extract tables", "AI document tools — chat with PDFs, summarize, translate, extract tables",
"OCR — extract text from images and scanned PDFs in English, Arabic, and French", "OCR — extract text from images and scanned PDFs in English, Arabic, and French",
"Utility tools — QR code generator, video to GIF, word counter, text cleaner" "Utility tools — QR code generator, video to GIF, word counter, text cleaner"
] ],
"heroTitle": "Empowering Document Productivity Worldwide",
"teamTitle": "Our Team",
"valuesTitle": "Our Values",
"ctaText": "Have questions? Get in touch."
}, },
"contact": { "contact": {
"metaDescription": "Contact the Dociva team. Report bugs, request features, or send us a message.", "metaDescription": "Contact the Dociva team. Report bugs, request features, or send us a message.",
@@ -194,7 +239,12 @@
"subjectPlaceholder": "Subject", "subjectPlaceholder": "Subject",
"successMessage": "Your message has been sent! We'll get back to you soon.", "successMessage": "Your message has been sent! We'll get back to you soon.",
"directEmail": "Or email us directly at", "directEmail": "Or email us directly at",
"responseTime": "We typically respond within 2448 hours." "responseTime": "We typically respond within 2448 hours.",
"emailLabel": "Email:",
"phoneLabel": "Phone:",
"officeLabel": "Office:",
"connectTitle": "Connect With Us",
"faqTitle": "FAQ"
}, },
"privacy": { "privacy": {
"metaDescription": "Privacy policy for Dociva. Learn how we handle your files and data with full transparency.", "metaDescription": "Privacy policy for Dociva. Learn how we handle your files and data with full transparency.",
@@ -643,7 +693,9 @@
"height": "Height (px)", "height": "Height (px)",
"quality": "Quality", "quality": "Quality",
"lockAspect": "Lock aspect ratio", "lockAspect": "Lock aspect ratio",
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio." "aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio.",
"widthPlaceholder": "e.g. 800",
"heightPlaceholder": "e.g. 600"
}, },
"imageToSvg": { "imageToSvg": {
"title": "Image to SVG", "title": "Image to SVG",
@@ -989,7 +1041,8 @@
"shortDesc": "Generate QR Code", "shortDesc": "Generate QR Code",
"dataLabel": "Text or URL", "dataLabel": "Text or URL",
"dataPlaceholder": "Enter text, URL, or any data...", "dataPlaceholder": "Enter text, URL, or any data...",
"sizeLabel": "Size" "sizeLabel": "Size",
"altText": "Generated QR Code"
}, },
"htmlToPdf": { "htmlToPdf": {
"title": "HTML to PDF", "title": "HTML to PDF",
@@ -1124,7 +1177,29 @@
"dataLabel": "Barcode Data", "dataLabel": "Barcode Data",
"dataPlaceholder": "Enter data to encode...", "dataPlaceholder": "Enter data to encode...",
"typeLabel": "Barcode Type", "typeLabel": "Barcode Type",
"formatLabel": "Output Format" "formatLabel": "Output Format",
"altText": "Generated barcode"
}
},
"footer": {
"categories": {
"pdf": "PDF",
"imageConvert": "Image & Convert",
"aiUtility": "AI & Utility",
"guides": "Guides",
"comparisons": "Comparisons"
},
"guides": {
"bestPdfTools": "Best PDF Tools",
"freePdfToolsOnline": "Free PDF Tools Online",
"convertFilesOnline": "Convert Files Online"
},
"comparisons": {
"compressPdfVsIlovepdf": "Dociva vs iLovePDF",
"mergePdfVsSmallpdf": "Dociva vs Smallpdf",
"pdfToWordVsAdobeAcrobat": "Dociva vs Adobe Acrobat",
"compressImageVsTinypng": "Dociva vs TinyPNG",
"ocrVsAdobeScan": "Dociva vs Adobe Scan"
} }
}, },
"account": { "account": {
@@ -1208,7 +1283,10 @@
"newSize": "New Size", "newSize": "New Size",
"reduction": "Reduction", "reduction": "Reduction",
"downloadReady": "Your file is ready for download.", "downloadReady": "Your file is ready for download.",
"linkExpiry": "Download link expires in 30 minutes." "linkExpiry": "Download link expires in 30 minutes.",
"success": "Success!",
"fileReady": "Your file is ready",
"processAnother": "Process Another"
}, },
"downloadGate": { "downloadGate": {
"title": "Sign up to download your file", "title": "Sign up to download your file",

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Dociva", "appName": "Dociva",
"tagline": "Outils de fichiers en ligne gratuits", "tagline": "Outils de fichiers en ligne gratuits",
@@ -35,6 +35,11 @@
"subject": "Sujet", "subject": "Sujet",
"message": "Message", "message": "Message",
"name": "Nom", "name": "Nom",
"siteTagline": "Workflows PDF et fichiers en ligne",
"footerDescription": "Convertissez, compressez, modifiez et automatisez le traitement de documents dans un espace de travail basé sur le navigateur, conçu pour la rapidité, la clarté et le traitement sécurisé.",
"uploading": "Téléchargement en cours...",
"convert": "Convertir",
"sending": "Envoi en cours...",
"errors": { "errors": {
"fileTooLarge": "Fichier trop volumineux. Taille maximale autorisée : {{size}} Mo.", "fileTooLarge": "Fichier trop volumineux. Taille maximale autorisée : {{size}} Mo.",
"invalidFileType": "Type de fichier non valide. Formats acceptés : {{types}}", "invalidFileType": "Type de fichier non valide. Formats acceptés : {{types}}",
@@ -44,7 +49,19 @@
"rateLimited": "Trop de requêtes. Veuillez attendre un moment et réessayer.", "rateLimited": "Trop de requêtes. Veuillez attendre un moment et réessayer.",
"serverError": "Une erreur serveur s'est produite. Veuillez réessayer plus tard.", "serverError": "Une erreur serveur s'est produite. Veuillez réessayer plus tard.",
"networkError": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.", "networkError": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.",
"noFileSelected": "Aucun fichier sélectionné. Veuillez choisir un fichier à télécharger." "noFileSelected": "Aucun fichier sélectionné. Veuillez choisir un fichier à télécharger.",
"aiUnavailable": "Les fonctionnalités IA sont temporairement indisponibles. Veuillez réessayer plus tard.",
"aiRateLimited": "Le service IA est actuellement occupé. Veuillez réessayer dans un instant.",
"aiBudgetExceeded": "Quota de traitement IA dépassé. Veuillez réessayer plus tard.",
"pdfEncrypted": "Ce PDF est protégé par un mot de passe. Veuillez d'abord le déverrouiller.",
"pdfTextEmpty": "Aucun texte lisible trouvé dans ce PDF.",
"pdfNoTables": "Aucun tableau trouvé dans ce PDF.",
"taskUnavailable": "Service temporairement indisponible. Veuillez réessayer dans un instant.",
"translationFailed": "Le service de traduction a échoué. Veuillez réessayer.",
"invalidInput": "Entrée non valide. Veuillez vérifier vos paramètres et réessayer.",
"genericTitle": "Une erreur s'est produite",
"genericDesc": "Une erreur inattendue s'est produite. Veuillez réessayer.",
"tryAgain": "Réessayer"
} }
}, },
"auth": { "auth": {
@@ -141,7 +158,31 @@
"feature2Title": "Une précision de confiance", "feature2Title": "Une précision de confiance",
"feature2Desc": "Obtenez des fichiers parfaits et modifiables en quelques secondes sans perte de qualité.", "feature2Desc": "Obtenez des fichiers parfaits et modifiables en quelques secondes sans perte de qualité.",
"feature3Title": "Sécurité intégrée", "feature3Title": "Sécurité intégrée",
"feature3Desc": "Accédez aux fichiers en toute sécurité, protégés par un cryptage automatique." "feature3Desc": "Accédez aux fichiers en toute sécurité, protégés par un cryptage automatique.",
"startFree": "Commencer gratuitement",
"heroBadge": "Workflows de documents modernes",
"statsToolsLabel": "Total des outils",
"statsPdfLabel": "Workflows PDF",
"statsOtherLabel": "Image, IA et utilitaires",
"statsAccessLabel": "Modèle d'accès",
"statsAccessValue": "Sans inscription",
"trustSecure": "Fichiers supprimés automatiquement",
"trustFast": "Résultats en quelques secondes",
"trust30Tools": "30+ outils gratuits",
"trustNoSignup": "Aucune inscription requise",
"ctaBrowseTools": "Parcourir tous les outils",
"quickStartLabel": "Points de départ populaires",
"heroUploadEyebrow": "Déposez et commencez",
"heroUploadTitle": "Choisissez un fichier et accédez directement au bon outil",
"howItWorksLabel": "Processus simple",
"howItWorksTitle": "Convertissez et modifiez en trois étapes simples",
"toolsDirectoryTitle": "Trouvez le bon outil plus rapidement",
"otherTools": "Autres outils",
"whyChooseLabel": "Pourquoi Dociva",
"ctaBannerLabel": "Commencez dès aujourd'hui",
"ctaBannerTitle": "Prêt à convertir vos fichiers ?",
"ctaBannerSubtitle": "Rejoignez des milliers d'utilisateurs qui convertissent, compressent et modifient leurs fichiers chaque jour — complètement gratuit.",
"ctaCreateAccount": "Créer un compte gratuit"
}, },
"socialProof": { "socialProof": {
"badge": "Adopté par des équipes actives", "badge": "Adopté par des équipes actives",
@@ -173,7 +214,11 @@
"Outils documentaires IA — discuter avec des PDF, résumer, traduire, extraire des tableaux", "Outils documentaires IA — discuter avec des PDF, résumer, traduire, extraire des tableaux",
"OCR — extraire du texte d'images et de PDF numérisés en anglais, arabe et français", "OCR — extraire du texte d'images et de PDF numérisés en anglais, arabe et français",
"Outils utilitaires — générateur de QR code, vidéo vers GIF, compteur de mots, nettoyeur de texte" "Outils utilitaires — générateur de QR code, vidéo vers GIF, compteur de mots, nettoyeur de texte"
] ],
"heroTitle": "Améliorer la productivité documentaire dans le monde entier",
"teamTitle": "Notre équipe",
"valuesTitle": "Nos valeurs",
"ctaText": "Des questions ? Contactez-nous."
}, },
"contact": { "contact": {
"metaDescription": "Contactez l'équipe Dociva. Signalez un bug, demandez une fonctionnalité ou envoyez-nous un message.", "metaDescription": "Contactez l'équipe Dociva. Signalez un bug, demandez une fonctionnalité ou envoyez-nous un message.",
@@ -194,7 +239,12 @@
"subjectPlaceholder": "Sujet", "subjectPlaceholder": "Sujet",
"successMessage": "Votre message a été envoyé ! Nous vous répondrons bientôt.", "successMessage": "Votre message a été envoyé ! Nous vous répondrons bientôt.",
"directEmail": "Ou contactez-nous directement à", "directEmail": "Ou contactez-nous directement à",
"responseTime": "Nous répondons généralement sous 24 à 48 heures." "responseTime": "Nous répondons généralement sous 24 à 48 heures.",
"emailLabel": "E-mail :",
"phoneLabel": "Téléphone :",
"officeLabel": "Bureau :",
"connectTitle": "Connectez-vous avec nous",
"faqTitle": "FAQ"
}, },
"privacy": { "privacy": {
"metaDescription": "Politique de confidentialité de Dociva. Découvrez comment nous gérons vos fichiers et données en toute transparence.", "metaDescription": "Politique de confidentialité de Dociva. Découvrez comment nous gérons vos fichiers et données en toute transparence.",
@@ -643,7 +693,9 @@
"height": "Hauteur (px)", "height": "Hauteur (px)",
"quality": "Qualité", "quality": "Qualité",
"lockAspect": "Verrouiller le rapport d'aspect", "lockAspect": "Verrouiller le rapport d'aspect",
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect." "aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect.",
"widthPlaceholder": "ex. 800",
"heightPlaceholder": "ex. 600"
}, },
"imageToSvg": { "imageToSvg": {
"title": "Image vers SVG", "title": "Image vers SVG",
@@ -989,7 +1041,8 @@
"shortDesc": "Générer un code QR", "shortDesc": "Générer un code QR",
"dataLabel": "Texte ou URL", "dataLabel": "Texte ou URL",
"dataPlaceholder": "Entrez du texte, une URL ou des données...", "dataPlaceholder": "Entrez du texte, une URL ou des données...",
"sizeLabel": "Taille" "sizeLabel": "Taille",
"altText": "QR Code généré"
}, },
"htmlToPdf": { "htmlToPdf": {
"title": "HTML vers PDF", "title": "HTML vers PDF",
@@ -1124,7 +1177,29 @@
"dataLabel": "Données du code-barres", "dataLabel": "Données du code-barres",
"dataPlaceholder": "Entrez les données à encoder...", "dataPlaceholder": "Entrez les données à encoder...",
"typeLabel": "Type de code-barres", "typeLabel": "Type de code-barres",
"formatLabel": "Format de sortie" "formatLabel": "Format de sortie",
"altText": "Code-barres généré"
}
},
"footer": {
"categories": {
"pdf": "PDF",
"imageConvert": "Image & Conversion",
"aiUtility": "IA & Utilitaires",
"guides": "Guides",
"comparisons": "Comparaisons"
},
"guides": {
"bestPdfTools": "Meilleurs outils PDF",
"freePdfToolsOnline": "Outils PDF gratuits en ligne",
"convertFilesOnline": "Convertir des fichiers en ligne"
},
"comparisons": {
"compressPdfVsIlovepdf": "Dociva vs iLovePDF",
"mergePdfVsSmallpdf": "Dociva vs Smallpdf",
"pdfToWordVsAdobeAcrobat": "Dociva vs Adobe Acrobat",
"compressImageVsTinypng": "Dociva vs TinyPNG",
"ocrVsAdobeScan": "Dociva vs Adobe Scan"
} }
}, },
"account": { "account": {
@@ -1208,7 +1283,10 @@
"newSize": "Nouvelle taille", "newSize": "Nouvelle taille",
"reduction": "Réduction", "reduction": "Réduction",
"downloadReady": "Votre fichier est prêt à être téléchargé.", "downloadReady": "Votre fichier est prêt à être téléchargé.",
"linkExpiry": "Le lien de téléchargement expire dans 30 minutes." "linkExpiry": "Le lien de téléchargement expire dans 30 minutes.",
"success": "Succès !",
"fileReady": "Votre fichier est prêt",
"processAnother": "Traiter un autre fichier"
}, },
"downloadGate": { "downloadGate": {
"title": "Inscrivez-vous pour télécharger votre fichier", "title": "Inscrivez-vous pour télécharger votre fichier",

View File

@@ -1,4 +1,5 @@
import axios, { type InternalAxiosRequestConfig } from 'axios'; import axios, { type InternalAxiosRequestConfig } from 'axios';
import i18n from '@/i18n';
const CSRF_COOKIE_NAME = 'csrf_token'; const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token'; const CSRF_HEADER_NAME = 'X-CSRF-Token';
@@ -160,21 +161,27 @@ api.interceptors.response.use(
} }
if (error.response.status === 429) { if (error.response.status === 429) {
return Promise.reject(new Error('Too many requests. Please wait a moment and try again.')); return Promise.reject(new Error(i18n.t('common.errors.rateLimited')));
} }
const responseData = error.response.data; const responseData = error.response.data;
const errorCode: string | undefined = responseData?.error_code;
if (errorCode) {
const mapped = resolveErrorCode(errorCode);
if (mapped) return Promise.reject(new Error(mapped));
}
const message = const message =
responseData?.user_message ||
responseData?.error || responseData?.error ||
responseData?.message || responseData?.message ||
(typeof responseData === 'string' && responseData.trim() (typeof responseData === 'string' && responseData.trim()
? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() ? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
: null) || : null) ||
`Request failed (${error.response.status}).`; i18n.t('common.errors.serverError');
return Promise.reject(new Error(message)); return Promise.reject(new Error(message));
} }
if (error.request) { if (error.request) {
return Promise.reject(new Error('Network error. Please check your connection.')); return Promise.reject(new Error(i18n.t('common.errors.networkError')));
} }
return Promise.reject(error); return Promise.reject(error);
} }
@@ -251,7 +258,57 @@ function isTaskErrorPayload(value: unknown): value is TaskErrorPayload {
return Boolean(value) && typeof value === 'object'; return Boolean(value) && typeof value === 'object';
} }
/**
* Maps a backend error_code to a fully translated message via i18n.
* Returns null when no specific mapping exists (caller should fall back to user_message or generic).
*/
export function resolveErrorCode(errorCode: string): string | null {
const map: Record<string, string> = {
TASK_FAILURE: i18n.t('common.errors.processingFailed'),
CELERY_NOT_REGISTERED: i18n.t('common.errors.taskUnavailable'),
OPENROUTER_UNAUTHORIZED: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_RATE_LIMIT: i18n.t('common.errors.aiRateLimited'),
OPENROUTER_INSUFFICIENT_CREDITS: i18n.t('common.errors.aiRateLimited'),
OPENROUTER_SERVER_ERROR: i18n.t('common.errors.serverError'),
OPENROUTER_CONNECTION_ERROR: i18n.t('common.errors.networkError'),
OPENROUTER_TIMEOUT: i18n.t('common.errors.serverError'),
OPENROUTER_MISSING_API_KEY: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_EMPTY_RESPONSE: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_ERROR_PAYLOAD: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_REQUEST_ERROR: i18n.t('common.errors.serverError'),
DEEPL_NOT_CONFIGURED: i18n.t('common.errors.translationFailed'),
DEEPL_UNSUPPORTED_TARGET_LANGUAGE: i18n.t('common.errors.invalidInput'),
DEEPL_TIMEOUT: i18n.t('common.errors.translationFailed'),
DEEPL_CONNECTION_ERROR: i18n.t('common.errors.networkError'),
DEEPL_REQUEST_ERROR: i18n.t('common.errors.translationFailed'),
DEEPL_RATE_LIMIT: i18n.t('common.errors.aiRateLimited'),
DEEPL_SERVER_ERROR: i18n.t('common.errors.serverError'),
DEEPL_CREDITS_OR_PERMISSIONS: i18n.t('common.errors.translationFailed'),
DEEPL_EMPTY_RESPONSE: i18n.t('common.errors.translationFailed'),
DEEPL_EMPTY_TEXT: i18n.t('common.errors.pdfTextEmpty'),
TRANSLATION_PROVIDER_FAILED: i18n.t('common.errors.translationFailed'),
AI_BUDGET_EXCEEDED: i18n.t('common.errors.aiBudgetExceeded'),
PDF_ENCRYPTED: i18n.t('common.errors.pdfEncrypted'),
PDF_TEXT_EXTRACTION_FAILED: i18n.t('common.errors.processingFailed'),
PDF_TEXT_EMPTY: i18n.t('common.errors.pdfTextEmpty'),
PDF_AI_INVALID_INPUT: i18n.t('common.errors.invalidInput'),
PDF_AI_ERROR: i18n.t('common.errors.processingFailed'),
PDF_TABLES_NOT_FOUND: i18n.t('common.errors.pdfNoTables'),
PDF_TABLE_EXTRACTION_FAILED: i18n.t('common.errors.processingFailed'),
TABULA_NOT_INSTALLED: i18n.t('common.errors.serverError'),
};
return map[errorCode] ?? null;
}
export function getTaskErrorMessage(error: unknown, fallback: string): string { export function getTaskErrorMessage(error: unknown, fallback: string): string {
if (isTaskErrorPayload(error)) {
// Prefer a translated message keyed by error_code
if (typeof error.error_code === 'string') {
const translated = resolveErrorCode(error.error_code);
if (translated) return translated;
}
}
if (typeof error === 'string' && error.trim()) { if (typeof error === 'string' && error.trim()) {
return error.trim(); return error.trim();
} }