Compare commits
10 Commits
0f9b1fe260
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c483e8508b | ||
|
|
586d93eb41 | ||
|
|
d066d8e414 | ||
|
|
f57779205b | ||
|
|
3f4d874560 | ||
|
|
ade7abac46 | ||
|
|
8693834230 | ||
|
|
009ac7f087 | ||
|
|
7928e688d5 | ||
|
|
7e9edc2992 |
@@ -80,6 +80,10 @@ INDEXNOW_AUTO_SUBMIT=true
|
||||
INDEXNOW_STRICT=false
|
||||
INDEXNOW_FULL_SUBMIT=false
|
||||
|
||||
# Gitea (optional)
|
||||
GITEA_DOMAIN=
|
||||
GITEA_ROOT_URL=
|
||||
|
||||
# Frontend Analytics / Ads (Vite)
|
||||
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
VITE_PLAUSIBLE_DOMAIN=dociva.io
|
||||
|
||||
@@ -181,6 +181,27 @@ services:
|
||||
- frontend_build:/app/dist
|
||||
- indexnow_state:/app/.indexnow
|
||||
|
||||
# --- Gitea (self-hosted Git) ---
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
restart: always
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
# Expose the correct SSH port to users (host maps 2222 -> container 22)
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
# Optional: set these in .env for correct clone URLs
|
||||
- GITEA__server__DOMAIN=${GITEA_DOMAIN:-}
|
||||
- GITEA__server__ROOT_URL=${GITEA_ROOT_URL:-}
|
||||
volumes:
|
||||
- gitea_data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "2222:22"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -189,3 +210,4 @@ volumes:
|
||||
db_data:
|
||||
frontend_build:
|
||||
indexnow_state:
|
||||
gitea_data:
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3822257947737372"
|
||||
crossorigin="anonymous"></script>
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
@@ -65,7 +67,6 @@
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Tajawal:wght@400;700&display=swap" />
|
||||
</noscript>
|
||||
<link rel="modulepreload" href="/src/main.tsx" />
|
||||
<title>Dociva — Free Online File Tools</title>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"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": {
|
||||
"@microsoft/clarity": "^1.0.2",
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/static.xml</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/blog.xml</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/tools.xml</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/seo.xml</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/comparisons.xml</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/merge-split-pdf-files</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
33
frontend/public/sitemaps/comparisons.xml
Normal file
33
frontend/public/sitemaps/comparisons.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/compare/compress-pdf-vs-ilovepdf</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/compare/merge-pdf-vs-smallpdf</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/compare/pdf-to-word-vs-adobe-acrobat</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/compare/compress-image-vs-tinypng</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/compare/ocr-vs-adobe-scan</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,61 +2,61 @@
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/about</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/contact</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/privacy</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/terms</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/pricing</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/pricing-transparency</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/developers</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
127
frontend/scripts/check-hardcoded-text.mjs
Normal file
127
frontend/scripts/check-hardcoded-text.mjs
Normal 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);
|
||||
}
|
||||
83
frontend/scripts/check-i18n-keys.mjs
Normal file
83
frontend/scripts/check-i18n-keys.mjs
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import Clarity from '@microsoft/clarity';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { Toaster } from 'sonner';
|
||||
@@ -48,11 +48,28 @@ function LoadingFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
function IdleLoad({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
const id = requestIdleCallback(() => setReady(true));
|
||||
return () => cancelIdleCallback(id);
|
||||
}
|
||||
const id = setTimeout(() => setReady(true), 2000);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
return ready ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useDirection();
|
||||
const location = useLocation();
|
||||
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
|
||||
const isMarketingLayout =
|
||||
location.pathname === '/' ||
|
||||
['/about', '/contact', '/pricing', '/tools', '/developers', '/pricing-transparency'].includes(location.pathname) ||
|
||||
location.pathname.startsWith('/compare/');
|
||||
|
||||
useEffect(() => {
|
||||
initAnalytics();
|
||||
@@ -103,7 +120,7 @@ export default function App() {
|
||||
<div className="flex min-h-screen flex-col bg-slate-50 transition-colors duration-300 dark:bg-slate-950">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto flex-1 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<main className={isMarketingLayout ? 'flex-1' : 'container mx-auto flex-1 px-4 py-8 sm:px-6 lg:px-8'}>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
@@ -148,7 +165,9 @@ export default function App() {
|
||||
|
||||
<Footer />
|
||||
<Suspense fallback={null}>
|
||||
<IdleLoad>
|
||||
<SiteAssistant />
|
||||
</IdleLoad>
|
||||
<CookieConsent />
|
||||
</Suspense>
|
||||
<Toaster
|
||||
|
||||
@@ -1,71 +1,123 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText } 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: [
|
||||
{ slug: 'pdf-to-word', label: 'PDF to Word' },
|
||||
{ slug: 'compress-pdf', label: 'Compress PDF' },
|
||||
{ slug: 'merge-pdf', label: 'Merge PDF' },
|
||||
{ slug: 'split-pdf', label: 'Split PDF' },
|
||||
{ slug: 'pdf-to-images', label: 'PDF to Images' },
|
||||
{ slug: 'protect-pdf', label: 'Protect PDF' },
|
||||
{ slug: 'watermark-pdf', label: 'Watermark PDF' },
|
||||
{ slug: 'pdf-editor', label: 'PDF Editor' },
|
||||
{ slug: 'pdf-to-word', i18nKey: 'tools.pdfToWord.title' },
|
||||
{ slug: 'compress-pdf', i18nKey: 'tools.compressPdf.title' },
|
||||
{ slug: 'merge-pdf', i18nKey: 'tools.mergePdf.title' },
|
||||
{ slug: 'split-pdf', i18nKey: 'tools.splitPdf.title' },
|
||||
{ slug: 'pdf-to-images', i18nKey: 'tools.pdfToImages.title' },
|
||||
{ slug: 'protect-pdf', i18nKey: 'tools.protectPdf.title' },
|
||||
{ slug: 'watermark-pdf', i18nKey: 'tools.watermarkPdf.title' },
|
||||
{ slug: 'pdf-editor', i18nKey: 'tools.pdfEditor.title' },
|
||||
],
|
||||
'Image & Convert': [
|
||||
{ slug: 'compress-image', label: 'Compress Image' },
|
||||
{ slug: 'image-converter', label: 'Image Converter' },
|
||||
{ slug: 'image-resize', label: 'Image Resize' },
|
||||
{ slug: 'remove-background', label: 'Remove Background' },
|
||||
{ slug: 'word-to-pdf', label: 'Word to PDF' },
|
||||
{ slug: 'html-to-pdf', label: 'HTML to PDF' },
|
||||
{ slug: 'pdf-to-excel', label: 'PDF to Excel' },
|
||||
{ slug: 'compress-image', i18nKey: 'tools.compressImage.title' },
|
||||
{ slug: 'image-converter', i18nKey: 'tools.imageConvert.title' },
|
||||
{ slug: 'image-resize', i18nKey: 'tools.imageResize.title' },
|
||||
{ slug: 'remove-background', i18nKey: 'tools.removeBg.title' },
|
||||
{ slug: 'word-to-pdf', i18nKey: 'tools.wordToPdf.title' },
|
||||
{ slug: 'html-to-pdf', i18nKey: 'tools.htmlToPdf.title' },
|
||||
{ slug: 'pdf-to-excel', i18nKey: 'tools.pdfToExcel.title' },
|
||||
],
|
||||
'AI & Utility': [
|
||||
{ slug: 'chat-pdf', label: 'Chat with PDF' },
|
||||
{ slug: 'summarize-pdf', label: 'Summarize PDF' },
|
||||
{ slug: 'translate-pdf', label: 'Translate PDF' },
|
||||
{ slug: 'ocr', label: 'OCR' },
|
||||
{ slug: 'qr-code', label: 'QR Code Generator' },
|
||||
{ slug: 'video-to-gif', label: 'Video to GIF' },
|
||||
{ slug: 'word-counter', label: 'Word Counter' },
|
||||
{ slug: 'chat-pdf', i18nKey: 'tools.chatPdf.title' },
|
||||
{ slug: 'summarize-pdf', i18nKey: 'tools.summarizePdf.title' },
|
||||
{ slug: 'translate-pdf', i18nKey: 'tools.translatePdf.title' },
|
||||
{ slug: 'ocr', i18nKey: 'tools.ocr.title' },
|
||||
{ slug: 'qr-code', i18nKey: 'tools.qrCode.title' },
|
||||
{ slug: 'video-to-gif', i18nKey: 'tools.videoToGif.title' },
|
||||
{ slug: 'word-counter', i18nKey: 'tools.wordCounter.title' },
|
||||
],
|
||||
Guides: [
|
||||
{ slug: 'best-pdf-tools', label: 'Best PDF Tools', isLanding: true },
|
||||
{ slug: 'free-pdf-tools-online', label: 'Free PDF Tools Online', isLanding: true },
|
||||
{ slug: 'convert-files-online', label: 'Convert Files Online', isLanding: true },
|
||||
{ slug: 'best-pdf-tools', i18nKey: 'footer.guides.bestPdfTools', isLanding: true },
|
||||
{ slug: 'free-pdf-tools-online', i18nKey: 'footer.guides.freePdfToolsOnline', isLanding: true },
|
||||
{ slug: 'convert-files-online', i18nKey: 'footer.guides.convertFilesOnline', isLanding: true },
|
||||
],
|
||||
Comparisons: [
|
||||
{ slug: 'compress-pdf-vs-ilovepdf', label: 'Dociva vs iLovePDF', isComparison: true },
|
||||
{ slug: 'merge-pdf-vs-smallpdf', label: 'Dociva vs Smallpdf', isComparison: true },
|
||||
{ slug: 'pdf-to-word-vs-adobe-acrobat', label: 'Dociva vs Adobe Acrobat', isComparison: true },
|
||||
{ slug: 'compress-image-vs-tinypng', label: 'Dociva vs TinyPNG', isComparison: true },
|
||||
{ slug: 'ocr-vs-adobe-scan', label: 'Dociva vs Adobe Scan', isComparison: true },
|
||||
{ slug: 'compress-pdf-vs-ilovepdf', i18nKey: 'footer.comparisons.compressPdfVsIlovepdf', isComparison: true },
|
||||
{ slug: 'merge-pdf-vs-smallpdf', i18nKey: 'footer.comparisons.mergePdfVsSmallpdf', isComparison: true },
|
||||
{ slug: 'pdf-to-word-vs-adobe-acrobat', i18nKey: 'footer.comparisons.pdfToWordVsAdobeAcrobat', isComparison: true },
|
||||
{ slug: 'compress-image-vs-tinypng', i18nKey: 'footer.comparisons.compressImageVsTinypng', 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() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
|
||||
{/* Tool link grid */}
|
||||
<div className="mb-8 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<footer className="border-t border-slate-200/80 bg-white/80 backdrop-blur-sm dark:border-slate-700/60 dark:bg-slate-950/80">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="marketing-panel overflow-hidden px-6 py-8 sm:px-8 sm:py-10">
|
||||
<div className="grid gap-10 xl:grid-cols-[1.15fr,1.85fr]">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 via-sky-500 to-accent-500 shadow-lg shadow-primary-200/70 dark:shadow-primary-950/40">
|
||||
<Layers3 className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-black tracking-tight text-slate-950 dark:text-white">
|
||||
{t('common.appName')}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('common.siteTagline')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-md text-sm leading-7 text-slate-600 dark:text-slate-300">
|
||||
{t('common.footerDescription')}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-600 dark:bg-white dark:text-slate-950 dark:hover:bg-primary-300"
|
||||
>
|
||||
{t('common.allTools')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/developers"
|
||||
className="inline-flex items-center rounded-full border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-900"
|
||||
>
|
||||
{t('common.developers')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{Object.entries(FOOTER_TOOLS).map(([category, tools]) => (
|
||||
<div key={category}>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-slate-900 dark:text-white">
|
||||
{category}
|
||||
<h3 className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-slate-500 dark:text-slate-400">
|
||||
{t(CATEGORY_KEYS[category] ?? category)}
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-2.5">
|
||||
{tools.map((tool) => (
|
||||
<li key={tool.slug}>
|
||||
<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}`}
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
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"
|
||||
>
|
||||
{tool.label}
|
||||
{t(tool.i18nKey)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -73,75 +125,23 @@ export default function Footer() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="border-t border-slate-200 pt-6 dark:border-slate-700">
|
||||
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
© {new Date().getFullYear()} {t('common.appName')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.terms')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/tools"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.allTools')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.contact')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing-transparency"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.pricingTransparency')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.blog')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/developers"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.developers')}
|
||||
</Link>
|
||||
<div className="mt-6 flex flex-col gap-4 border-t border-slate-200/80 pt-6 dark:border-slate-700/60 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>© {new Date().getFullYear()} {t('common.appName')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<Link to="/privacy" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.privacy')}</Link>
|
||||
<Link to="/terms" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.terms')}</Link>
|
||||
<Link to="/pricing" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.pricing')}</Link>
|
||||
<Link to="/pricing-transparency" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.pricingTransparency')}</Link>
|
||||
<Link to="/about" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.about')}</Link>
|
||||
<Link to="/contact" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.contact')}</Link>
|
||||
<Link to="/blog" className="text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400">{t('common.blog')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound, Coins, ArrowRight } from 'lucide-react';
|
||||
import { ChevronDown, Coins, ArrowRight, Layers3, Menu, Moon, Sparkles, Sun, UserRound, X } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { ensureLanguageResources } from '@/i18n';
|
||||
|
||||
interface LangOption {
|
||||
code: string;
|
||||
label: string;
|
||||
@@ -16,6 +17,14 @@ const languages: LangOption[] = [
|
||||
{ code: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
];
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: '/tools', key: 'common.allTools', fallback: 'All tools' },
|
||||
{ to: '/pricing', key: 'common.pricing', fallback: 'Pricing' },
|
||||
{ to: '/developers', key: 'common.developers', fallback: 'Developers' },
|
||||
{ to: '/about', key: 'common.about', fallback: 'About' },
|
||||
{ to: '/contact', key: 'common.contact', fallback: 'Contact' },
|
||||
] as const;
|
||||
|
||||
function useDarkMode() {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
@@ -41,6 +50,7 @@ function useDarkMode() {
|
||||
export default function Header() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark, toggle: toggleDark } = useDarkMode();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const credits = useAuthStore((state) => state.credits);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
@@ -60,96 +70,99 @@ export default function Header() {
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
setLangOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const switchLang = async (code: string) => {
|
||||
const resolved = await ensureLanguageResources(code);
|
||||
void i18n.changeLanguage(resolved);
|
||||
setLangOpen(false);
|
||||
};
|
||||
|
||||
const desktopNavClassName = ({ isActive }: { isActive: boolean }) =>
|
||||
[
|
||||
'rounded-full px-4 py-2 text-sm font-semibold transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-950'
|
||||
: 'text-slate-600 hover:bg-white hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white',
|
||||
].join(' ');
|
||||
|
||||
const mobileNavClassName = ({ isActive }: { isActive: boolean }) =>
|
||||
[
|
||||
'block rounded-2xl px-4 py-3 text-sm font-semibold transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-600 text-white shadow-sm shadow-primary-200 dark:shadow-primary-950/30'
|
||||
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200/80 bg-white/85 backdrop-blur-xl dark:border-slate-700/60 dark:bg-slate-900/85">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2.5 group">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-600 shadow-sm shadow-primary-200 group-hover:bg-primary-700 transition-colors dark:shadow-primary-900/40">
|
||||
<FileText className="h-5 w-5 text-white" />
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200/70 bg-white/78 backdrop-blur-2xl dark:border-slate-700/60 dark:bg-slate-950/78">
|
||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/" className="group flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 via-sky-500 to-accent-500 shadow-lg shadow-primary-200/70 transition-transform duration-300 group-hover:-translate-y-0.5 dark:shadow-primary-950/40">
|
||||
<Layers3 className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-extrabold tracking-tight text-slate-900 dark:text-white">
|
||||
<div>
|
||||
<span className="block text-lg font-black tracking-tight text-slate-950 dark:text-white">
|
||||
{t('common.appName')}
|
||||
</span>
|
||||
<span className="hidden text-xs font-medium text-slate-500 dark:text-slate-400 sm:block">
|
||||
{t('common.siteTagline')}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.home')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/developers"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.developers')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-1 rounded-full border border-slate-200/80 bg-white/80 p-1 shadow-sm lg:flex dark:border-slate-700/70 dark:bg-slate-900/70">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<NavLink key={link.to} to={link.to} className={desktopNavClassName}>
|
||||
{t(link.key, link.fallback)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Account / credits pill */}
|
||||
<Link
|
||||
to="/account"
|
||||
className="hidden max-w-[200px] items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 md:flex dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
className="hidden max-w-[220px] items-center gap-2 rounded-full border border-slate-200/80 bg-white/70 px-3.5 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-white lg:flex dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-200 dark:hover:bg-slate-900"
|
||||
>
|
||||
<UserRound className="h-4 w-4 flex-shrink-0" />
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{user?.email || t('common.account')}</span>
|
||||
{user && credits && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{user && credits ? (
|
||||
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
<Coins className="h-3 w-3" />
|
||||
{credits.credits_remaining}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</Link>
|
||||
|
||||
{/* CTA — Start Free */}
|
||||
{!user && (
|
||||
{!user ? (
|
||||
<Link
|
||||
to="/account"
|
||||
className="hidden md:inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm shadow-primary-200 transition-all hover:bg-primary-700 hover:shadow-md hover:-translate-y-px active:translate-y-0 dark:shadow-primary-900/40"
|
||||
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"
|
||||
>
|
||||
{t('home.startFree', 'Start Free')}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('home.startFree')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className="flex items-center justify-center rounded-xl p-2.5 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
className="flex items-center justify-center rounded-full border border-transparent p-2.5 text-slate-500 transition-colors hover:border-slate-200 hover:bg-white dark:text-slate-400 dark:hover:border-slate-700 dark:hover:bg-slate-900"
|
||||
aria-label={isDark ? t('common.lightMode') : t('common.darkMode')}
|
||||
title={isDark ? t('common.lightMode') : t('common.darkMode')}
|
||||
>
|
||||
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<div className="relative" ref={langRef}>
|
||||
<button
|
||||
onClick={() => setLangOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
onClick={() => setLangOpen((value) => !value)}
|
||||
className="flex items-center gap-1.5 rounded-full border border-transparent px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-slate-200 hover:bg-white dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-900"
|
||||
aria-label={t('common.language')}
|
||||
aria-expanded={langOpen}
|
||||
aria-haspopup="listbox"
|
||||
@@ -159,36 +172,34 @@ export default function Header() {
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${langOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{langOpen && (
|
||||
<div className="absolute end-0 top-full z-50 mt-2 w-44 origin-top-right animate-in fade-in slide-in-from-top-2 rounded-xl border border-slate-200 bg-white p-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
{langOpen ? (
|
||||
<div className="absolute end-0 top-full z-50 mt-2 w-48 origin-top-right rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-200/70 dark:border-slate-700 dark:bg-slate-900 dark:shadow-slate-950/30">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => void switchLang(lang.code)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
lang.code === i18n.language
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={lang.code === i18n.language}
|
||||
>
|
||||
<span className="text-lg leading-none">{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
{lang.code === i18n.language && (
|
||||
{lang.code === i18n.language ? (
|
||||
<span className="ms-auto text-primary-600 dark:text-primary-400">✓</span>
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="flex items-center justify-center rounded-xl p-2.5 text-slate-500 transition-colors hover:bg-slate-100 md:hidden dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
onClick={() => setMobileOpen((value) => !value)}
|
||||
className="flex items-center justify-center rounded-full border border-transparent p-2.5 text-slate-500 transition-colors hover:border-slate-200 hover:bg-white lg:hidden dark:text-slate-400 dark:hover:border-slate-700 dark:hover:bg-slate-900"
|
||||
aria-label="Menu"
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
@@ -196,62 +207,40 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileOpen && (
|
||||
<nav className="border-t border-slate-200 bg-white px-4 pb-4 pt-2 md:hidden dark:border-slate-700 dark:bg-slate-900">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('common.home')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
{mobileOpen ? (
|
||||
<nav className="border-t border-slate-200/70 bg-white/92 px-4 pb-5 pt-3 lg:hidden dark:border-slate-700/60 dark:bg-slate-950/92">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-2">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<NavLink key={link.to} to={link.to} className={mobileNavClassName}>
|
||||
{t(link.key, link.fallback)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
className="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
|
||||
>
|
||||
<span>{user?.email || t('common.account')}</span>
|
||||
{user && credits && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{user && credits ? (
|
||||
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
<Coins className="h-3 w-3" />
|
||||
{credits.credits_remaining}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</Link>
|
||||
<Link
|
||||
to="/developers"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('common.developers')}
|
||||
</Link>
|
||||
{!user && (
|
||||
|
||||
{!user ? (
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="mt-2 flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
className="mt-1 flex items-center justify-center gap-2 rounded-2xl bg-slate-950 px-4 py-3 text-sm font-semibold text-white dark:bg-white dark:text-slate-950"
|
||||
>
|
||||
{t('home.startFree', 'Start Free')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
25
frontend/src/components/layout/MarketingPageLayout.tsx
Normal file
25
frontend/src/components/layout/MarketingPageLayout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface MarketingPageLayoutProps {
|
||||
hero?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export default function MarketingPageLayout({
|
||||
hero,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
}: MarketingPageLayoutProps) {
|
||||
const rootClassName = ['marketing-shell relative isolate', className].filter(Boolean).join(' ');
|
||||
const contentClassName = ['relative', bodyClassName].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
{hero}
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,15 +39,26 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
|
||||
const toolTitle = t(`tools.${seo.i18nKey}.title`);
|
||||
const toolDesc = t(`tools.${seo.i18nKey}.description`);
|
||||
const localizedTitleSuffix = i18n.exists(`seo.${seo.i18nKey}.metaTitleSuffix`)
|
||||
? t(`seo.${seo.i18nKey}.metaTitleSuffix`)
|
||||
: seo.titleSuffix;
|
||||
const localizedMetaDescription = i18n.exists(`seo.${seo.i18nKey}.metaDescription`)
|
||||
? t(`seo.${seo.i18nKey}.metaDescription`)
|
||||
: seo.metaDescription;
|
||||
const localizedFaqData = t(`seo.${seo.i18nKey}.faq`, { returnObjects: true }) as SEOFAQ[];
|
||||
const localizedFaqs = Array.isArray(localizedFaqData) && localizedFaqData.length > 0
|
||||
? localizedFaqData.map((faq) => ({ question: faq.q, answer: faq.a }))
|
||||
: seo.faqs;
|
||||
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const path = `/tools/${slug}`;
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const socialImageUrl = buildSocialImageUrl(origin);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
const metaTitle = `${toolTitle} — ${localizedTitleSuffix}`;
|
||||
|
||||
const toolSchema = generateToolSchema({
|
||||
name: toolTitle,
|
||||
description: seo.metaDescription,
|
||||
description: localizedMetaDescription,
|
||||
url: canonicalUrl,
|
||||
category: seo.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication',
|
||||
ratingValue: ratingData.average,
|
||||
@@ -60,12 +71,12 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
{ name: toolTitle, url: canonicalUrl },
|
||||
]);
|
||||
|
||||
const faqSchema = seo.faqs.length > 0 ? generateFAQ(seo.faqs) : null;
|
||||
const faqSchema = localizedFaqs.length > 0 ? generateFAQ(localizedFaqs) : null;
|
||||
const howToSteps = t(`seo.${seo.i18nKey}.howToUse`, { returnObjects: true }) as string[];
|
||||
const howToSchema = Array.isArray(howToSteps) && howToSteps.length > 0
|
||||
? generateHowTo({
|
||||
name: toolTitle,
|
||||
description: seo.metaDescription,
|
||||
description: localizedMetaDescription,
|
||||
steps: howToSteps,
|
||||
url: canonicalUrl,
|
||||
})
|
||||
@@ -74,14 +85,14 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{toolTitle} — {seo.titleSuffix} | {t('common.appName')}</title>
|
||||
<meta name="description" content={seo.metaDescription} />
|
||||
<title>{metaTitle} | {t('common.appName')}</title>
|
||||
<meta name="description" content={localizedMetaDescription} />
|
||||
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
||||
<meta property="og:description" content={seo.metaDescription} />
|
||||
<meta property="og:title" content={metaTitle} />
|
||||
<meta property="og:description" content={localizedMetaDescription} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={socialImageUrl} />
|
||||
@@ -90,8 +101,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
||||
<meta name="twitter:description" content={seo.metaDescription} />
|
||||
<meta name="twitter:title" content={metaTitle} />
|
||||
<meta name="twitter:description" content={localizedMetaDescription} />
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
<meta name="twitter:image:alt" content={`${toolTitle} social preview`} />
|
||||
|
||||
@@ -208,11 +219,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
|
||||
{/* FAQ Section */}
|
||||
{(() => {
|
||||
const faqData = t(`seo.${seo.i18nKey}.faq`, { returnObjects: true }) as SEOFAQ[];
|
||||
const faqs = Array.isArray(faqData)
|
||||
? faqData.map((f) => ({ question: f.q, answer: f.a }))
|
||||
: [];
|
||||
return <FAQSection faqs={faqs} />;
|
||||
return <FAQSection faqs={localizedFaqs} />;
|
||||
})()}
|
||||
|
||||
{/* Related Tools */}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { withTranslation, type WithTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
fallbackMessage?: string;
|
||||
}
|
||||
@@ -10,7 +11,7 @@ interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
@@ -22,6 +23,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Try Again
|
||||
{t('common.errors.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -46,3 +48,5 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, lazy, Suspense } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UploadCloud, PenLine, ChevronRight, FileCheck } from 'lucide-react';
|
||||
import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
|
||||
import type { ToolOption } from '@/utils/fileRouting';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
|
||||
const ToolSelectorModal = lazy(() => import('@/components/shared/ToolSelectorModal'));
|
||||
|
||||
/**
|
||||
* The MIME types we accept on the homepage smart upload zone.
|
||||
* Covers PDF, images, video, and Word documents.
|
||||
@@ -45,12 +45,13 @@ export default function HeroUploadZone() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
async (acceptedFiles: File[]) => {
|
||||
setError(null);
|
||||
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
const file = acceptedFiles[0];
|
||||
const { getToolsForFile, detectFileCategory, getCategoryLabel } = await import('@/utils/fileRouting');
|
||||
const tools = getToolsForFile(file);
|
||||
|
||||
if (tools.length === 0) {
|
||||
@@ -107,7 +108,7 @@ export default function HeroUploadZone() {
|
||||
{...getRootProps()}
|
||||
className={`hero-upload-zone group ${isDragActive ? 'drag-active' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<input {...getInputProps()} aria-label={t('home.dragDropTitle', 'Drag & drop your file here')} />
|
||||
|
||||
{/* Cloud icon with animated ring */}
|
||||
<div className="relative mb-6">
|
||||
@@ -210,6 +211,7 @@ export default function HeroUploadZone() {
|
||||
</div>
|
||||
|
||||
{/* Tool Selector Modal */}
|
||||
<Suspense fallback={null}>
|
||||
<ToolSelectorModal
|
||||
isOpen={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
@@ -217,6 +219,7 @@ export default function HeroUploadZone() {
|
||||
tools={matchedTools}
|
||||
fileTypeLabel={fileTypeLabel}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
82
frontend/src/components/shared/ManifestToolIcon.tsx
Normal file
82
frontend/src/components/shared/ManifestToolIcon.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Barcode,
|
||||
Code,
|
||||
Crop,
|
||||
Droplets,
|
||||
Eraser,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Film,
|
||||
GitBranch,
|
||||
Hash,
|
||||
Image,
|
||||
ImageIcon,
|
||||
Languages,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
PenLine,
|
||||
Presentation,
|
||||
QrCode,
|
||||
RotateCw,
|
||||
ScanText,
|
||||
Scaling,
|
||||
Scissors,
|
||||
Sheet,
|
||||
Table,
|
||||
Unlock,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ICON_MAP = {
|
||||
ArrowUpDown,
|
||||
Barcode,
|
||||
Code,
|
||||
Crop,
|
||||
Droplets,
|
||||
Eraser,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Film,
|
||||
GitBranch,
|
||||
Hash,
|
||||
Image,
|
||||
ImageIcon,
|
||||
Languages,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
PenLine,
|
||||
Presentation,
|
||||
QrCode,
|
||||
RotateCw,
|
||||
ScanText,
|
||||
Scaling,
|
||||
Scissors,
|
||||
Sheet,
|
||||
Table,
|
||||
Unlock,
|
||||
Wrench,
|
||||
} as const;
|
||||
|
||||
interface ManifestToolIconProps {
|
||||
iconName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ManifestToolIcon({
|
||||
iconName,
|
||||
className = 'h-6 w-6',
|
||||
}: ManifestToolIconProps) {
|
||||
const Icon = ICON_MAP[iconName as keyof typeof ICON_MAP] ?? FileText;
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
43
frontend/src/components/shared/SectionIntro.tsx
Normal file
43
frontend/src/components/shared/SectionIntro.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
interface SectionIntroProps {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
align?: 'left' | 'center';
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
}
|
||||
|
||||
export default function SectionIntro({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
align = 'left',
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
}: SectionIntroProps) {
|
||||
const alignmentClassName = align === 'center' ? 'mx-auto max-w-3xl text-center' : 'max-w-3xl';
|
||||
|
||||
return (
|
||||
<div className={[alignmentClassName, className].filter(Boolean).join(' ')}>
|
||||
{eyebrow ? <p className="section-kicker">{eyebrow}</p> : null}
|
||||
<h2 className={[
|
||||
'mt-3 text-3xl font-black tracking-tight text-slate-950 dark:text-white sm:text-4xl',
|
||||
titleClassName,
|
||||
].filter(Boolean).join(' ')}>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p
|
||||
className={[
|
||||
'mt-4 text-base leading-7 text-slate-600 dark:text-slate-300 sm:text-lg',
|
||||
descriptionClassName,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Star } from 'lucide-react';
|
||||
@@ -12,8 +12,27 @@ interface SocialProofStripProps {
|
||||
export default function SocialProofStrip({ className = '' }: SocialProofStripProps) {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = useState<PublicStatsSummary | null>(null);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let cancelled = false;
|
||||
|
||||
getPublicStats()
|
||||
@@ -31,11 +50,12 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible]);
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
aria-hidden="true"
|
||||
className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}
|
||||
>
|
||||
@@ -97,7 +117,7 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
].filter((card): card is { label: string; value: string } => Boolean(card));
|
||||
|
||||
return (
|
||||
<section className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
|
||||
<section ref={sectionRef} className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
|
||||
@@ -172,10 +172,10 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
|
||||
{isUploading ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
@@ -195,8 +195,8 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-green-900 dark:text-green-200">Success!</h2>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">Your file is ready</p>
|
||||
<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">{t('result.fileReady')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,15 +208,15 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-red-900 dark:text-red-200">Error</h2>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error || 'Processing failed'}</p>
|
||||
<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 || t('common.errors.processingFailed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
Process Another
|
||||
{t('result.processAnother')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function BarcodeGenerator() {
|
||||
{phase === 'done' && downloadUrl && (
|
||||
<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">
|
||||
<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 className="flex gap-3">
|
||||
<a href={downloadUrl} download className="btn-primary flex-1">{t('common.download')}</a>
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function ImageResize() {
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="e.g. 800"
|
||||
placeholder={t('tools.imageResize.widthPlaceholder')}
|
||||
value={width}
|
||||
onChange={(e) => {
|
||||
setWidth(e.target.value);
|
||||
@@ -160,7 +160,7 @@ export default function ImageResize() {
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="e.g. 600"
|
||||
placeholder={t('tools.imageResize.heightPlaceholder')}
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
setHeight(e.target.value);
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function QrCodeGenerator() {
|
||||
{phase === 'done' && result && result.status === 'completed' && downloadUrl && (
|
||||
<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">
|
||||
<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 className="flex gap-3">
|
||||
<a href={downloadUrl} download={result.filename || 'qrcode.png'}
|
||||
|
||||
@@ -79,11 +79,11 @@ export const TOOLS_SEO: ToolSEO[] = [
|
||||
{
|
||||
i18nKey: 'compressPdf',
|
||||
slug: 'compress-pdf',
|
||||
titleSuffix: 'Free Online PDF Compressor — Reduce File Size',
|
||||
metaDescription: 'Compress PDF files online for free. Reduce PDF size by up to 90% while maintaining quality. Fast and secure PDF compression.',
|
||||
titleSuffix: 'Free Online PDF Compressor to Reduce PDF File Size',
|
||||
metaDescription: 'Compress PDF files online for free. Reduce PDF file size for email, uploads, and sharing while keeping text readable and document quality under control.',
|
||||
category: 'PDF',
|
||||
relatedSlugs: ['merge-pdf', 'split-pdf', 'pdf-to-word', 'compress-image'],
|
||||
keywords: 'compress pdf, reduce pdf size, pdf compressor, shrink pdf, make pdf smaller',
|
||||
keywords: 'compress pdf, pdf compressor, reduce pdf file size, compress pdf online, make pdf smaller',
|
||||
features: [
|
||||
'Reduce PDF file size by up to 90%',
|
||||
'Choose your compression level (low, medium, high)',
|
||||
@@ -92,10 +92,10 @@ export const TOOLS_SEO: ToolSEO[] = [
|
||||
'Process files securely on our servers',
|
||||
],
|
||||
faqs: [
|
||||
{ question: 'How does PDF compression work?', answer: 'Our tool optimizes images, removes unnecessary metadata, and compresses internal structures to reduce file size while maintaining visual quality.' },
|
||||
{ question: 'Will compression affect text quality?', answer: 'No. Text remains crisp and searchable. Mainly images within the PDF are optimized to reduce file size.' },
|
||||
{ question: 'How much can I reduce my PDF size?', answer: 'Depending on the content, you can typically reduce file size by 50-90%, especially for PDFs with many images.' },
|
||||
{ question: 'Is there a file size limit?', answer: 'You can compress PDFs up to 20MB in size.' },
|
||||
{ question: 'How do I compress a PDF online?', answer: 'Upload your PDF, choose the compression level you want, start the process, and download the smaller PDF when it is ready.' },
|
||||
{ question: 'How can I make a PDF smaller for email or uploads?', answer: 'Use the balanced or maximum compression setting to reduce PDF file size until it fits common email and upload limits.' },
|
||||
{ question: 'Will PDF compression reduce quality?', answer: 'Compression mainly optimizes images and embedded assets. Text usually stays sharp and searchable, while visual quality depends on the compression level you choose.' },
|
||||
{ question: 'Does compression change my original PDF?', answer: 'No. The tool creates a compressed copy for download and leaves your original file unchanged.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -123,11 +123,11 @@ export const TOOLS_SEO: ToolSEO[] = [
|
||||
{
|
||||
i18nKey: 'splitPdf',
|
||||
slug: 'split-pdf',
|
||||
titleSuffix: 'Free Online PDF Splitter — Extract Pages',
|
||||
metaDescription: 'Split PDF files into individual pages or extract specific page ranges online for free. Fast, secure, and no signup needed.',
|
||||
titleSuffix: 'Free Online PDF Splitter to Split or Separate PDF Pages',
|
||||
metaDescription: 'Split PDF files online for free. Use this PDF splitter to separate PDF pages, cut page ranges, or save selected pages into smaller PDF files without losing quality.',
|
||||
category: 'PDF',
|
||||
relatedSlugs: ['merge-pdf', 'extract-pages', 'rotate-pdf', 'reorder-pdf'],
|
||||
keywords: 'split pdf, pdf splitter, extract pages from pdf, separate pdf pages, divide pdf',
|
||||
keywords: 'split pdf, pdf splitter, separate pdf pages, split pdf online, pdf cutter, divide pdf',
|
||||
features: [
|
||||
'Split a PDF into individual pages',
|
||||
'Extract specific page ranges',
|
||||
@@ -136,9 +136,10 @@ export const TOOLS_SEO: ToolSEO[] = [
|
||||
'Secure — files deleted after processing',
|
||||
],
|
||||
faqs: [
|
||||
{ question: 'How do I split a PDF?', answer: 'Upload your PDF, specify the pages or ranges you want to extract, and click split. Download the resulting PDF instantly.' },
|
||||
{ question: 'Can I extract specific pages?', answer: 'Yes, you can specify individual pages (e.g., 1, 3, 5) or ranges (e.g., 1-5) to extract.' },
|
||||
{ question: 'Is splitting a PDF free?', answer: 'Yes, our PDF splitter is completely free with no limitations.' },
|
||||
{ question: 'How do I split a PDF online?', answer: 'Upload your PDF, choose whether to split every page or only selected page ranges, then download the new PDF files created from your document.' },
|
||||
{ question: 'Can I separate PDF pages without splitting the whole file?', answer: 'Yes. You can enter exact page numbers or ranges so only the pages you want are saved into new files.' },
|
||||
{ question: 'Will splitting a PDF reduce quality?', answer: 'No. Splitting is a structural change, so the pages keep their original quality and layout.' },
|
||||
{ question: 'What is the difference between Split PDF and Extract Pages?', answer: 'Split PDF is best when you want separate output files or broad page separation. Extract Pages is better when you want selected pages combined into one new PDF.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -315,11 +316,11 @@ export const TOOLS_SEO: ToolSEO[] = [
|
||||
{
|
||||
i18nKey: 'extractPages',
|
||||
slug: 'extract-pages',
|
||||
titleSuffix: 'Free Online PDF Page Extractor',
|
||||
metaDescription: 'Extract specific pages from a PDF into a new document online for free. Select the exact pages you need.',
|
||||
titleSuffix: 'Free Online PDF Page Extractor to Extract Pages from PDF',
|
||||
metaDescription: 'Extract pages from PDF online for free. Select exact page numbers or ranges to create a new PDF with only the pages you need.',
|
||||
category: 'PDF',
|
||||
relatedSlugs: ['split-pdf', 'merge-pdf', 'reorder-pdf', 'compress-pdf'],
|
||||
keywords: 'extract pdf pages, pdf page extractor, select pages from pdf, copy pdf pages',
|
||||
keywords: 'extract pages from pdf, pdf page extractor, extract pdf pages, pdf extractor, save selected pages from pdf',
|
||||
features: [
|
||||
'Extract specific pages from any PDF',
|
||||
'Select individual pages or ranges',
|
||||
@@ -327,8 +328,10 @@ export const TOOLS_SEO: ToolSEO[] = [
|
||||
'Free and completely secure',
|
||||
],
|
||||
faqs: [
|
||||
{ question: 'How do I extract pages from a PDF?', answer: 'Upload your PDF, select the pages you want (e.g., 1, 3, 5-10), and download the new PDF containing only those pages.' },
|
||||
{ question: 'What is the difference between Split and Extract?', answer: 'Split divides a PDF at a specific point, while Extract lets you pick any combination of pages.' },
|
||||
{ question: 'How do I extract pages from a PDF?', answer: 'Upload your PDF, enter the pages or ranges you want to keep, and download the new PDF containing only those selected pages.' },
|
||||
{ question: 'Can I extract multiple non-consecutive pages?', answer: 'Yes. You can extract pages like 1,3,7 as well as ranges such as 5-10 in the same request.' },
|
||||
{ question: 'What is the difference between Extract Pages and Split PDF?', answer: 'Extract Pages creates one new PDF from the exact pages you choose. Split PDF is better when you want broader page separation or multiple outputs.' },
|
||||
{ question: 'Will the original PDF stay unchanged?', answer: 'Yes. The original file is not edited. The tool creates a separate PDF that contains only the extracted pages.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,15 +58,15 @@ describe('Tool Manifest ↔ SEO Data sync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Manifest ↔ HomePage ICON_MAP sync', () => {
|
||||
const homePageSource = readFileSync(
|
||||
resolve(__dirname, '../pages/HomePage.tsx'),
|
||||
describe('Tool Manifest ↔ ManifestToolIcon ICON_MAP sync', () => {
|
||||
const iconSource = readFileSync(
|
||||
resolve(__dirname, '../components/shared/ManifestToolIcon.tsx'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Extract icon names from the ICON_MAP object literal
|
||||
// Match from "= {" to "};" to skip the type annotation that also contains braces
|
||||
const iconMapMatch = homePageSource.match(/ICON_MAP[^=]+=\s*\{([\s\S]+?)\};/);
|
||||
const iconMapMatch = iconSource.match(/ICON_MAP[^=]+=\s*\{([\s\S]+?)\}\s*as\s+const/);
|
||||
const iconMapKeys = new Set(
|
||||
iconMapMatch
|
||||
? iconMapMatch[1]
|
||||
|
||||
@@ -590,6 +590,11 @@ export function getHomepageTools(section: 'pdf' | 'other'): readonly ToolEntry[]
|
||||
return TOOL_MANIFEST.filter((t) => t.homepage && t.homepageSection === section);
|
||||
}
|
||||
|
||||
/** Tools grouped by portfolio category */
|
||||
export function getToolsByCategory(category: ToolCategory): readonly ToolEntry[] {
|
||||
return TOOL_MANIFEST.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
/** Lookup a single tool by slug */
|
||||
export function getToolEntry(slug: string): ToolEntry | undefined {
|
||||
return TOOL_MANIFEST.find((t) => t.slug === slug);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"appName": "Dociva",
|
||||
"tagline": "أدوات ملفات مجانية على الإنترنت",
|
||||
@@ -35,6 +35,11 @@
|
||||
"subject": "الموضوع",
|
||||
"message": "الرسالة",
|
||||
"name": "الاسم",
|
||||
"siteTagline": "سير عمل PDF والملفات عبر الإنترنت",
|
||||
"footerDescription": "حوّل، اضغط، عدّل، وأتمت عمل المستندات في مساحة عمل واحدة تعمل في المتصفح، مصممة للسرعة والوضوح والمعالجة الآمنة.",
|
||||
"uploading": "جارٍ الرفع...",
|
||||
"convert": "تحويل",
|
||||
"sending": "جارٍ الإرسال...",
|
||||
"errors": {
|
||||
"fileTooLarge": "حجم الملف كبير جدًا. الحد الأقصى المسموح {{size}} ميجابايت.",
|
||||
"invalidFileType": "نوع الملف غير صالح. الأنواع المقبولة: {{types}}",
|
||||
@@ -44,7 +49,19 @@
|
||||
"rateLimited": "طلبات كثيرة جدًا. يرجى الانتظار لحظة والمحاولة مجددًا.",
|
||||
"serverError": "حدث خطأ في الخادم. يرجى المحاولة لاحقًا.",
|
||||
"networkError": "خطأ في الشبكة. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
|
||||
"noFileSelected": "لم يتم اختيار ملف. يرجى اختيار ملف للرفع."
|
||||
"noFileSelected": "لم يتم اختيار ملف. يرجى اختيار ملف للرفع.",
|
||||
"aiUnavailable": "ميزات الذكاء الاصطناعي غير متاحة مؤقتاً. يرجى المحاولة لاحقاً.",
|
||||
"aiRateLimited": "خدمة الذكاء الاصطناعي مشغولة حالياً. يرجى المحاولة بعد قليل.",
|
||||
"aiBudgetExceeded": "تم استنفاد حصة معالجة الذكاء الاصطناعي. يرجى المحاولة لاحقاً.",
|
||||
"pdfEncrypted": "هذا الـ PDF محمي بكلمة مرور. يرجى إلغاء قفله أولاً.",
|
||||
"pdfTextEmpty": "لم يُعثر على نص قابل للقراءة في هذا الـ PDF.",
|
||||
"pdfNoTables": "لم تُعثر على جداول في هذا الـ PDF.",
|
||||
"taskUnavailable": "الخدمة غير متاحة مؤقتاً. يرجى إعادة المحاولة بعد لحظة.",
|
||||
"translationFailed": "فشلت خدمة الترجمة. يرجى المحاولة مرة أخرى.",
|
||||
"invalidInput": "مدخلات غير صالحة. يرجى التحقق من إعداداتك والمحاولة مرة أخرى.",
|
||||
"genericTitle": "حدث خطأ ما",
|
||||
"genericDesc": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.",
|
||||
"tryAgain": "حاول مرة أخرى"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -141,7 +158,31 @@
|
||||
"feature2Title": "دقة يمكنك الوثوق بها",
|
||||
"feature2Desc": "احصل على ملفات دقيقة وقابلة للتعديل في ثوانٍ بدون فقدان للجودة.",
|
||||
"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": {
|
||||
"badge": "موثوق من فرق نشطة",
|
||||
@@ -173,7 +214,11 @@
|
||||
"أدوات المستندات بالذكاء الاصطناعي — التحدث مع PDF، التلخيص، الترجمة، استخراج الجداول",
|
||||
"OCR — استخراج النص من الصور وملفات PDF الممسوحة ضوئياً بالعربية والإنجليزية والفرنسية",
|
||||
"أدوات مساعدة — مولد QR، تحويل فيديو إلى GIF، عداد الكلمات، منظف النصوص"
|
||||
]
|
||||
],
|
||||
"heroTitle": "تمكين إنتاجية المستندات في كل مكان",
|
||||
"teamTitle": "فريقنا",
|
||||
"valuesTitle": "قيمنا",
|
||||
"ctaText": "هل لديك أسئلة؟ تواصل معنا."
|
||||
},
|
||||
"contact": {
|
||||
"metaDescription": "تواصل مع فريق Dociva. أبلغ عن خطأ أو اطلب ميزة جديدة أو أرسل لنا رسالة.",
|
||||
@@ -194,7 +239,12 @@
|
||||
"subjectPlaceholder": "الموضوع",
|
||||
"successMessage": "تم إرسال رسالتك! سنرد عليك قريباً.",
|
||||
"directEmail": "أو راسلنا مباشرة على",
|
||||
"responseTime": "نرد عادةً خلال 24-48 ساعة."
|
||||
"responseTime": "نرد عادةً خلال 24-48 ساعة.",
|
||||
"emailLabel": "البريد الإلكتروني:",
|
||||
"phoneLabel": "الهاتف:",
|
||||
"officeLabel": "المكتب:",
|
||||
"connectTitle": "تواصل معنا",
|
||||
"faqTitle": "الأسئلة الشائعة"
|
||||
},
|
||||
"privacy": {
|
||||
"metaDescription": "سياسة الخصوصية لـ Dociva. تعرّف على كيفية تعاملنا مع ملفاتك وبياناتك بشفافية كاملة.",
|
||||
@@ -623,7 +673,7 @@
|
||||
},
|
||||
"compressPdf": {
|
||||
"title": "ضغط PDF",
|
||||
"description": "قلّل حجم ملف PDF مع الحفاظ على الجودة. اختر مستوى الضغط.",
|
||||
"description": "اضغط ملفات PDF عبر الإنترنت وقلّل الحجم مع الحفاظ على وضوح المحتوى.",
|
||||
"shortDesc": "ضغط PDF",
|
||||
"qualityLow": "أقصى ضغط",
|
||||
"qualityMedium": "متوازن",
|
||||
@@ -643,7 +693,9 @@
|
||||
"height": "الارتفاع (بكسل)",
|
||||
"quality": "الجودة",
|
||||
"lockAspect": "قفل نسبة العرض للارتفاع",
|
||||
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع."
|
||||
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع.",
|
||||
"widthPlaceholder": "مثال: 800",
|
||||
"heightPlaceholder": "مثال: 600"
|
||||
},
|
||||
"imageToSvg": {
|
||||
"title": "تحويل الصورة إلى SVG",
|
||||
@@ -716,7 +768,7 @@
|
||||
},
|
||||
"splitPdf": {
|
||||
"title": "تقسيم PDF",
|
||||
"description": "قسّم ملف PDF إلى صفحات فردية أو استخرج نطاقات صفحات محددة.",
|
||||
"description": "قسّم صفحات PDF عبر الإنترنت أو افصل نطاقات صفحات محددة في ملفات جديدة.",
|
||||
"shortDesc": "تقسيم PDF",
|
||||
"allPages": "كل الصفحات",
|
||||
"allPagesDesc": "استخراج كل صفحة في ملف PDF مستقل",
|
||||
@@ -977,7 +1029,7 @@
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "استخراج صفحات PDF",
|
||||
"description": "استخرج صفحات محددة من PDF إلى مستند جديد.",
|
||||
"description": "استخرج صفحات من PDF إلى ملف جديد باستخدام أرقام صفحات أو نطاقات دقيقة.",
|
||||
"shortDesc": "استخراج الصفحات",
|
||||
"pagesLabel": "الصفحات المطلوبة",
|
||||
"pagesPlaceholder": "مثال: 1,3,5-8",
|
||||
@@ -989,7 +1041,8 @@
|
||||
"shortDesc": "إنشاء رمز QR",
|
||||
"dataLabel": "نص أو رابط",
|
||||
"dataPlaceholder": "أدخل نصاً أو رابطاً أو أي بيانات...",
|
||||
"sizeLabel": "الحجم"
|
||||
"sizeLabel": "الحجم",
|
||||
"altText": "رمز QR المُولَّد"
|
||||
},
|
||||
"htmlToPdf": {
|
||||
"title": "HTML إلى PDF",
|
||||
@@ -1124,7 +1177,29 @@
|
||||
"dataLabel": "بيانات الباركود",
|
||||
"dataPlaceholder": "أدخل البيانات للترميز...",
|
||||
"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": {
|
||||
@@ -1208,7 +1283,10 @@
|
||||
"newSize": "الحجم الجديد",
|
||||
"reduction": "نسبة التقليل",
|
||||
"downloadReady": "ملفك جاهز للتحميل.",
|
||||
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة."
|
||||
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة.",
|
||||
"success": "تم بنجاح!",
|
||||
"fileReady": "ملفك جاهز",
|
||||
"processAnother": "معالجة ملف آخر"
|
||||
},
|
||||
"downloadGate": {
|
||||
"title": "سجّل لتحميل ملفك",
|
||||
@@ -1257,15 +1335,17 @@
|
||||
]
|
||||
},
|
||||
"compressPdf": {
|
||||
"whatItDoes": "قلّل حجم ملفات PDF بنسبة تصل إلى 90% مع الحفاظ على قابلية القراءة والجودة العالية. اختر من بين ثلاثة مستويات ضغط لتحقيق التوازن بين الجودة وحجم الملف.",
|
||||
"howToUse": ["ارفع ملف PDF إلى أداة الضغط.", "اختر مستوى الضغط المفضل: أقصى ضغط، متوازن، أو جودة عالية.", "انقر ضغط وانتظر المعالجة.", "حمّل ملف PDF المضغوط بحجم أصغر بكثير."],
|
||||
"benefits": ["تقليل حجم الملف بنسبة تصل إلى 90%", "ثلاثة مستويات ضغط للاختيار", "النص يبقى واضحاً وقابلاً للبحث", "مثالي لمرفقات البريد الإلكتروني", "مجاني بدون تسجيل"],
|
||||
"useCases": ["تصغير ملفات PDF الكبيرة لإرسالها بالبريد الإلكتروني", "تقليل مساحة التخزين للمستندات المؤرشفة", "تسريع رفع ملفات PDF على المواقع", "تحسين ملفات PDF للعرض على الهاتف", "تحضير المستندات للنشر على الويب"],
|
||||
"metaTitleSuffix": "أداة مجانية عبر الإنترنت لضغط PDF وتقليل حجم الملف",
|
||||
"metaDescription": "اضغط ملفات PDF عبر الإنترنت مجاناً. قلّل حجم ملف PDF للبريد الإلكتروني والرفع والمشاركة مع الحفاظ على وضوح النص وجودة مناسبة.",
|
||||
"whatItDoes": "استخدم أداة ضغط PDF هذه لتقليل حجم ملفات PDF قبل إرسالها بالبريد الإلكتروني أو رفعها أو أرشفتها. تقوم الأداة بتحسين الصور وبنية الملف مع الحفاظ على وضوح النص وسهولة القراءة.",
|
||||
"howToUse": ["ارفع ملف PDF الذي تريد ضغطه.", "اختر مستوى الضغط المناسب: أقصى ضغط أو متوازن أو جودة عالية.", "ابدأ الضغط وانتظر إنشاء الملف الأصغر.", "حمّل ملف PDF المضغوط وشاركه أو ارفعه مباشرة."],
|
||||
"benefits": ["تصغير ملفات PDF الكبيرة لمرفقات البريد ونماذج الرفع", "اختيار توازن مناسب بين الحجم الصغير والجودة البصرية", "الحفاظ على النص واضحاً وقابلاً للبحث بعد الضغط", "العمل مباشرة من المتصفح بدون تسجيل", "معالجة آمنة مع حذف تلقائي للملفات"],
|
||||
"useCases": ["تقليل حجم PDF قبل إرساله كمرفق بريد إلكتروني", "تجاوز حدود الرفع في النماذج والمنصات المختلفة", "تصغير ملفات PDF الممسوحة ضوئياً والغنية بالصور", "توفير مساحة التخزين للملفات المؤرشفة", "تجهيز ملفات PDF لتنزيل أسرع على الهاتف"],
|
||||
"faq": [
|
||||
{"q": "كيف يعمل ضغط PDF؟", "a": "تقوم الأداة بتحسين الصور وإزالة البيانات الوصفية غير الضرورية وضغط الهياكل الداخلية لتقليل حجم الملف مع الحفاظ على الجودة المرئية."},
|
||||
{"q": "هل سيؤثر الضغط على جودة النص؟", "a": "لا. يبقى النص واضحاً وقابلاً للبحث. يتم تحسين الصور بشكل أساسي لتقليل الحجم."},
|
||||
{"q": "كم يمكنني تقليل حجم PDF؟", "a": "حسب المحتوى، يمكنك عادةً تقليل الحجم بنسبة 50-90%، خاصةً للملفات التي تحتوي على صور كثيرة."},
|
||||
{"q": "هل يوجد حد لحجم الملف؟", "a": "يمكنك ضغط ملفات PDF بحجم يصل إلى 20 ميجابايت."}
|
||||
{"q": "كيف أضغط ملف PDF عبر الإنترنت؟", "a": "ارفع ملف PDF، اختر مستوى الضغط المطلوب، ابدأ المعالجة، ثم حمّل الملف الأصغر عندما يصبح جاهزاً."},
|
||||
{"q": "كيف أجعل ملف PDF أصغر للبريد الإلكتروني أو الرفع؟", "a": "استخدم الإعداد المتوازن أو أقصى ضغط لتقليل حجم ملف PDF حتى يناسب حدود البريد الإلكتروني أو الرفع الشائعة."},
|
||||
{"q": "هل يقلل ضغط PDF من الجودة؟", "a": "يركز الضغط بشكل أساسي على تحسين الصور والعناصر المضمنة. يبقى النص غالباً واضحاً وقابلاً للبحث، بينما تعتمد الجودة البصرية على مستوى الضغط الذي تختاره."},
|
||||
{"q": "هل يغيّر الضغط ملف PDF الأصلي؟", "a": "لا. تنشئ الأداة نسخة مضغوطة للتحميل وتترك الملف الأصلي بدون تغيير."}
|
||||
]
|
||||
},
|
||||
"mergePdf": {
|
||||
@@ -1281,14 +1361,17 @@
|
||||
]
|
||||
},
|
||||
"splitPdf": {
|
||||
"whatItDoes": "قسّم مستند PDF إلى ملفات منفصلة. يمكنك تقسيم كل صفحة إلى ملف فردي أو استخراج نطاقات صفحات محددة. مثالي لعزل أقسام من مستندات كبيرة.",
|
||||
"howToUse": ["ارفع مستند PDF.", "اختر تقسيم جميع الصفحات أو تحديد صفحات/نطاقات محددة.", "أدخل أرقام الصفحات (مثل 1,3,5-8) لاستخراج صفحات محددة.", "حمّل ملفات PDF الناتجة."],
|
||||
"benefits": ["تقسيم إلى صفحات فردية أو نطاقات مخصصة", "صيغة بسيطة لنطاقات الصفحات", "بدون فقدان الجودة", "مجاني بدون تسجيل", "يعمل مع أي مستند PDF"],
|
||||
"useCases": ["استخراج فصل معين من كتاب إلكتروني", "إرسال صفحات محددة فقط لزميل", "تقسيم دليل كبير إلى أقسام", "عزل صفحة واحدة للطباعة", "فصل مستند ممسوح ضوئياً متعدد الصفحات"],
|
||||
"metaTitleSuffix": "أداة مجانية عبر الإنترنت لتقسيم PDF وفصل الصفحات",
|
||||
"metaDescription": "قسّم ملفات PDF عبر الإنترنت مجاناً. افصل صفحات PDF أو قص نطاقات صفحات محددة وأنشئ ملفات أصغر بدون فقدان الجودة.",
|
||||
"whatItDoes": "استخدم أداة تقسيم PDF هذه لتقسيم الصفحات إلى ملفات منفصلة أو لتجزئة مستند طويل إلى أقسام أصغر. يمكنك فصل صفحات PDF صفحة بصفحة أو حفظ النطاقات التي تحتاجها فقط.",
|
||||
"howToUse": ["ارفع ملف PDF.", "اختر ما إذا كنت تريد تقسيم كل الصفحات أو فصل صفحات أو نطاقات محددة فقط.", "أدخل أرقام الصفحات مثل 1,3,5-8 عندما تريد ناتجاً مخصصاً.", "حمّل ملفات PDF الجديدة التي تم إنشاؤها من الصفحات المختارة."],
|
||||
"benefits": ["تقسيم صفحات PDF بشكل فردي أو حسب نطاق مخصص", "فصل صفحات PDF بدون تغيير الجودة الأصلية", "إرسال الصفحات المطلوبة فقط بدلاً من المستند الكامل", "معالجة سريعة من المتصفح بدون تسجيل", "مناسب للتقارير والعقود والملفات الممسوحة ضوئياً"],
|
||||
"useCases": ["تقسيم ملف PDF كبير إلى ملفات أصغر للزملاء أو العملاء", "فصل فصل أو ملحق من تقرير طويل", "قص صفحات محددة من مستند ممسوح ضوئياً", "إنشاء ملفات PDF أصغر لتناسب البريد أو الرفع", "الاحتفاظ بالصفحات المطلوبة فقط للمراجعة أو الطباعة"],
|
||||
"faq": [
|
||||
{"q": "كيف أقسّم ملف PDF؟", "a": "ارفع PDF، حدد الصفحات أو النطاقات المطلوبة، وانقر تقسيم. حمّل PDF الناتج فوراً."},
|
||||
{"q": "هل يمكنني استخراج صفحات محددة؟", "a": "نعم، يمكنك تحديد صفحات فردية (مثل 1, 3, 5) أو نطاقات (مثل 1-5) للاستخراج."},
|
||||
{"q": "هل تقسيم PDF مجاني؟", "a": "نعم، أداة تقسيم PDF مجانية تماماً بدون قيود."}
|
||||
{"q": "كيف أقسّم ملف PDF عبر الإنترنت؟", "a": "ارفع ملف PDF، اختر ما إذا كنت تريد تقسيم كل الصفحات أو نطاقات محددة فقط، ثم حمّل ملفات PDF الجديدة الناتجة من المستند."},
|
||||
{"q": "هل يمكنني فصل صفحات PDF بدون تقسيم الملف بالكامل؟", "a": "نعم. يمكنك إدخال أرقام صفحات أو نطاقات دقيقة بحيث يتم حفظ الصفحات المطلوبة فقط في ملفات جديدة."},
|
||||
{"q": "هل يؤدي تقسيم PDF إلى تقليل الجودة؟", "a": "لا. تقسيم PDF هو تغيير في بنية الملف فقط، لذلك تحتفظ الصفحات بجودتها وتخطيطها الأصليين."},
|
||||
{"q": "ما الفرق بين تقسيم PDF واستخراج الصفحات؟", "a": "تقسيم PDF مناسب عندما تريد ملفات خرج منفصلة أو فصل الصفحات بشكل واسع. أما استخراج الصفحات فهو أفضل عندما تريد دمج الصفحات المختارة في ملف PDF جديد واحد."}
|
||||
]
|
||||
},
|
||||
"rotatePdf": {
|
||||
@@ -1391,14 +1474,17 @@
|
||||
]
|
||||
},
|
||||
"extractPages": {
|
||||
"whatItDoes": "استخرج صفحات محددة من PDF وأنشئ مستنداً جديداً يحتوي فقط على الصفحات التي اخترتها. اختر صفحات فردية أو نطاقات صفحات بصيغة بسيطة.",
|
||||
"howToUse": ["ارفع مستند PDF.", "أدخل أرقام الصفحات أو النطاقات (مثل 1,3,5-8).", "انقر استخراج لإنشاء PDF جديد.", "حمّل PDF بالصفحات المختارة فقط."],
|
||||
"benefits": ["استخراج صفحات فردية أو نطاقات", "صيغة بسيطة بفواصل", "المستند الأصلي يبقى بدون تغيير", "مجاني وآمن تماماً", "معالجة سريعة"],
|
||||
"useCases": ["استخراج فصل واحد من كتاب إلكتروني", "الحصول على صفحات محددة لعرض تقديمي", "إنشاء مستند فرعي للمراجعة", "سحب صفحات من مستند ممسوح ضوئياً متعدد الصفحات", "عزل صفحة مهمة لمشاركتها بشكل منفصل"],
|
||||
"metaTitleSuffix": "أداة مجانية عبر الإنترنت لاستخراج صفحات من PDF",
|
||||
"metaDescription": "استخرج صفحات من PDF عبر الإنترنت مجاناً. حدّد أرقام الصفحات أو النطاقات الدقيقة لإنشاء ملف PDF جديد يحتوي فقط على الصفحات المطلوبة.",
|
||||
"whatItDoes": "تتيح لك أداة استخراج صفحات PDF هذه سحب صفحات محددة من ملف PDF ودمجها في ملف جديد واحد. وهي مناسبة عندما تحتاج إلى استخراج صفحات من PDF بدون تقسيم كل صفحة.",
|
||||
"howToUse": ["ارفع مستند PDF.", "أدخل الصفحات الدقيقة أو النطاقات التي تريد الاحتفاظ بها مثل 2,4,7-10.", "انقر استخراج لإنشاء PDF جديد يحتوي فقط على تلك الصفحات.", "حمّل ملف PDF المستخرج وشاركه أو أكمل العمل عليه."],
|
||||
"benefits": ["استخراج الصفحات التي تحتاجها فقط في ملف PDF نظيف واحد", "دعم أرقام الصفحات الدقيقة ونطاقات الصفحات", "ترك ملف PDF الأصلي بدون تغيير", "مفيد للنماذج والعقود والفصول والملفات الممسوحة ضوئياً", "معالجة سريعة وآمنة مع تنظيف تلقائي"],
|
||||
"useCases": ["إرسال عدة صفحات مطلوبة من حزمة مستندات طويلة", "إنشاء نسخة مراجعة تحتوي على فصول مختارة فقط", "حفظ فاتورة أو نموذج أو ملحق من ملف PDF أكبر", "سحب الصفحات المهمة من مستند ممسوح ضوئياً متعدد الصفحات", "تحضير مستند أصغر قبل الدمج أو التوقيع"],
|
||||
"faq": [
|
||||
{"q": "كيف أستخرج صفحات من PDF؟", "a": "ارفع PDF، أدخل الصفحات المطلوبة (مثل 1,3,5-8)، وحمّل PDF الجديد الذي يحتوي فقط على تلك الصفحات."},
|
||||
{"q": "ما الفرق بين التقسيم والاستخراج؟", "a": "التقسيم يقسم كل صفحة إلى ملفات منفصلة، بينما الاستخراج يتيح لك اختيار أي مجموعة من الصفحات المحددة في مستند واحد جديد."},
|
||||
{"q": "هل يمكنني استخراج الصفحات بترتيب مختلف؟", "a": "يتم استخراج الصفحات بالترتيب المحدد. استخدم أداة إعادة الترتيب لمزيد من التحكم في ترتيب الصفحات."}
|
||||
{"q": "كيف أستخرج صفحات من PDF؟", "a": "ارفع ملف PDF، أدخل الصفحات أو النطاقات التي تريد الاحتفاظ بها، ثم حمّل ملف PDF الجديد الذي يحتوي فقط على تلك الصفحات المختارة."},
|
||||
{"q": "هل يمكنني استخراج عدة صفحات غير متتالية؟", "a": "نعم. يمكنك استخراج صفحات مثل 1,3,7 بالإضافة إلى نطاقات مثل 5-10 في الطلب نفسه."},
|
||||
{"q": "ما الفرق بين استخراج الصفحات وتقسيم PDF؟", "a": "استخراج الصفحات ينشئ ملف PDF جديداً واحداً من الصفحات التي تحددها بدقة. أما تقسيم PDF فهو أفضل عندما تريد فصل الصفحات على نطاق أوسع أو إنشاء عدة ملفات."},
|
||||
{"q": "هل يبقى ملف PDF الأصلي بدون تغيير؟", "a": "نعم. لا يتم تعديل الملف الأصلي. تنشئ الأداة ملف PDF منفصلاً يحتوي فقط على الصفحات المستخرجة."}
|
||||
]
|
||||
},
|
||||
"pdfEditor": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"appName": "Dociva",
|
||||
"tagline": "Free Online File Tools",
|
||||
@@ -35,6 +35,11 @@
|
||||
"subject": "Subject",
|
||||
"message": "Message",
|
||||
"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": {
|
||||
"fileTooLarge": "File is too large. Maximum size is {{size}}MB.",
|
||||
"invalidFileType": "Invalid file type. Accepted: {{types}}",
|
||||
@@ -44,7 +49,19 @@
|
||||
"rateLimited": "Too many requests. Please wait a moment and try again.",
|
||||
"serverError": "A server error occurred. Please try again later.",
|
||||
"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": {
|
||||
@@ -141,7 +158,31 @@
|
||||
"feature2Title": "Accuracy you can trust",
|
||||
"feature2Desc": "Get pixel-perfect, editable files in seconds with zero quality loss.",
|
||||
"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": {
|
||||
"badge": "Trusted by active teams",
|
||||
@@ -173,7 +214,11 @@
|
||||
"AI document tools — chat with PDFs, summarize, translate, extract tables",
|
||||
"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"
|
||||
]
|
||||
],
|
||||
"heroTitle": "Empowering Document Productivity Worldwide",
|
||||
"teamTitle": "Our Team",
|
||||
"valuesTitle": "Our Values",
|
||||
"ctaText": "Have questions? Get in touch."
|
||||
},
|
||||
"contact": {
|
||||
"metaDescription": "Contact the Dociva team. Report bugs, request features, or send us a message.",
|
||||
@@ -194,7 +239,12 @@
|
||||
"subjectPlaceholder": "Subject",
|
||||
"successMessage": "Your message has been sent! We'll get back to you soon.",
|
||||
"directEmail": "Or email us directly at",
|
||||
"responseTime": "We typically respond within 24–48 hours."
|
||||
"responseTime": "We typically respond within 24–48 hours.",
|
||||
"emailLabel": "Email:",
|
||||
"phoneLabel": "Phone:",
|
||||
"officeLabel": "Office:",
|
||||
"connectTitle": "Connect With Us",
|
||||
"faqTitle": "FAQ"
|
||||
},
|
||||
"privacy": {
|
||||
"metaDescription": "Privacy policy for Dociva. Learn how we handle your files and data with full transparency.",
|
||||
@@ -623,7 +673,7 @@
|
||||
},
|
||||
"compressPdf": {
|
||||
"title": "Compress PDF",
|
||||
"description": "Reduce PDF file size while maintaining quality. Choose your compression level.",
|
||||
"description": "Compress PDF files online and reduce file size without sacrificing readability.",
|
||||
"shortDesc": "Compress PDF",
|
||||
"qualityLow": "Maximum Compression",
|
||||
"qualityMedium": "Balanced",
|
||||
@@ -643,7 +693,9 @@
|
||||
"height": "Height (px)",
|
||||
"quality": "Quality",
|
||||
"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": {
|
||||
"title": "Image to SVG",
|
||||
@@ -716,7 +768,7 @@
|
||||
},
|
||||
"splitPdf": {
|
||||
"title": "Split PDF",
|
||||
"description": "Split a PDF into individual pages or extract specific page ranges.",
|
||||
"description": "Split PDF pages online or separate selected page ranges into new files.",
|
||||
"shortDesc": "Split PDF",
|
||||
"allPages": "All Pages",
|
||||
"allPagesDesc": "Extract every page as a separate PDF file",
|
||||
@@ -977,7 +1029,7 @@
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Extract PDF Pages",
|
||||
"description": "Extract specific pages from a PDF into a new document.",
|
||||
"description": "Extract pages from a PDF into a new document with exact page numbers or ranges.",
|
||||
"shortDesc": "Extract Pages",
|
||||
"pagesLabel": "Pages to Extract",
|
||||
"pagesPlaceholder": "e.g. 1,3,5-8",
|
||||
@@ -989,7 +1041,8 @@
|
||||
"shortDesc": "Generate QR Code",
|
||||
"dataLabel": "Text or URL",
|
||||
"dataPlaceholder": "Enter text, URL, or any data...",
|
||||
"sizeLabel": "Size"
|
||||
"sizeLabel": "Size",
|
||||
"altText": "Generated QR Code"
|
||||
},
|
||||
"htmlToPdf": {
|
||||
"title": "HTML to PDF",
|
||||
@@ -1124,7 +1177,29 @@
|
||||
"dataLabel": "Barcode Data",
|
||||
"dataPlaceholder": "Enter data to encode...",
|
||||
"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": {
|
||||
@@ -1208,7 +1283,10 @@
|
||||
"newSize": "New Size",
|
||||
"reduction": "Reduction",
|
||||
"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": {
|
||||
"title": "Sign up to download your file",
|
||||
@@ -1257,15 +1335,17 @@
|
||||
]
|
||||
},
|
||||
"compressPdf": {
|
||||
"whatItDoes": "Reduce the file size of your PDF documents by up to 90% while keeping them readable and high quality. Choose between three compression levels to balance quality and file size according to your needs.",
|
||||
"howToUse": ["Upload your PDF file to the compressor.", "Select your preferred compression level: Maximum, Balanced, or High Quality.", "Click compress and wait for processing.", "Download your compressed PDF with a significantly smaller file size."],
|
||||
"benefits": ["Reduce file size by up to 90%", "Three compression levels to choose from", "Text remains crisp and searchable", "Ideal for email attachments and uploads", "Free with no registration needed"],
|
||||
"useCases": ["Making large PDFs small enough to email", "Reducing storage space for archived documents", "Speeding up PDF uploads to websites", "Optimizing PDFs for mobile viewing", "Preparing documents for web publishing"],
|
||||
"metaTitleSuffix": "Free Online PDF Compressor to Reduce PDF File Size",
|
||||
"metaDescription": "Compress PDF files online for free. Reduce PDF file size for email, uploads, and sharing while keeping text readable and document quality under control.",
|
||||
"whatItDoes": "Use this online PDF compressor to reduce PDF file size for email, uploads, web sharing, and storage. It optimizes images and document structure while keeping text readable and the layout usable.",
|
||||
"howToUse": ["Upload the PDF you want to compress.", "Choose Maximum Compression, Balanced, or High Quality depending on how small the file needs to be.", "Start compression and wait for the smaller PDF to be generated.", "Download the compressed PDF and share it or upload it anywhere."],
|
||||
"benefits": ["Make large PDFs smaller for email and form uploads", "Choose the right trade-off between small size and visual quality", "Keep text sharp and searchable after compression", "Works directly in the browser with no signup", "Original document is processed securely and deleted automatically"],
|
||||
"useCases": ["Reducing a PDF before sending it as an email attachment", "Meeting upload limits on job portals, CRMs, or government forms", "Shrinking scanned image-heavy PDFs for faster sharing", "Saving cloud storage space for archived reports", "Preparing PDFs for quicker mobile downloads"],
|
||||
"faq": [
|
||||
{"q": "How does PDF compression work?", "a": "Our tool optimizes images, removes unnecessary metadata, and compresses internal structures to reduce file size while maintaining visual quality."},
|
||||
{"q": "Will compression affect text quality?", "a": "No. Text remains crisp and searchable. Mainly images within the PDF are optimized to reduce file size."},
|
||||
{"q": "How much can I reduce my PDF size?", "a": "Depending on the content, you can typically reduce file size by 50-90%, especially for PDFs with many images."},
|
||||
{"q": "Is there a file size limit?", "a": "You can compress PDFs up to 20MB in size."}
|
||||
{"q": "How do I compress a PDF online?", "a": "Upload your PDF, choose the compression level you want, start the process, and download the smaller PDF when it is ready."},
|
||||
{"q": "How can I make a PDF smaller for email or uploads?", "a": "Use the balanced or maximum compression setting to reduce PDF file size until it fits common email and upload limits."},
|
||||
{"q": "Will PDF compression reduce quality?", "a": "Compression mainly optimizes images and embedded assets. Text usually stays sharp and searchable, while visual quality depends on the compression level you choose."},
|
||||
{"q": "Does compression change my original PDF?", "a": "No. The tool creates a compressed copy for download and leaves your original file unchanged."}
|
||||
]
|
||||
},
|
||||
"mergePdf": {
|
||||
@@ -1281,14 +1361,17 @@
|
||||
]
|
||||
},
|
||||
"splitPdf": {
|
||||
"whatItDoes": "Divide a PDF document into separate files. You can split every page into an individual file or extract specific page ranges. Perfect for isolating sections from large documents.",
|
||||
"howToUse": ["Upload your PDF document.", "Choose to split all pages or select specific pages/ranges.", "Enter page numbers (e.g. 1,3,5-8) if extracting specific pages.", "Download the resulting PDF files."],
|
||||
"benefits": ["Split into individual pages or custom ranges", "Simple page range syntax (e.g. 1,3,5-8)", "No quality loss", "Free and no signup required", "Works with any PDF document"],
|
||||
"useCases": ["Extracting a specific chapter from an e-book", "Sending only relevant pages to a colleague", "Breaking up a large manual into sections", "Isolating a single page for printing", "Separating a multi-page scanned document"],
|
||||
"metaTitleSuffix": "Free Online PDF Splitter to Split or Separate PDF Pages",
|
||||
"metaDescription": "Split PDF files online for free. Use this PDF splitter to separate PDF pages, cut page ranges, or save selected pages into smaller PDF files without losing quality.",
|
||||
"whatItDoes": "Use this PDF splitter to split PDF pages into separate files or break a long document into smaller sections. You can separate PDF pages one by one or save only the ranges you want.",
|
||||
"howToUse": ["Upload your PDF file.", "Choose whether to split every page or only separate specific pages or ranges.", "Enter page numbers such as 1,3,5-8 when you want custom output.", "Download the new PDF files created from your selected pages."],
|
||||
"benefits": ["Split PDF pages individually or by custom range", "Separate PDF pages without changing the original quality", "Useful for sending only the pages someone needs", "Fast browser-based processing with no signup", "Works for reports, scans, contracts, and other multi-page PDFs"],
|
||||
"useCases": ["Breaking a large PDF into smaller files for clients or teammates", "Separating one chapter or appendix from a long report", "Cutting PDF pages out of a scanned batch document", "Creating smaller PDFs for email or upload limits", "Saving only the pages you need for review or printing"],
|
||||
"faq": [
|
||||
{"q": "How do I split a PDF?", "a": "Upload your PDF, specify the pages or ranges you want to extract, and click split. Download the resulting PDF instantly."},
|
||||
{"q": "Can I extract specific pages?", "a": "Yes, you can specify individual pages (e.g. 1, 3, 5) or ranges (e.g. 1-5) to extract."},
|
||||
{"q": "Is splitting a PDF free?", "a": "Yes, our PDF splitter is completely free with no limitations."}
|
||||
{"q": "How do I split a PDF online?", "a": "Upload your PDF, choose whether to split every page or only selected page ranges, then download the new PDF files created from your document."},
|
||||
{"q": "Can I separate PDF pages without splitting the whole file?", "a": "Yes. You can enter exact page numbers or ranges so only the pages you want are saved into new files."},
|
||||
{"q": "Will splitting a PDF reduce quality?", "a": "No. Splitting is a structural change, so the pages keep their original quality and layout."},
|
||||
{"q": "What is the difference between Split PDF and Extract Pages?", "a": "Split PDF is best when you want separate output files or broad page separation. Extract Pages is better when you want selected pages combined into one new PDF."}
|
||||
]
|
||||
},
|
||||
"rotatePdf": {
|
||||
@@ -1391,14 +1474,17 @@
|
||||
]
|
||||
},
|
||||
"extractPages": {
|
||||
"whatItDoes": "Extract specific pages from a PDF and create a new document containing only the pages you selected. Choose individual pages or page ranges using simple syntax. The perfect tool when you only need certain pages from a large document.",
|
||||
"howToUse": ["Upload your PDF document.", "Enter the page numbers or ranges (e.g. 1,3,5-8).", "Click Extract to create a new PDF.", "Download the PDF with only your selected pages."],
|
||||
"benefits": ["Extract individual pages or ranges", "Simple comma-separated syntax", "Original document stays unchanged", "Free and completely secure", "Fast processing"],
|
||||
"useCases": ["Extracting a single chapter from an e-book", "Getting specific pages for a presentation", "Creating a subset document for review", "Pulling pages from a scanned multi-page document", "Isolating an important page to share separately"],
|
||||
"metaTitleSuffix": "Free Online PDF Page Extractor to Extract Pages from PDF",
|
||||
"metaDescription": "Extract pages from PDF online for free. Select exact page numbers or ranges to create a new PDF with only the pages you need.",
|
||||
"whatItDoes": "This PDF page extractor lets you pull specific pages from a PDF and combine them into one new file. It is ideal when you need to extract pages from PDF documents without splitting every page.",
|
||||
"howToUse": ["Upload your PDF document.", "Enter the exact pages or page ranges you want to keep, such as 2,4,7-10.", "Click Extract to create a new PDF containing only those pages.", "Download the extracted-pages PDF and share it or continue editing it."],
|
||||
"benefits": ["Extract only the pages you need into one clean PDF", "Supports exact page numbers and page ranges", "Leaves the original PDF unchanged", "Useful for forms, contracts, chapters, and scanned packets", "Fast secure processing with automatic cleanup"],
|
||||
"useCases": ["Sending a few required pages from a long application packet", "Creating a review copy with only selected chapters", "Saving one invoice, form, or appendix from a larger PDF", "Pulling key pages out of a scanned document bundle", "Preparing a smaller document before merging or signing"],
|
||||
"faq": [
|
||||
{"q": "How do I extract pages from a PDF?", "a": "Upload your PDF, enter the pages you want (e.g. 1,3,5-8), and download the new PDF containing only those pages."},
|
||||
{"q": "What is the difference between Split and Extract?", "a": "Split divides every page into separate files, while Extract lets you pick any combination of specific pages into one new document."},
|
||||
{"q": "Can I extract pages in a different order?", "a": "The pages are extracted in the order specified. Use our Reorder tool for more control over page arrangement."}
|
||||
{"q": "How do I extract pages from a PDF?", "a": "Upload your PDF, enter the pages or ranges you want to keep, and download the new PDF containing only those selected pages."},
|
||||
{"q": "Can I extract multiple non-consecutive pages?", "a": "Yes. You can extract pages like 1,3,7 as well as ranges such as 5-10 in the same request."},
|
||||
{"q": "What is the difference between Extract Pages and Split PDF?", "a": "Extract Pages creates one new PDF from the exact pages you choose. Split PDF is better when you want broader page separation or multiple outputs."},
|
||||
{"q": "Will the original PDF stay unchanged?", "a": "Yes. The original file is not edited. The tool creates a separate PDF that contains only the extracted pages."}
|
||||
]
|
||||
},
|
||||
"pdfEditor": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"appName": "Dociva",
|
||||
"tagline": "Outils de fichiers en ligne gratuits",
|
||||
@@ -35,6 +35,11 @@
|
||||
"subject": "Sujet",
|
||||
"message": "Message",
|
||||
"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": {
|
||||
"fileTooLarge": "Fichier trop volumineux. Taille maximale autorisée : {{size}} Mo.",
|
||||
"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.",
|
||||
"serverError": "Une erreur serveur s'est produite. Veuillez réessayer plus tard.",
|
||||
"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": {
|
||||
@@ -141,7 +158,31 @@
|
||||
"feature2Title": "Une précision de confiance",
|
||||
"feature2Desc": "Obtenez des fichiers parfaits et modifiables en quelques secondes sans perte de qualité.",
|
||||
"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": {
|
||||
"badge": "Adopté par des équipes actives",
|
||||
@@ -173,7 +214,11 @@
|
||||
"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",
|
||||
"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": {
|
||||
"metaDescription": "Contactez l'équipe Dociva. Signalez un bug, demandez une fonctionnalité ou envoyez-nous un message.",
|
||||
@@ -194,7 +239,12 @@
|
||||
"subjectPlaceholder": "Sujet",
|
||||
"successMessage": "Votre message a été envoyé ! Nous vous répondrons bientôt.",
|
||||
"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": {
|
||||
"metaDescription": "Politique de confidentialité de Dociva. Découvrez comment nous gérons vos fichiers et données en toute transparence.",
|
||||
@@ -623,7 +673,7 @@
|
||||
},
|
||||
"compressPdf": {
|
||||
"title": "Compresser PDF",
|
||||
"description": "Réduisez la taille du fichier PDF tout en maintenant la qualité. Choisissez votre niveau de compression.",
|
||||
"description": "Compressez des fichiers PDF en ligne et réduisez leur taille sans nuire à la lisibilité.",
|
||||
"shortDesc": "Compresser PDF",
|
||||
"qualityLow": "Compression maximale",
|
||||
"qualityMedium": "Équilibré",
|
||||
@@ -643,7 +693,9 @@
|
||||
"height": "Hauteur (px)",
|
||||
"quality": "Qualité",
|
||||
"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": {
|
||||
"title": "Image vers SVG",
|
||||
@@ -716,7 +768,7 @@
|
||||
},
|
||||
"splitPdf": {
|
||||
"title": "Diviser PDF",
|
||||
"description": "Divisez un PDF en pages individuelles ou extrayez des plages de pages spécifiques.",
|
||||
"description": "Divisez des pages PDF en ligne ou séparez des plages précises dans de nouveaux fichiers.",
|
||||
"shortDesc": "Diviser PDF",
|
||||
"allPages": "Toutes les pages",
|
||||
"allPagesDesc": "Extraire chaque page dans un fichier PDF séparé",
|
||||
@@ -977,7 +1029,7 @@
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Extraire des pages PDF",
|
||||
"description": "Extrayez des pages spécifiques d'un PDF dans un nouveau document.",
|
||||
"description": "Extrayez des pages d'un PDF dans un nouveau document avec des numéros ou plages précis.",
|
||||
"shortDesc": "Extraire les pages",
|
||||
"pagesLabel": "Pages à extraire",
|
||||
"pagesPlaceholder": "ex. 1,3,5-8",
|
||||
@@ -989,7 +1041,8 @@
|
||||
"shortDesc": "Générer un code QR",
|
||||
"dataLabel": "Texte ou URL",
|
||||
"dataPlaceholder": "Entrez du texte, une URL ou des données...",
|
||||
"sizeLabel": "Taille"
|
||||
"sizeLabel": "Taille",
|
||||
"altText": "QR Code généré"
|
||||
},
|
||||
"htmlToPdf": {
|
||||
"title": "HTML vers PDF",
|
||||
@@ -1124,7 +1177,29 @@
|
||||
"dataLabel": "Données du code-barres",
|
||||
"dataPlaceholder": "Entrez les données à encoder...",
|
||||
"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": {
|
||||
@@ -1208,7 +1283,10 @@
|
||||
"newSize": "Nouvelle taille",
|
||||
"reduction": "Réduction",
|
||||
"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": {
|
||||
"title": "Inscrivez-vous pour télécharger votre fichier",
|
||||
@@ -1257,15 +1335,17 @@
|
||||
]
|
||||
},
|
||||
"compressPdf": {
|
||||
"whatItDoes": "Réduisez la taille des fichiers PDF jusqu'à 90% tout en maintenant la lisibilité et une haute qualité. Choisissez parmi trois niveaux de compression pour équilibrer qualité et taille de fichier.",
|
||||
"howToUse": ["Téléchargez votre fichier PDF dans l'outil de compression.", "Sélectionnez votre niveau de compression préféré : compression maximale, équilibré ou haute qualité.", "Cliquez sur Compresser et attendez le traitement.", "Téléchargez votre PDF compressé avec une taille considérablement réduite."],
|
||||
"benefits": ["Réduction de taille jusqu'à 90%", "Trois niveaux de compression au choix", "Le texte reste net et consultable", "Parfait pour les pièces jointes d'e-mail", "Gratuit sans inscription"],
|
||||
"useCases": ["Réduire des PDF volumineux pour l'envoi par e-mail", "Réduire l'espace de stockage pour les documents archivés", "Accélérer le téléchargement de PDF sur les sites web", "Optimiser les PDF pour la visualisation mobile", "Préparer des documents pour la publication web"],
|
||||
"metaTitleSuffix": "Compresseur PDF gratuit en ligne pour réduire la taille d'un fichier",
|
||||
"metaDescription": "Compressez des fichiers PDF en ligne gratuitement. Réduisez la taille d'un PDF pour l'e-mail, les formulaires et le partage tout en conservant un texte lisible.",
|
||||
"whatItDoes": "Utilisez ce compresseur PDF en ligne pour réduire la taille d'un PDF avant l'envoi par e-mail, le téléversement ou l'archivage. L'outil optimise les images et la structure du document tout en conservant un texte lisible.",
|
||||
"howToUse": ["Téléchargez le PDF à compresser.", "Choisissez Compression maximale, Équilibré ou Haute qualité selon le niveau de réduction souhaité.", "Lancez la compression et attendez la génération du PDF plus léger.", "Téléchargez le PDF compressé puis partagez-le ou téléversez-le où vous voulez."],
|
||||
"benefits": ["Réduire les PDF volumineux pour l'e-mail et les formulaires en ligne", "Choisir le bon compromis entre taille réduite et qualité visuelle", "Conserver un texte net et consultable après compression", "Fonctionner directement dans le navigateur sans inscription", "Traitement sécurisé avec suppression automatique des fichiers"],
|
||||
"useCases": ["Réduire un PDF avant de l'envoyer en pièce jointe", "Respecter les limites de taille sur les portails et formulaires", "Alléger des PDF numérisés riches en images", "Économiser de l'espace de stockage pour les archives", "Préparer des PDF plus rapides à télécharger sur mobile"],
|
||||
"faq": [
|
||||
{"q": "Comment fonctionne la compression PDF ?", "a": "Notre outil optimise les images, supprime les métadonnées inutiles et compresse les structures internes pour réduire la taille du fichier tout en maintenant la qualité visuelle."},
|
||||
{"q": "La compression affectera-t-elle la qualité du texte ?", "a": "Non. Le texte reste net et consultable. Principalement les images sont optimisées pour réduire la taille."},
|
||||
{"q": "De combien puis-je réduire la taille d'un PDF ?", "a": "Selon le contenu, vous pouvez généralement réduire la taille de 50 à 90%, surtout pour les fichiers contenant beaucoup d'images."},
|
||||
{"q": "Y a-t-il une limite de taille de fichier ?", "a": "Vous pouvez compresser des fichiers PDF jusqu'à 20 Mo."}
|
||||
{"q": "Comment compresser un PDF en ligne ?", "a": "Téléchargez votre PDF, choisissez le niveau de compression souhaité, lancez le traitement puis récupérez le fichier plus léger lorsqu'il est prêt."},
|
||||
{"q": "Comment réduire la taille d'un PDF pour l'e-mail ou le téléversement ?", "a": "Utilisez le mode équilibré ou la compression maximale pour faire passer le fichier sous les limites habituelles d'envoi ou de dépôt."},
|
||||
{"q": "La compression PDF réduit-elle la qualité ?", "a": "La compression agit surtout sur les images et les ressources intégrées. Le texte reste généralement net et consultable, tandis que la qualité visuelle dépend du niveau choisi."},
|
||||
{"q": "La compression modifie-t-elle mon PDF original ?", "a": "Non. L'outil crée une copie compressée à télécharger et laisse le fichier d'origine intact."}
|
||||
]
|
||||
},
|
||||
"mergePdf": {
|
||||
@@ -1281,14 +1361,17 @@
|
||||
]
|
||||
},
|
||||
"splitPdf": {
|
||||
"whatItDoes": "Divisez un document PDF en fichiers séparés. Vous pouvez scinder chaque page en fichiers individuels ou extraire des plages de pages spécifiques. Idéal pour isoler des sections de documents volumineux.",
|
||||
"howToUse": ["Téléchargez votre document PDF.", "Choisissez de diviser toutes les pages ou de spécifier des pages/plages particulières.", "Saisissez les numéros de pages (ex. 1,3,5-8) pour une extraction sélective.", "Téléchargez les fichiers PDF résultants."],
|
||||
"benefits": ["Diviser en pages individuelles ou plages personnalisées", "Syntaxe simple pour les plages de pages", "Sans perte de qualité", "Gratuit sans inscription", "Fonctionne avec tout document PDF"],
|
||||
"useCases": ["Extraire un chapitre spécifique d'un e-book", "Envoyer uniquement certaines pages à un collègue", "Diviser un manuel volumineux en sections", "Isoler une seule page pour l'impression", "Séparer un document numérisé de plusieurs pages"],
|
||||
"metaTitleSuffix": "Outil gratuit en ligne pour diviser un PDF et séparer des pages",
|
||||
"metaDescription": "Divisez des fichiers PDF en ligne gratuitement. Séparez des pages PDF, découpez des plages et créez des PDF plus petits sans perte de qualité.",
|
||||
"whatItDoes": "Utilisez cet outil pour diviser des pages PDF en fichiers séparés ou découper un document long en sections plus petites. Vous pouvez séparer les pages une par une ou conserver uniquement les plages nécessaires.",
|
||||
"howToUse": ["Téléchargez votre fichier PDF.", "Choisissez si vous voulez diviser toutes les pages ou seulement séparer certaines pages ou plages.", "Saisissez des numéros comme 1,3,5-8 lorsque vous voulez un résultat personnalisé.", "Téléchargez les nouveaux fichiers PDF créés à partir des pages sélectionnées."],
|
||||
"benefits": ["Diviser des pages PDF individuellement ou par plage personnalisée", "Séparer des pages PDF sans altérer la qualité d'origine", "Envoyer uniquement les pages utiles au lieu du document complet", "Traitement rapide dans le navigateur sans inscription", "Adapté aux rapports, contrats et PDF numérisés"],
|
||||
"useCases": ["Découper un PDF volumineux en fichiers plus petits pour des collègues ou clients", "Séparer un chapitre ou une annexe d'un long rapport", "Isoler des pages utiles d'un document numérisé", "Créer des PDF plus légers pour l'e-mail ou le dépôt", "Conserver uniquement les pages nécessaires pour la relecture ou l'impression"],
|
||||
"faq": [
|
||||
{"q": "Comment diviser un fichier PDF ?", "a": "Téléchargez votre PDF, sélectionnez les pages ou plages souhaitées et cliquez sur Diviser. Téléchargez le PDF résultant immédiatement."},
|
||||
{"q": "Puis-je extraire des pages spécifiques ?", "a": "Oui, vous pouvez spécifier des pages individuelles (ex. 1, 3, 5) ou des plages (ex. 1-5) pour l'extraction."},
|
||||
{"q": "La division de PDF est-elle gratuite ?", "a": "Oui, notre outil de division PDF est entièrement gratuit sans restrictions."}
|
||||
{"q": "Comment diviser un PDF en ligne ?", "a": "Téléchargez votre PDF, choisissez si vous voulez diviser toutes les pages ou seulement certaines plages, puis téléchargez les nouveaux fichiers générés."},
|
||||
{"q": "Puis-je séparer des pages PDF sans diviser tout le fichier ?", "a": "Oui. Vous pouvez saisir des numéros de pages ou des plages précises afin d'enregistrer uniquement les pages voulues dans de nouveaux fichiers."},
|
||||
{"q": "La division d'un PDF réduit-elle la qualité ?", "a": "Non. La division modifie uniquement la structure du fichier, donc les pages conservent leur qualité et leur mise en page d'origine."},
|
||||
{"q": "Quelle est la différence entre Diviser PDF et Extraire des pages ?", "a": "Diviser PDF convient mieux lorsque vous voulez plusieurs sorties ou une séparation large des pages. Extraire des pages est préférable lorsque vous voulez réunir des pages choisies dans un seul nouveau PDF."}
|
||||
]
|
||||
},
|
||||
"rotatePdf": {
|
||||
@@ -1391,14 +1474,17 @@
|
||||
]
|
||||
},
|
||||
"extractPages": {
|
||||
"whatItDoes": "Extrayez des pages spécifiques d'un PDF et créez un nouveau document contenant uniquement les pages sélectionnées. Choisissez des pages individuelles ou des plages de pages avec une syntaxe simple.",
|
||||
"howToUse": ["Téléchargez votre document PDF.", "Saisissez les numéros de pages ou plages (ex. 1,3,5-8).", "Cliquez sur Extraire pour créer un nouveau PDF.", "Téléchargez le PDF contenant uniquement les pages choisies."],
|
||||
"benefits": ["Extraction de pages individuelles ou par plages", "Syntaxe simple séparée par des virgules", "Le document original reste inchangé", "Gratuit et totalement sécurisé", "Traitement rapide"],
|
||||
"useCases": ["Extraire un seul chapitre d'un e-book", "Obtenir des pages spécifiques pour une présentation", "Créer un sous-document pour révision", "Extraire des pages d'un document numérisé multi-pages", "Isoler une page importante pour un partage séparé"],
|
||||
"metaTitleSuffix": "Extracteur de pages PDF gratuit en ligne",
|
||||
"metaDescription": "Extrayez des pages d'un PDF en ligne gratuitement. Sélectionnez des numéros ou plages exacts pour créer un nouveau PDF avec uniquement les pages utiles.",
|
||||
"whatItDoes": "Cet extracteur de pages PDF vous permet de récupérer des pages précises d'un PDF et de les réunir dans un nouveau fichier. Il convient parfaitement lorsque vous devez extraire des pages d'un PDF sans séparer chaque page du document.",
|
||||
"howToUse": ["Téléchargez votre document PDF.", "Saisissez les pages ou plages exactes à conserver, par exemple 2,4,7-10.", "Cliquez sur Extraire pour créer un nouveau PDF contenant uniquement ces pages.", "Téléchargez le PDF extrait puis partagez-le ou poursuivez votre traitement."],
|
||||
"benefits": ["Extraire uniquement les pages nécessaires dans un PDF propre", "Prendre en charge les numéros de pages précis et les plages", "Laisser le PDF original inchangé", "Utile pour les formulaires, contrats, chapitres et lots numérisés", "Traitement rapide et sécurisé avec nettoyage automatique"],
|
||||
"useCases": ["Envoyer seulement quelques pages d'un dossier volumineux", "Créer une copie de relecture avec des chapitres sélectionnés", "Conserver une facture, un formulaire ou une annexe d'un PDF plus grand", "Retirer des pages clés d'un document numérisé multi-pages", "Préparer un document plus léger avant fusion ou signature"],
|
||||
"faq": [
|
||||
{"q": "Comment extraire des pages d'un PDF ?", "a": "Téléchargez votre PDF, saisissez les pages souhaitées (ex. 1,3,5-8) et téléchargez le nouveau PDF contenant uniquement ces pages."},
|
||||
{"q": "Quelle est la différence entre diviser et extraire ?", "a": "La division sépare chaque page en fichiers distincts, tandis que l'extraction vous permet de choisir n'importe quelle combinaison de pages spécifiques dans un nouveau document unique."},
|
||||
{"q": "Puis-je extraire les pages dans un ordre différent ?", "a": "Les pages sont extraites dans l'ordre spécifié. Utilisez l'outil de réorganisation pour plus de contrôle sur l'ordre des pages."}
|
||||
{"q": "Comment extraire des pages d'un PDF ?", "a": "Téléchargez votre PDF, saisissez les pages ou plages à conserver, puis téléchargez le nouveau PDF contenant uniquement ces pages sélectionnées."},
|
||||
{"q": "Puis-je extraire plusieurs pages non consécutives ?", "a": "Oui. Vous pouvez extraire des pages comme 1,3,7 ainsi que des plages comme 5-10 dans la même demande."},
|
||||
{"q": "Quelle est la différence entre Extraire des pages et Diviser PDF ?", "a": "Extraire des pages crée un seul nouveau PDF à partir des pages choisies avec précision. Diviser PDF est préférable lorsque vous voulez une séparation plus large ou plusieurs fichiers de sortie."},
|
||||
{"q": "Le PDF d'origine reste-t-il inchangé ?", "a": "Oui. Le fichier original n'est pas modifié. L'outil crée un PDF séparé qui contient uniquement les pages extraites."}
|
||||
]
|
||||
},
|
||||
"pdfEditor": {
|
||||
|
||||
@@ -2,13 +2,37 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||
import { Target, Cpu, Shield, Lock, Wrench } from 'lucide-react';
|
||||
import { Lightbulb, Shield, Send, Users, FileText, Globe } from 'lucide-react';
|
||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||
|
||||
const TEAM_MEMBERS = [
|
||||
{ nameKey: 'pages.about.team.ceo', nameDefault: 'CEO', role: 'CEO' },
|
||||
{ nameKey: 'pages.about.team.cto', nameDefault: 'CTO', role: 'CTO' },
|
||||
{ nameKey: 'pages.about.team.lead', nameDefault: 'Lead Developer', role: 'Lead Developer' },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ value: '1,000,000+', labelKey: 'pages.about.statsUsers', labelDefault: 'Users Served' },
|
||||
{ value: '500,000,000+', labelKey: 'pages.about.statsFiles', labelDefault: 'Files Processed' },
|
||||
{ value: '150+', labelKey: 'pages.about.statsCountries', labelDefault: 'Countries Reached' },
|
||||
];
|
||||
|
||||
const TIMELINE = [
|
||||
{ year: '2018', labelKey: 'pages.about.timeline2018', labelDefault: 'Founded as DocuFlow' },
|
||||
{ year: '2020', labelKey: 'pages.about.timeline2020', labelDefault: 'Launched Cloud Platform' },
|
||||
{ year: '2022', labelKey: 'pages.about.timeline2022', labelDefault: 'Global Expansion' },
|
||||
{ year: '2024', labelKey: 'pages.about.timeline2024', labelDefault: 'AI Integration' },
|
||||
];
|
||||
|
||||
const VALUES = [
|
||||
{ icon: Lightbulb, titleKey: 'pages.about.valueInnovation', titleDefault: 'Innovation' },
|
||||
{ icon: Shield, titleKey: 'pages.about.valueSecurity', titleDefault: 'Security' },
|
||||
{ icon: Send, titleKey: 'pages.about.valueSimplicity', titleDefault: 'Simplicity' },
|
||||
];
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,87 +47,106 @@ export default function AboutPage() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-8 text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.about.title')}
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Hero Banner */}
|
||||
<section className="relative mb-16 overflow-hidden rounded-3xl bg-gradient-to-br from-primary-600 via-primary-500 to-sky-400 px-8 py-14 text-white shadow-lg sm:px-12 sm:py-20">
|
||||
<div className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-20 -left-10 h-48 w-48 rounded-full bg-white/10 blur-3xl" />
|
||||
<h1 className="relative text-3xl font-extrabold uppercase tracking-wide sm:text-4xl lg:text-5xl">
|
||||
{t('pages.about.heroTitle', 'Empowering Document Productivity Worldwide')}
|
||||
</h1>
|
||||
|
||||
{/* Mission */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Target className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.about.missionTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
<p className="relative mt-4 max-w-2xl text-lg leading-relaxed text-white/90">
|
||||
{t('pages.about.missionText')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Technology */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Cpu className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.about.technologyTitle')}
|
||||
{/* Our Team */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.about.teamTitle', 'Our Team')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{TEAM_MEMBERS.map((member, idx) => (
|
||||
<div key={idx} className="flex flex-col items-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 shadow-md dark:bg-primary-900/30">
|
||||
<Users className="h-10 w-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{t('pages.about.technologyText')}
|
||||
<p className="mt-3 text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
{t(member.nameKey, member.role)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Shield className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.about.securityTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{t('pages.about.securityText')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* File Privacy */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Lock className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.about.privacyTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{t('pages.about.privacyText', { minutes: FILE_RETENTION_MINUTES })}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* What We Offer */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Wrench className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.about.toolsTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
{Array.isArray(toolCategories) && (
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-600 dark:text-slate-400">
|
||||
{toolCategories.map((cat, idx) => (
|
||||
<li key={idx}>{cat}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="mb-16">
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{STATS.map((stat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative flex flex-col items-center rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
{/* Decorative ring */}
|
||||
<div className="mb-4 flex h-28 w-28 items-center justify-center rounded-full border-4 border-primary-200 dark:border-primary-800">
|
||||
<span className="text-xl font-extrabold text-primary-700 dark:text-primary-300">{stat.value}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
{t(stat.labelKey, stat.labelDefault)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline */}
|
||||
<section className="mb-16">
|
||||
<div className="relative flex items-center justify-between overflow-x-auto py-8">
|
||||
{/* Line */}
|
||||
<div className="absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-primary-200 dark:bg-primary-800" />
|
||||
{TIMELINE.map((event, idx) => (
|
||||
<div key={idx} className="relative z-10 flex flex-col items-center px-4">
|
||||
<div className="mb-3 flex h-4 w-4 items-center justify-center rounded-full bg-primary-600 ring-4 ring-primary-100 dark:ring-primary-900/50" />
|
||||
<span className="text-sm font-bold text-slate-900 dark:text-white">{event.year}</span>
|
||||
<span className="mt-1 max-w-[120px] text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{t(event.labelKey, event.labelDefault)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Our Values */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.about.valuesTitle', 'Our Values')}
|
||||
</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{VALUES.map(({ icon: Icon, titleKey, titleDefault }, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col items-center rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<Icon className="mb-4 h-10 w-10 text-primary-600 dark:text-primary-400" />
|
||||
<h3 className="text-lg font-bold text-primary-700 dark:text-primary-300">
|
||||
{t(titleKey, titleDefault)}
|
||||
</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center dark:border-slate-700 dark:bg-slate-800">
|
||||
<p className="mb-4 text-slate-600 dark:text-slate-400">
|
||||
<Link to="/contact" className="font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center dark:border-slate-700 dark:bg-slate-800">
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
{t('pages.about.ctaText', 'Have questions? Get in touch.')}
|
||||
</p>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary-600 px-6 py-3 font-semibold text-white transition-all hover:-translate-y-0.5 hover:bg-primary-700"
|
||||
>
|
||||
{t('common.contact')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -607,7 +607,7 @@ export default function AccountPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card rounded-[2rem] p-0">
|
||||
<section id="history" className="card rounded-[2rem] p-0">
|
||||
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderClock className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Search } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
|
||||
import ManifestToolIcon from '@/components/shared/ManifestToolIcon';
|
||||
import { TOOLS_SEO } from '@/config/seoData';
|
||||
import { TOOL_MANIFEST } from '@/config/toolManifest';
|
||||
import { generateBreadcrumbs, generateCollectionPage, generateItemList, getSiteOrigin } from '@/utils/seo';
|
||||
|
||||
const CATEGORY_ORDER = ['PDF', 'Convert', 'Image', 'AI', 'Utility'] as const;
|
||||
const CATEGORY_TABS = [
|
||||
{ key: 'All', labelKey: 'pages.toolsHub.categoryAll', labelDefault: 'All' },
|
||||
{ key: 'Convert', labelKey: 'pages.toolsHub.categoryConvert', labelDefault: 'Convert' },
|
||||
{ key: 'PDF', labelKey: 'pages.toolsHub.categoryOrganize', labelDefault: 'Organize' },
|
||||
{ key: 'Image', labelKey: 'pages.toolsHub.categoryOptimize', labelDefault: 'Optimize' },
|
||||
{ key: 'AI', labelKey: 'pages.toolsHub.categorySecurity', labelDefault: 'Security' },
|
||||
] as const;
|
||||
|
||||
function getManifestEntry(slug: string) {
|
||||
return TOOL_MANIFEST.find((t) => t.slug === slug);
|
||||
}
|
||||
|
||||
export default function AllToolsPage() {
|
||||
const { t } = useTranslation();
|
||||
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const path = '/tools';
|
||||
const url = `${origin}${path}`;
|
||||
const [activeTab, setActiveTab] = useState('All');
|
||||
const [query, setQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
||||
|
||||
const groupedTools = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: TOOLS_SEO.filter((tool) => tool.category === category),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
const filteredTools = useMemo(() => {
|
||||
let tools = TOOLS_SEO;
|
||||
if (activeTab !== 'All') {
|
||||
tools = tools.filter((tool) => tool.category === activeTab);
|
||||
}
|
||||
if (deferredQuery) {
|
||||
tools = tools.filter((tool) => {
|
||||
const title = t(`tools.${tool.i18nKey}.title`).toLowerCase();
|
||||
const desc = t(`tools.${tool.i18nKey}.shortDesc`, '').toLowerCase();
|
||||
return title.includes(deferredQuery) || desc.includes(deferredQuery);
|
||||
});
|
||||
}
|
||||
return tools;
|
||||
}, [activeTab, deferredQuery, t]);
|
||||
|
||||
const jsonLd = [
|
||||
generateCollectionPage({
|
||||
@@ -45,54 +73,94 @@ export default function AllToolsPage() {
|
||||
jsonLd={jsonLd}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-6xl space-y-10">
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
{/* Header */}
|
||||
<section>
|
||||
<BreadcrumbNav
|
||||
className="mb-6"
|
||||
className="mb-4"
|
||||
items={[
|
||||
{ label: t('common.home'), to: '/' },
|
||||
{ label: t('common.allTools') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
{t('pages.toolsHub.title')}
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white sm:text-5xl">
|
||||
{t('pages.toolsHub.title', 'All PDF Tools')}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||
{t('pages.toolsHub.description')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{groupedTools.map((group) => (
|
||||
<section
|
||||
key={group.category}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70"
|
||||
{/* Search + category tabs */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('pages.toolsHub.searchPlaceholder', 'Search tools...')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-12 pr-4 text-slate-700 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORY_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`rounded-xl px-5 py-2.5 text-sm font-semibold transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-primary-600 text-white shadow-md'
|
||||
: 'border border-slate-200 bg-white text-slate-600 hover:border-primary-300 hover:text-primary-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-primary-600'
|
||||
}`}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{t(`pages.toolsHub.categories.${group.category}`)}
|
||||
</h2>
|
||||
{t(tab.labelKey, tab.labelDefault)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((tool) => (
|
||||
{/* Tools grid */}
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredTools.map((tool) => {
|
||||
const manifest = getManifestEntry(tool.slug);
|
||||
return (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
to={`/tools/${tool.slug}`}
|
||||
className="rounded-2xl border border-slate-200 p-5 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
|
||||
className="group flex flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:border-primary-600"
|
||||
>
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-primary-600 dark:text-primary-400">
|
||||
{group.category}
|
||||
</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{manifest ? (
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${manifest.bgColor}`}>
|
||||
<ManifestToolIcon iconName={manifest.iconName} className={`h-5 w-5 ${manifest.iconColor}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-700" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
{t(`tools.${tool.i18nKey}.title`)}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
</div>
|
||||
<p className="flex-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{t(`tools.${tool.i18nKey}.shortDesc`)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-4 flex items-center text-sm font-medium text-primary-600 opacity-0 transition-opacity group-hover:opacity-100 dark:text-primary-400">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredTools.length === 0 && (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-12 text-center dark:border-slate-700 dark:bg-slate-800">
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('pages.toolsHub.noResults', 'No tools found matching your search.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" format="horizontal" className="mt-4" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Calendar, ChevronLeft, Clock } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import {
|
||||
@@ -184,6 +185,8 @@ export default function BlogPostPage() {
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="bottom-banner" format="horizontal" className="mt-8" />
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useParams, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { CheckCircle, XCircle, MinusCircle, ArrowRight, Swords, Trophy, ExternalLink } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { getComparisonPage, getComparisonPagesByTool, type ComparisonFeature } from '@/config/comparisonData';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import { getSiteOrigin, buildSocialImageUrl, getOgLocale, generateWebPage, generateFAQ } from '@/utils/seo';
|
||||
@@ -272,6 +273,8 @@ export default function ComparisonPage() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" format="horizontal" className="mb-12" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Phone,
|
||||
MapPin,
|
||||
ChevronDown,
|
||||
Github,
|
||||
Twitter,
|
||||
Linkedin,
|
||||
Facebook,
|
||||
Instagram,
|
||||
} from 'lucide-react';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
@@ -9,11 +23,27 @@ import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||
import { getApiClient } from '@/services/api';
|
||||
|
||||
const CONTACT_EMAIL = 'support@dociva.io';
|
||||
const CONTACT_PHONE = '+1 (555) 123-4567';
|
||||
const OFFICE_ADDRESS = '123 Tech Avenue, Innovation City, CA 90001';
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
const api = getApiClient();
|
||||
|
||||
type Category = 'general' | 'bug' | 'feature';
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{ questionKey: 'pages.contact.faq1q', answerKey: 'pages.contact.faq1a', questionDefault: 'What is your pricing?', answerDefault: 'We offer a generous free tier with all tools. Pro plans start at $9/month for more credits and features.' },
|
||||
{ questionKey: 'pages.contact.faq2q', answerKey: 'pages.contact.faq2a', questionDefault: 'How does the platform work?', answerDefault: 'Upload your file, choose a tool, and download the result — no sign-up required for basic usage.' },
|
||||
{ questionKey: 'pages.contact.faq3q', answerKey: 'pages.contact.faq3a', questionDefault: 'Is my data secure?', answerDefault: 'Yes. All transfers are encrypted, and files are automatically deleted within minutes of processing.' },
|
||||
];
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{ icon: Facebook, href: '#', label: 'Facebook' },
|
||||
{ icon: Twitter, href: '#', label: 'Twitter' },
|
||||
{ icon: Linkedin, href: '#', label: 'LinkedIn' },
|
||||
{ icon: Instagram, href: '#', label: 'Instagram' },
|
||||
{ icon: Github, href: '#', label: 'GitHub' },
|
||||
];
|
||||
|
||||
export default function ContactPage() {
|
||||
const { t } = useTranslation();
|
||||
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
@@ -21,6 +51,7 @@ export default function ContactPage() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
||||
|
||||
const placeholderKey = `pages.contact.${category}Placeholder` as const;
|
||||
|
||||
@@ -93,100 +124,72 @@ export default function ContactPage() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t('pages.contact.title')}
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Page header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white sm:text-5xl">
|
||||
{t('pages.contact.title', 'Get in Touch')}
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||
<p className="mt-3 text-lg text-primary-600 dark:text-primary-400">
|
||||
{t('pages.contact.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('pages.contact.formTitle')}
|
||||
</h2>
|
||||
|
||||
{/* Category */}
|
||||
{/* Two-column layout */}
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
{/* Left column — Contact form */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('pages.contact.categoryLabel')}
|
||||
</label>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Category */}
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as Category)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-slate-700 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<option value="general">{t('pages.contact.categories.general')}</option>
|
||||
<option value="bug">{t('pages.contact.categories.bug')}</option>
|
||||
<option value="feature">{t('pages.contact.categories.feature')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('common.name')}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder={t('pages.contact.namePlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
placeholder={t('pages.contact.namePlaceholder', 'Name')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-slate-700 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder={t('pages.contact.emailPlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
placeholder={t('pages.contact.emailPlaceholder', 'Email')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-slate-700 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('common.subject')}
|
||||
</label>
|
||||
<input
|
||||
id="subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
required
|
||||
placeholder={t('pages.contact.subjectPlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
placeholder={t('pages.contact.subjectPlaceholder', 'Subject')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-slate-700 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('common.message')}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={6}
|
||||
rows={5}
|
||||
required
|
||||
placeholder={t(placeholderKey)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
placeholder={t(placeholderKey, 'Message')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-slate-700 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -196,28 +199,113 @@ export default function ContactPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-primary-600 px-8 py-3 font-semibold text-white shadow-md transition-all hover:-translate-y-0.5 hover:bg-primary-700 hover:shadow-lg disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{loading ? t('common.sending', 'Sending...') : t('common.send')}
|
||||
{loading ? t('common.sending', 'Sending...') : t('common.send', 'Submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Direct email fallback */}
|
||||
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
<p>
|
||||
{t('pages.contact.directEmail')}{' '}
|
||||
{/* Right column — Contact info cards */}
|
||||
<div className="space-y-5">
|
||||
{/* Email card */}
|
||||
<div className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<Mail className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{t('pages.contact.emailLabel', 'Email:')}</p>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="inline-flex items-center gap-1 font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
className="text-sm text-slate-600 hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-1">{t('pages.contact.responseTime')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone card */}
|
||||
<div className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<Phone className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{t('pages.contact.phoneLabel', 'Phone:')}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{CONTACT_PHONE}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Office card */}
|
||||
<div className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<MapPin className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{t('pages.contact.officeLabel', 'Office:')}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{OFFICE_ADDRESS}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social links */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.contact.connectTitle', 'Connect With Us')}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{SOCIAL_LINKS.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-primary-600 text-white shadow-md transition-all hover:-translate-y-0.5 hover:bg-primary-700 hover:shadow-lg"
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response time */}
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('pages.contact.responseTime')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="mt-16">
|
||||
<h2 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.contact.faqTitle', 'FAQ')}
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{FAQ_ITEMS.map((faq, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFaq(openFaq === idx ? null : idx)}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<span className="pr-2 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{t(faq.questionKey, faq.questionDefault)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 shrink-0 text-primary-500 transition-transform ${openFaq === idx ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{openFaq === idx && (
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{t(faq.answerKey, faq.answerDefault)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,92 @@
|
||||
import { useDeferredValue } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateOrganization, generateWebSite, getSiteOrigin } from '@/utils/seo';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FileText,
|
||||
FileOutput,
|
||||
Minimize2,
|
||||
ImageIcon,
|
||||
Film,
|
||||
Hash,
|
||||
Eraser,
|
||||
Layers,
|
||||
Scissors,
|
||||
RotateCw,
|
||||
Image,
|
||||
FileImage,
|
||||
Droplets,
|
||||
Lock,
|
||||
Unlock,
|
||||
ListOrdered,
|
||||
PenLine,
|
||||
GitBranch,
|
||||
Scaling,
|
||||
ScanText,
|
||||
Sheet,
|
||||
ArrowUpDown,
|
||||
QrCode,
|
||||
Code,
|
||||
MessageSquare,
|
||||
Languages,
|
||||
Table,
|
||||
Search,
|
||||
X,
|
||||
Crop,
|
||||
FileDown,
|
||||
Wrench,
|
||||
Presentation,
|
||||
Barcode,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Globe,
|
||||
UploadCloud,
|
||||
MousePointerClick,
|
||||
Download,
|
||||
ArrowRight,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
Globe,
|
||||
Layers,
|
||||
Lock,
|
||||
MousePointerClick,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Star,
|
||||
UploadCloud,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import ToolCard from '@/components/shared/ToolCard';
|
||||
import HeroUploadZone from '@/components/shared/HeroUploadZone';
|
||||
import MarketingPageLayout from '@/components/layout/MarketingPageLayout';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import HeroUploadZone from '@/components/shared/HeroUploadZone';
|
||||
import ManifestToolIcon from '@/components/shared/ManifestToolIcon';
|
||||
import SectionIntro from '@/components/shared/SectionIntro';
|
||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||
import { getHomepageTools, type ToolEntry } from '@/config/toolManifest';
|
||||
|
||||
// Map icon names from manifest to lucide components
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
FileText, FileOutput, Minimize2, ImageIcon, Film, Hash, Eraser, Layers,
|
||||
Scissors, RotateCw, Image, FileImage, Droplets, Lock, Unlock, ListOrdered,
|
||||
PenLine, GitBranch, Scaling, ScanText, Sheet, ArrowUpDown, QrCode, Code,
|
||||
MessageSquare, Languages, Table, Crop, FileDown, Wrench, Presentation, Barcode,
|
||||
};
|
||||
|
||||
function renderToolIcon(tool: ToolEntry) {
|
||||
const IconComponent = ICON_MAP[tool.iconName];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent className={`h-6 w-6 ${tool.iconColor}`} />;
|
||||
}
|
||||
import ToolCard from '@/components/shared/ToolCard';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { TOOL_MANIFEST, getHomepageTools, type ToolEntry } from '@/config/toolManifest';
|
||||
import { generateOrganization, generateWebSite, getSiteOrigin } from '@/utils/seo';
|
||||
|
||||
interface ToolInfo {
|
||||
key: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
bgColor: string;
|
||||
iconName: string;
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] {
|
||||
return tools.map((t) => ({
|
||||
key: t.i18nKey,
|
||||
path: `/tools/${t.slug}`,
|
||||
icon: renderToolIcon(t),
|
||||
icon: <ManifestToolIcon iconName={t.iconName} className={`h-6 w-6 ${t.iconColor}`} />,
|
||||
bgColor: t.bgColor,
|
||||
iconName: t.iconName,
|
||||
iconColor: t.iconColor,
|
||||
}));
|
||||
}
|
||||
|
||||
const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf'));
|
||||
const otherTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('other'));
|
||||
|
||||
const FEATURE_PANELS = [
|
||||
{
|
||||
icon: Layers,
|
||||
bgClassName: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
iconClassName: 'text-blue-600 dark:text-blue-400',
|
||||
titleKey: 'home.feature1Title',
|
||||
titleDefault: 'One complete workspace',
|
||||
descKey: 'home.feature1Desc',
|
||||
descDefault: 'Edit, convert, compress, merge, and split without bouncing between disconnected tools.',
|
||||
perks: ['home.feature1Perk1', 'home.feature1Perk2'],
|
||||
fallbackPerks: ['30+ tools in one place', 'PDF, image, and AI workflows'],
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
bgClassName: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
iconClassName: 'text-emerald-600 dark:text-emerald-400',
|
||||
titleKey: 'home.feature2Title',
|
||||
titleDefault: 'Accuracy you can trust',
|
||||
descKey: 'home.feature2Desc',
|
||||
descDefault: 'Clear outputs, reliable formatting, and fast turnaround for the workflows people use every day.',
|
||||
perks: ['home.feature2Perk1', 'home.feature2Perk2'],
|
||||
fallbackPerks: ['Preserve layouts and readability', 'Built for repeatable file tasks'],
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
bgClassName: 'bg-violet-100 dark:bg-violet-900/30',
|
||||
iconClassName: 'text-violet-600 dark:text-violet-400',
|
||||
titleKey: 'home.feature3Title',
|
||||
titleDefault: 'Built-in security',
|
||||
descKey: 'home.feature3Desc',
|
||||
descDefault: 'Files are processed securely, automatically cleaned up, and accessible without forcing registration.',
|
||||
perks: ['home.feature3Perk1', 'home.feature3Perk2'],
|
||||
fallbackPerks: ['Auto-delete policies', 'Encrypted transfers'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const HOW_IT_WORKS = [
|
||||
{
|
||||
step: '01',
|
||||
@@ -127,6 +126,25 @@ export default function HomePage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
||||
const homepageQuickLinks = pdfTools.slice(0, 4);
|
||||
const stats = [
|
||||
{
|
||||
label: t('home.statsToolsLabel', 'Total tools'),
|
||||
value: String(TOOL_MANIFEST.length),
|
||||
},
|
||||
{
|
||||
label: t('home.statsPdfLabel', 'PDF workflows'),
|
||||
value: String(pdfTools.length),
|
||||
},
|
||||
{
|
||||
label: t('home.statsOtherLabel', 'Image, AI & utility'),
|
||||
value: String(otherTools.length),
|
||||
},
|
||||
{
|
||||
label: t('home.statsAccessLabel', 'Access model'),
|
||||
value: t('home.statsAccessValue', 'No signup'),
|
||||
},
|
||||
];
|
||||
|
||||
const matchesTool = (tool: ToolInfo) => {
|
||||
if (!deferredQuery) {
|
||||
@@ -151,7 +169,95 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarketingPageLayout
|
||||
bodyClassName="pb-20"
|
||||
hero={
|
||||
<section className="px-4 pb-10 pt-8 sm:px-6 lg:px-8 lg:pt-10">
|
||||
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1.05fr_0.95fr] xl:gap-8">
|
||||
<div className="marketing-panel relative overflow-hidden p-8 sm:p-10 lg:p-12">
|
||||
<div className="pointer-events-none absolute -left-10 top-10 h-36 w-36 rounded-full bg-primary-200/60 blur-3xl dark:bg-primary-800/30" />
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 h-44 w-44 rounded-full bg-sky-200/50 blur-3xl dark:bg-sky-800/20" />
|
||||
<div className="relative">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-4 py-1.5 text-xs font-bold uppercase tracking-[0.22em] text-primary-700 dark:border-primary-800 dark:bg-primary-900/25 dark:text-primary-300">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t('home.heroBadge', 'Modern document workflows')}
|
||||
</span>
|
||||
|
||||
<h1 className="mt-6 max-w-3xl text-4xl font-black tracking-tight text-slate-950 dark:text-white sm:text-5xl lg:text-6xl lg:leading-[1.02]">
|
||||
{t('home.hero')}
|
||||
</h1>
|
||||
|
||||
<p className="mt-5 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-300">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
{ icon: ShieldCheck, text: t('home.trustSecure', 'Files auto-deleted') },
|
||||
{ icon: Zap, text: t('home.trustFast', 'Results in seconds') },
|
||||
{ icon: Globe, text: t('home.trust30Tools', '30+ free tools') },
|
||||
{ icon: Lock, text: t('home.trustNoSignup', 'No sign-up needed') },
|
||||
].map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="metric-card flex items-center gap-3 py-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
|
||||
<Icon className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-slate-950 px-5 py-3 text-sm font-semibold text-white transition-all hover:-translate-y-0.5 hover:bg-primary-600 dark:bg-white dark:text-slate-950 dark:hover:bg-primary-300"
|
||||
>
|
||||
{t('home.ctaBrowseTools', 'Browse All Tools')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[1.75rem] border border-slate-200/80 bg-white/85 p-5 dark:border-slate-700/70 dark:bg-slate-900/65">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
|
||||
{t('home.quickStartLabel', 'Popular starting points')}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{homepageQuickLinks.map((tool) => (
|
||||
<Link
|
||||
key={tool.path}
|
||||
to={tool.path}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3.5 py-2 text-sm font-medium text-slate-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200 dark:hover:border-primary-600 dark:hover:text-primary-300"
|
||||
>
|
||||
<ManifestToolIcon iconName={tool.iconName} className={`h-4 w-4 ${tool.iconColor}`} />
|
||||
{t(`tools.${tool.key}.title`)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="marketing-panel p-6 sm:p-8">
|
||||
<SectionIntro
|
||||
eyebrow={t('home.heroUploadEyebrow', 'Upload and start')}
|
||||
title={t('home.heroUploadTitle', 'Choose a file and jump straight into the right tool')}
|
||||
description={t(
|
||||
'home.heroUploadDescription',
|
||||
'The smart upload zone keeps the current routing logic and suggests the best workflow automatically.'
|
||||
)}
|
||||
/>
|
||||
<HeroUploadZone />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<SEOHead
|
||||
title={t('common.appName')}
|
||||
description={t('home.heroSub')}
|
||||
@@ -165,77 +271,31 @@ export default function HomePage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── Hero Section ──────────────────────────────────────────── */}
|
||||
<section className="hero-gradient-bg relative overflow-hidden py-16 sm:py-24 px-4 mb-10 rounded-b-[3rem]">
|
||||
{/* Decorative blobs */}
|
||||
<div className="pointer-events-none absolute -top-32 left-1/2 h-[600px] w-[600px] -translate-x-1/2 rounded-full bg-primary-400/10 blur-3xl dark:bg-primary-600/10" />
|
||||
<div className="pointer-events-none absolute top-0 right-0 h-80 w-80 rounded-full bg-accent-400/8 blur-3xl dark:bg-accent-600/8" />
|
||||
|
||||
<div className="relative max-w-4xl mx-auto text-center">
|
||||
{/* Animated badge */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-4 py-1.5 mb-6 dark:border-primary-800 dark:bg-primary-900/30">
|
||||
<span className="h-2 w-2 rounded-full bg-primary-500 animate-pulse" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-primary-700 dark:text-primary-300">
|
||||
{t('home.heroBadge', 'Free Online PDF & File Tools')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900 sm:text-6xl lg:text-7xl dark:text-white mb-6 leading-[1.1]">
|
||||
{t('home.hero')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-500 dark:text-slate-400 mb-4 leading-relaxed">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
|
||||
{/* Trust strip */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 mb-10">
|
||||
{[
|
||||
{ icon: ShieldCheck, text: t('home.trustNoSignup', 'No sign-up needed') },
|
||||
{ icon: Zap, text: t('home.trustFast', 'Results in seconds') },
|
||||
{ icon: Lock, text: t('home.trustSecure', 'Files auto-deleted') },
|
||||
{ icon: Globe, text: t('home.trust30Tools', '30+ free tools') },
|
||||
].map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Icon className="h-4 w-4 text-primary-500 flex-shrink-0" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Smart Upload Zone */}
|
||||
<HeroUploadZone />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Ad Slot ───────────────────────────────────────────────── */}
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<AdSlot slot="home-top" format="horizontal" className="mb-8" />
|
||||
|
||||
{/* ── Social Proof Strip ────────────────────────────────────── */}
|
||||
<SocialProofStrip className="mb-10" />
|
||||
|
||||
{/* ── How It Works ──────────────────────────────────────────── */}
|
||||
<section className="mb-14 px-2">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('home.howItWorksLabel', 'Simple process')}
|
||||
</p>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
|
||||
{t('home.howItWorksTitle', 'Convert & edit in 3 steps')}
|
||||
</h2>
|
||||
<p className="mt-3 text-slate-500 dark:text-slate-400 max-w-xl mx-auto">
|
||||
{t('home.howItWorksSubtitle', 'No account, no installation, no waiting. Just upload, choose a tool, and download.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-4 pb-14 sm:px-6 lg:px-8">
|
||||
<SocialProofStrip className="mb-12" />
|
||||
|
||||
<SectionIntro
|
||||
align="center"
|
||||
eyebrow={t('home.howItWorksLabel', 'Simple process')}
|
||||
title={t('home.howItWorksTitle', 'Convert and edit in three simple steps')}
|
||||
description={t(
|
||||
'home.howItWorksSubtitle',
|
||||
'No account, no installation, and no friction. Upload, choose the right workflow, and download.'
|
||||
)}
|
||||
className="mb-10"
|
||||
/>
|
||||
|
||||
<div className="relative grid gap-6 sm:grid-cols-3">
|
||||
{HOW_IT_WORKS.map(({ step, icon: Icon, titleKey, titleDefault, descKey, descDefault, color, glow }, idx) => (
|
||||
<div key={step} className="relative">
|
||||
{/* Connector line (between steps, hidden on mobile) */}
|
||||
{idx < HOW_IT_WORKS.length - 1 && (
|
||||
<div className="step-connector" />
|
||||
)}
|
||||
<div className="flex flex-col items-center text-center rounded-2xl bg-white p-7 shadow-sm ring-1 ring-slate-200/80 dark:bg-slate-800/70 dark:ring-slate-700/60">
|
||||
{/* Numbered icon */}
|
||||
<div className="marketing-card flex flex-col items-center text-center p-7">
|
||||
<div className={`relative mb-5 flex h-16 w-16 items-center justify-center rounded-2xl ${color} shadow-lg ${glow} text-white`}>
|
||||
<Icon className="h-8 w-8" />
|
||||
<span className="absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-black text-slate-700 shadow-sm ring-1 ring-slate-200 dark:bg-slate-700 dark:text-slate-200 dark:ring-slate-600">
|
||||
@@ -254,53 +314,62 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Search & Tools ────────────────────────────────────────── */}
|
||||
<section className="mb-8 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<section className="mx-auto max-w-7xl px-4 pb-14 sm:px-6 lg:px-8">
|
||||
<div className="marketing-panel p-6 sm:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[280px_1fr]">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('common.search')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('home.searchToolsPlaceholder')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row lg:max-w-2xl">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<SectionIntro
|
||||
eyebrow={t('common.search')}
|
||||
title={t('home.toolsDirectoryTitle', 'Find the right tool faster')}
|
||||
description={t(
|
||||
'home.toolsDirectorySubtitle',
|
||||
'Search by task, format, or output and jump directly into the workflow you need.'
|
||||
)}
|
||||
/>
|
||||
|
||||
<label className="relative mt-6 block">
|
||||
<Search className="pointer-events-none absolute start-4 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
placeholder={t('home.searchToolsPlaceholder')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-3 pl-10 pr-4 text-sm text-slate-900 outline-none transition-colors focus:border-primary-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-primary-500"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white py-3 pl-11 pr-4 text-sm text-slate-900 outline-none transition-colors focus:border-primary-400 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-primary-500"
|
||||
/>
|
||||
</label>
|
||||
{query && (
|
||||
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery('')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-full border border-slate-200 px-3.5 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ── PDF Tools Grid ────────────────────────────────────────── */}
|
||||
<section className="mb-12">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('home.pdfTools')}
|
||||
</h2>
|
||||
<Link to="/tools" className="flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="metric-card">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-black text-slate-950 dark:text-white">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-950 dark:text-white">{t('home.pdfTools')}</h2>
|
||||
<Link to="/tools" className="inline-flex items-center gap-2 text-sm font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
{t('common.allTools')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-10">
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredPdfTools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
@@ -313,10 +382,11 @@ export default function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mb-6 text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
<div className="mt-10">
|
||||
<h2 className="mb-6 text-xl font-bold text-slate-950 dark:text-white">
|
||||
{t('home.otherTools', 'Other Tools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredOtherTools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
@@ -328,160 +398,130 @@ export default function HomePage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPdfTools.length + filteredOtherTools.length === 0 && (
|
||||
<div className="mb-12 rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center dark:border-slate-600 dark:bg-slate-800/50">
|
||||
<p className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||
{filteredPdfTools.length + filteredOtherTools.length === 0 ? (
|
||||
<div className="mt-8 rounded-[1.75rem] border border-dashed border-slate-300 bg-slate-50 p-8 text-center dark:border-slate-700 dark:bg-slate-800/40">
|
||||
<p className="text-base font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('home.noSearchResults')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features / Why Choose Us ──────────────────────────────── */}
|
||||
<section className="mb-14 overflow-hidden rounded-3xl bg-slate-50 px-6 py-16 dark:bg-slate-900 sm:px-12">
|
||||
<div className="mb-12 text-center">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('home.whyChooseLabel', 'Why Dociva')}
|
||||
</p>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
|
||||
{t('home.featuresTitle', 'A smarter way to work with files')}
|
||||
</h2>
|
||||
</div>
|
||||
<section className="mx-auto max-w-7xl px-4 pb-14 sm:px-6 lg:px-8">
|
||||
<SectionIntro
|
||||
align="center"
|
||||
eyebrow={t('home.whyChooseLabel', 'Why Dociva')}
|
||||
title={t('home.featuresTitle', 'A clearer, faster way to work with files')}
|
||||
description={t(
|
||||
'home.featuresSubtitle',
|
||||
'The redesign is built around workflow clarity: one workspace, strong defaults, and fewer decisions before value.'
|
||||
)}
|
||||
className="mb-10"
|
||||
/>
|
||||
|
||||
<div className="grid gap-8 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Layers,
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
titleKey: 'home.feature1Title',
|
||||
titleDefault: 'One complete workspace',
|
||||
descKey: 'home.feature1Desc',
|
||||
descDefault: 'Edit, convert, compress, merge, split — without switching tabs.',
|
||||
perks: [
|
||||
t('home.feature1Perk1', '30+ tools in one place'),
|
||||
t('home.feature1Perk2', 'PDF, image & video support'),
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
titleKey: 'home.feature2Title',
|
||||
titleDefault: 'Accuracy you can trust',
|
||||
descKey: 'home.feature2Desc',
|
||||
descDefault: 'Pixel-perfect, editable output in seconds with zero quality loss.',
|
||||
perks: [
|
||||
t('home.feature2Perk1', 'Preserve fonts & layouts'),
|
||||
t('home.feature2Perk2', 'Batch-tested quality'),
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
bg: 'bg-violet-100 dark:bg-violet-900/30',
|
||||
color: 'text-violet-600 dark:text-violet-400',
|
||||
titleKey: 'home.feature3Title',
|
||||
titleDefault: 'Built-in security',
|
||||
descKey: 'home.feature3Desc',
|
||||
descDefault: 'Files are automatically deleted after processing. No account required.',
|
||||
perks: [
|
||||
t('home.feature3Perk1', 'Auto-deletion after 1 hour'),
|
||||
t('home.feature3Perk2', 'Encrypted transfers'),
|
||||
],
|
||||
},
|
||||
].map(({ icon: Icon, bg, color, titleKey, titleDefault, descKey, descDefault, perks }) => (
|
||||
<div key={titleKey} className="flex flex-col rounded-2xl bg-white p-7 shadow-sm ring-1 ring-slate-200/80 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className={`mb-5 flex h-14 w-14 items-center justify-center rounded-2xl ${bg}`}>
|
||||
<Icon className={`h-7 w-7 ${color}`} />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{FEATURE_PANELS.map((panel) => {
|
||||
const Icon = panel.icon;
|
||||
const perks = panel.perks.map((perkKey, index) => t(perkKey, panel.fallbackPerks[index]));
|
||||
|
||||
return (
|
||||
<div key={panel.titleKey} className="marketing-card flex h-full flex-col p-7">
|
||||
<div className={`mb-5 flex h-14 w-14 items-center justify-center rounded-2xl ${panel.bgClassName}`}>
|
||||
<Icon className={`h-7 w-7 ${panel.iconClassName}`} />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{t(titleKey, titleDefault)}
|
||||
<h3 className="text-lg font-bold text-slate-950 dark:text-white">
|
||||
{t(panel.titleKey, panel.titleDefault)}
|
||||
</h3>
|
||||
<p className="mb-5 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
|
||||
{t(descKey, descDefault)}
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">
|
||||
{t(panel.descKey, panel.descDefault)}
|
||||
</p>
|
||||
<ul className="mt-auto space-y-2">
|
||||
<ul className="mt-6 space-y-2">
|
||||
{perks.map((perk) => (
|
||||
<li key={perk} className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
|
||||
<Star className="h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||
<li key={perk} className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
<Star className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
{perk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Developer API Banner ──────────────────────────────────── */}
|
||||
<section className="mb-10 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<section className="mx-auto max-w-7xl px-4 pb-10 sm:px-6 lg:px-8">
|
||||
<div className="marketing-panel overflow-hidden bg-gradient-to-br from-slate-950 via-primary-900 to-sky-900 px-8 py-10 text-white dark:from-slate-900 dark:via-primary-950 dark:to-slate-900 sm:px-10 lg:px-12">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary-200">
|
||||
{t('common.developers')}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-white">
|
||||
{t('pages.developers.ctaTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
<p className="mt-3 text-base leading-7 text-slate-200">
|
||||
{t('pages.developers.ctaSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
to="/developers"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 hover:-translate-y-px"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition-colors hover:bg-primary-100"
|
||||
>
|
||||
{t('pages.developers.openDocs')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
className="inline-flex items-center justify-center rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
className="inline-flex items-center justify-center rounded-full border border-white/20 bg-white/10 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/15"
|
||||
>
|
||||
{t('pages.developers.getApiKey')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Bottom CTA Banner ─────────────────────────────────────── */}
|
||||
<section className="relative mb-14 overflow-hidden rounded-[2rem] bg-gradient-to-br from-primary-600 via-primary-700 to-accent-700 px-8 py-16 text-center">
|
||||
{/* Decorative blobs */}
|
||||
<section className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="marketing-panel relative overflow-hidden bg-gradient-to-br from-primary-600 via-primary-700 to-accent-700 px-8 py-16 text-center text-white">
|
||||
<div className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-16 -left-16 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
|
||||
<div className="relative">
|
||||
<p className="mb-2 text-sm font-bold uppercase tracking-widest text-primary-200">
|
||||
<div className="relative mx-auto max-w-3xl">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary-200">
|
||||
{t('home.ctaBannerLabel', 'Get started today')}
|
||||
</p>
|
||||
<h2 className="mb-4 text-3xl font-extrabold text-white sm:text-4xl">
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-white sm:text-4xl">
|
||||
{t('home.ctaBannerTitle', 'Ready to convert your files?')}
|
||||
</h2>
|
||||
<p className="mx-auto mb-10 max-w-xl text-lg text-primary-100">
|
||||
<p className="mt-4 text-lg leading-8 text-primary-100">
|
||||
{t('home.ctaBannerSubtitle', 'Join thousands of users who convert, compress, and edit their files every day — completely free.')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white px-8 py-3.5 text-sm font-bold text-primary-700 shadow-lg transition-all hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-primary-700 shadow-lg transition-all hover:-translate-y-0.5 hover:shadow-xl"
|
||||
>
|
||||
{t('home.ctaBrowseTools', 'Browse All Tools')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
className="inline-flex items-center gap-2 rounded-xl border-2 border-white/30 bg-white/10 px-8 py-3.5 text-sm font-bold text-white backdrop-blur transition-all hover:bg-white/20 hover:-translate-y-0.5"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-8 py-3.5 text-sm font-bold text-white backdrop-blur transition-colors hover:bg-white/15"
|
||||
>
|
||||
{t('home.ctaCreateAccount', 'Create Free Account')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── Ad Slot - Bottom ──────────────────────────────────────── */}
|
||||
<AdSlot slot="home-bottom" className="mt-12" />
|
||||
</>
|
||||
</section>
|
||||
</MarketingPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||
import { ArrowRight, Check, Coins, Crown, Loader2, Scale, X, Zap } from 'lucide-react';
|
||||
import { ArrowRight, Check, Coins, Crown, Loader2, Scale, Shield, Zap } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||
import { getApiClient } from '@/services/api';
|
||||
@@ -15,35 +15,48 @@ interface PlanFeature {
|
||||
key: string;
|
||||
free: boolean | string;
|
||||
pro: boolean | string;
|
||||
enterprise: boolean | string;
|
||||
}
|
||||
|
||||
const FEATURES: PlanFeature[] = [
|
||||
{ key: 'credits', free: '50 credits/30 days', pro: '500 credits/30 days' },
|
||||
{ key: 'apiAccess', free: false, pro: true },
|
||||
{ key: 'apiRequests', free: '—', pro: '1,000/month' },
|
||||
{ key: 'maxFileSize', free: '50 MB', pro: '100 MB' },
|
||||
{ key: 'historyRetention', free: '25 files', pro: '250 files' },
|
||||
{ key: 'allTools', free: true, pro: true },
|
||||
{ key: 'aiTools', free: true, pro: true },
|
||||
{ key: 'priorityProcessing', free: false, pro: true },
|
||||
{ key: 'noAds', free: false, pro: true },
|
||||
{ key: 'emailSupport', free: false, pro: true },
|
||||
{ key: 'credits', free: '50 credits/30 days', pro: '500 credits/30 days', enterprise: 'Unlimited' },
|
||||
{ key: 'apiAccess', free: false, pro: true, enterprise: true },
|
||||
{ key: 'apiRequests', free: '—', pro: '1,000/month', enterprise: 'Unlimited' },
|
||||
{ key: 'maxFileSize', free: '50 MB', pro: '100 MB', enterprise: '500 MB' },
|
||||
{ key: 'historyRetention', free: '25 files', pro: '250 files', enterprise: 'Unlimited' },
|
||||
{ key: 'allTools', free: true, pro: true, enterprise: true },
|
||||
{ key: 'aiTools', free: true, pro: true, enterprise: true },
|
||||
{ key: 'priorityProcessing', free: false, pro: true, enterprise: true },
|
||||
{ key: 'noAds', free: false, pro: true, enterprise: true },
|
||||
{ key: 'emailSupport', free: false, pro: true, enterprise: true },
|
||||
{ key: 'customIntegrations', free: false, pro: false, enterprise: true },
|
||||
{ key: 'dedicatedSupport', free: false, pro: false, enterprise: true },
|
||||
{ key: 'userManagement', free: false, pro: false, enterprise: true },
|
||||
];
|
||||
|
||||
const MONTHLY_PRICES = { free: 0, pro: 9.99, enterprise: 29.99 };
|
||||
const YEARLY_PRICES = { free: 0, pro: 7.99, enterprise: 24.99 };
|
||||
|
||||
export default function PricingPage() {
|
||||
const { t } = useTranslation();
|
||||
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [billing, setBilling] = useState<'monthly' | 'yearly'>('yearly');
|
||||
|
||||
async function handleUpgrade(billing: 'monthly' | 'yearly') {
|
||||
async function handleUpgrade(plan: 'pro' | 'enterprise') {
|
||||
// Track interest in paid plan
|
||||
try {
|
||||
await api.post('/internal/admin/plan-interest/record', { plan: 'pro', billing });
|
||||
await api.post('/internal/admin/plan-interest/record', { plan, billing });
|
||||
} catch {
|
||||
// Non-critical — don't block the flow
|
||||
}
|
||||
|
||||
if (plan === 'enterprise') {
|
||||
window.location.href = '/contact';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
window.location.href = '/account?redirect=pricing';
|
||||
return;
|
||||
@@ -53,42 +66,64 @@ export default function PricingPage() {
|
||||
const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing });
|
||||
if (data.url) window.location.href = data.url;
|
||||
} catch {
|
||||
// Stripe not configured yet — show message
|
||||
alert(t('pages.pricing.stripeNotReady', 'Payment system is being set up. Please try again later.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue(val: boolean | string) {
|
||||
if (val === true) return <Check className="mx-auto h-5 w-5 text-green-500" />;
|
||||
if (val === false) return <X className="mx-auto h-5 w-5 text-slate-300 dark:text-slate-600" />;
|
||||
return <span className="text-sm font-medium text-slate-700 dark:text-slate-300">{val}</span>;
|
||||
}
|
||||
const prices = billing === 'yearly' ? YEARLY_PRICES : MONTHLY_PRICES;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead
|
||||
title={t('pages.pricing.title', 'Pricing')}
|
||||
description={t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva. Get more file processing power, API access, and priority support.')}
|
||||
description={t('pages.pricing.metaDescription', 'Compare Free, Pro, and Enterprise plans for Dociva. Get more file processing power, API access, and priority support.')}
|
||||
path="/pricing"
|
||||
jsonLd={generateWebPage({
|
||||
name: t('pages.pricing.title', 'Pricing'),
|
||||
description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'),
|
||||
description: t('pages.pricing.metaDescription', 'Compare plans for Dociva.'),
|
||||
url: `${siteOrigin}/pricing`,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Header + billing toggle */}
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="mb-4 text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
<h1 className="mb-4 text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white sm:text-4xl lg:text-5xl">
|
||||
{t('pages.pricing.title', 'Simple, Transparent Pricing')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400">
|
||||
{t('pages.pricing.subtitle', 'Start free with all tools. Upgrade when you need more power.')}
|
||||
{t('pages.pricing.subtitle', 'Unlock the power of your PDFs with flexible plans.')}
|
||||
</p>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="mt-8 inline-flex items-center gap-3 rounded-full border border-slate-200 bg-white px-2 py-1.5 shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBilling('monthly')}
|
||||
className={`rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === 'monthly'
|
||||
? 'bg-primary-600 text-white shadow-md'
|
||||
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t('pages.pricing.monthly', 'Monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBilling('yearly')}
|
||||
className={`rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === 'yearly'
|
||||
? 'bg-primary-600 text-white shadow-md'
|
||||
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t('pages.pricing.yearly', 'Yearly')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Transparency callout */}
|
||||
<div className="mx-auto mt-6 max-w-3xl rounded-2xl border border-primary-200 bg-primary-50/80 p-5 text-start shadow-sm dark:border-primary-900/40 dark:bg-primary-900/20">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
@@ -120,88 +155,76 @@ export default function PricingPage() {
|
||||
<SocialProofStrip />
|
||||
</div>
|
||||
|
||||
{/* Plan Cards */}
|
||||
<div className="mb-16 grid gap-8 md:grid-cols-2">
|
||||
{/* 3-tier Plan Cards */}
|
||||
<div className="mb-16 grid gap-8 md:grid-cols-3">
|
||||
{/* Free Plan */}
|
||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-700">
|
||||
<Zap className="h-6 w-6 text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
<div className="relative flex flex-col rounded-2xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="mb-6 rounded-xl bg-gradient-to-r from-primary-100 to-primary-50 py-3 text-center dark:from-primary-900/30 dark:to-primary-900/10">
|
||||
<h2 className="text-lg font-bold text-primary-700 dark:text-primary-300">
|
||||
{t('pages.pricing.freePlan', 'Free')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('pages.pricing.freeDesc', 'For personal use')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-extrabold text-slate-900 dark:text-white">$0</span>
|
||||
<span className="text-4xl font-extrabold text-slate-900 dark:text-white">${prices.free}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400"> / {t('pages.pricing.month', 'month')}</span>
|
||||
</div>
|
||||
|
||||
<ul className="mb-8 space-y-3">
|
||||
<ul className="mb-8 flex-1 space-y-3">
|
||||
{FEATURES.filter((f) => f.free !== false).map((f) => (
|
||||
<li key={f.key} className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Check className="h-4 w-4 shrink-0 text-green-500" />
|
||||
<li key={f.key} className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>
|
||||
{t(`pages.pricing.features.${f.key}`, f.key)}
|
||||
{typeof f.free === 'string' && (
|
||||
<span className="ml-auto text-xs font-medium text-slate-500">({f.free})</span>
|
||||
<span className="ml-1 text-xs text-slate-500">({f.free})</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
className="block w-full rounded-xl border border-slate-300 bg-white py-3 text-center text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
className="block w-full rounded-xl border border-primary-300 bg-white py-3 text-center text-sm font-semibold text-primary-700 transition-colors hover:bg-primary-50 dark:border-primary-700 dark:bg-slate-700 dark:text-primary-300 dark:hover:bg-slate-600"
|
||||
>
|
||||
{t('pages.pricing.getStarted', 'Get Started Free')}
|
||||
{t('pages.pricing.getStarted', 'Get Started')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<div className="relative rounded-2xl border-2 border-primary-500 bg-white p-8 shadow-lg dark:bg-slate-800">
|
||||
<div className="absolute -top-3 right-6 rounded-full bg-primary-600 px-4 py-1 text-xs font-bold text-white">
|
||||
<div className="relative flex flex-col rounded-2xl border-2 border-primary-500 bg-white p-8 shadow-lg dark:bg-slate-800">
|
||||
<div className="absolute -top-3 right-6 rounded-full bg-slate-800 px-4 py-1 text-xs font-bold text-white dark:bg-white dark:text-slate-900">
|
||||
{t('pages.pricing.popular', 'MOST POPULAR')}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<Crown className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
<div className="mb-6 rounded-xl bg-gradient-to-r from-primary-600 to-primary-500 py-3 text-center">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
{t('pages.pricing.proPlan', 'Pro')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('pages.pricing.proDesc', 'For professionals & teams')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-extrabold text-slate-900 dark:text-white">$9</span>
|
||||
<span className="text-4xl font-extrabold text-slate-900 dark:text-white">${prices.pro}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400"> / {t('pages.pricing.month', 'month')}</span>
|
||||
</div>
|
||||
|
||||
<ul className="mb-8 space-y-3">
|
||||
{FEATURES.map((f) => (
|
||||
<li key={f.key} className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Check className="h-4 w-4 shrink-0 text-primary-500" />
|
||||
<ul className="mb-8 flex-1 space-y-3">
|
||||
{FEATURES.filter((f) => f.pro !== false).map((f) => (
|
||||
<li key={f.key} className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>
|
||||
{t(`pages.pricing.features.${f.key}`, f.key)}
|
||||
{typeof f.pro === 'string' && (
|
||||
<span className="ml-auto text-xs font-medium text-primary-600 dark:text-primary-400">({f.pro})</span>
|
||||
<span className="ml-1 text-xs text-primary-600 dark:text-primary-400">({f.pro})</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => handleUpgrade('monthly')}
|
||||
onClick={() => handleUpgrade('pro')}
|
||||
disabled={loading || user?.plan === 'pro'}
|
||||
className="block w-full rounded-xl bg-primary-600 py-3 text-center text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@@ -210,15 +233,48 @@ export default function PricingPage() {
|
||||
) : user?.plan === 'pro' ? (
|
||||
t('pages.pricing.currentPlan', 'Current Plan')
|
||||
) : (
|
||||
t('pages.pricing.upgradeToPro', 'Upgrade to Pro')
|
||||
t('pages.pricing.startFreeTrial', 'Start Your Free Trial')
|
||||
)}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('pages.pricing.securePayment', 'Secure payment via Stripe')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
<div className="relative flex flex-col rounded-2xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<div className="mb-6 rounded-xl bg-gradient-to-r from-violet-200 to-violet-100 py-3 text-center dark:from-violet-900/30 dark:to-violet-900/10">
|
||||
<h2 className="text-lg font-bold text-violet-700 dark:text-violet-300">
|
||||
{t('pages.pricing.enterprisePlan', 'Enterprise')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-extrabold text-slate-900 dark:text-white">${prices.enterprise}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400"> / {t('pages.pricing.month', 'month')}</span>
|
||||
</div>
|
||||
|
||||
<ul className="mb-8 flex-1 space-y-3">
|
||||
{FEATURES.filter((f) => f.enterprise !== false).map((f) => (
|
||||
<li key={f.key} className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>
|
||||
{t(`pages.pricing.features.${f.key}`, f.key)}
|
||||
{typeof f.enterprise === 'string' && (
|
||||
<span className="ml-1 text-xs text-violet-600 dark:text-violet-400">({f.enterprise})</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => handleUpgrade('enterprise')}
|
||||
className="block w-full rounded-xl border border-violet-300 bg-violet-50 py-3 text-center text-sm font-semibold text-violet-700 transition-colors hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-900/20 dark:text-violet-300 dark:hover:bg-violet-900/40"
|
||||
>
|
||||
{t('pages.pricing.contactSales', 'Contact Sales')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust section */}
|
||||
<section className="deferred-section mb-16 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
@@ -244,39 +300,16 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<div className="mb-16 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<th className="px-6 py-4 text-left font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('pages.pricing.feature', 'Feature')}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('pages.pricing.freePlan', 'Free')}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center font-semibold text-primary-600 dark:text-primary-400">
|
||||
{t('pages.pricing.proPlan', 'Pro')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURES.map((f, idx) => (
|
||||
<tr
|
||||
key={f.key}
|
||||
className={`border-b border-slate-100 dark:border-slate-700/50 ${
|
||||
idx % 2 === 0 ? 'bg-white dark:bg-slate-800' : 'bg-slate-50/50 dark:bg-slate-800/30'
|
||||
}`}
|
||||
>
|
||||
<td className="px-6 py-3 text-slate-700 dark:text-slate-300">
|
||||
{t(`pages.pricing.features.${f.key}`, f.key)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-center">{renderValue(f.free)}</td>
|
||||
<td className="px-6 py-3 text-center">{renderValue(f.pro)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Bottom trust badges */}
|
||||
<div className="mb-16 flex flex-wrap items-center justify-center gap-8 text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t('pages.pricing.securePayment', 'Secure Payment')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
{t('pages.pricing.moneyBack', '30-Day Money Back Guarantee')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, FolderKanban, Link2 } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import FAQSection from '@/components/seo/FAQSection';
|
||||
@@ -241,6 +242,8 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
||||
</section>
|
||||
|
||||
<FAQSection faqs={faqItems} />
|
||||
|
||||
<AdSlot slot="bottom-banner" format="horizontal" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import FAQSection from '@/components/seo/FAQSection';
|
||||
@@ -322,6 +323,8 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
||||
</section>
|
||||
|
||||
<FAQSection faqs={faqItems} />
|
||||
|
||||
<AdSlot slot="bottom-banner" format="horizontal" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const CSRF_COOKIE_NAME = 'csrf_token';
|
||||
const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||
@@ -160,21 +161,27 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
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 errorCode: string | undefined = responseData?.error_code;
|
||||
if (errorCode) {
|
||||
const mapped = resolveErrorCode(errorCode);
|
||||
if (mapped) return Promise.reject(new Error(mapped));
|
||||
}
|
||||
const message =
|
||||
responseData?.user_message ||
|
||||
responseData?.error ||
|
||||
responseData?.message ||
|
||||
(typeof responseData === 'string' && responseData.trim()
|
||||
? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
: null) ||
|
||||
`Request failed (${error.response.status}).`;
|
||||
i18n.t('common.errors.serverError');
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -251,7 +258,57 @@ function isTaskErrorPayload(value: unknown): value is TaskErrorPayload {
|
||||
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 {
|
||||
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()) {
|
||||
return error.trim();
|
||||
}
|
||||
|
||||
@@ -169,6 +169,40 @@
|
||||
linear-gradient(180deg, #0f172a 0%, #0f172a 100%);
|
||||
}
|
||||
|
||||
.marketing-shell {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.1), transparent 30%),
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.08), transparent 26%),
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.88) 0%, rgba(255, 255, 255, 0.92) 26%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.dark .marketing-shell {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.18), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 0.96) 0%, rgba(15, 23, 42, 0.98) 26%, #020617 100%);
|
||||
}
|
||||
|
||||
.marketing-panel {
|
||||
@apply rounded-[2rem] border border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-700/70 dark:bg-slate-900/70;
|
||||
}
|
||||
|
||||
.marketing-card {
|
||||
@apply rounded-[1.75rem] border border-slate-200/80 bg-white/90 p-6 shadow-sm transition-all duration-300 dark:border-slate-700/70 dark:bg-slate-900/75;
|
||||
}
|
||||
|
||||
.marketing-card:hover {
|
||||
@apply -translate-y-1 shadow-lg shadow-slate-200/70 dark:shadow-slate-950/30;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
@apply rounded-3xl border border-slate-200/80 bg-white/85 p-5 shadow-sm backdrop-blur-sm dark:border-slate-700/70 dark:bg-slate-900/70;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em] text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Shimmer loading effect
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -88,6 +88,14 @@ export default defineConfig({
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
if (id.includes('lucide-react')) {
|
||||
return 'icons';
|
||||
}
|
||||
|
||||
if (id.includes('@microsoft/clarity')) {
|
||||
return 'analytics';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,6 +14,20 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Gitea HTTP (ACME + redirect) ---
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.dociva.io;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 308 https://git.dociva.io$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect www to non-www
|
||||
server {
|
||||
listen 443 ssl;
|
||||
@@ -65,10 +79,10 @@ server {
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# Brotli (if module is available)
|
||||
brotli on;
|
||||
brotli_comp_level 5;
|
||||
brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
# Brotli (requires ngx_brotli module — disabled for stock nginx:alpine)
|
||||
# brotli on;
|
||||
# brotli_comp_level 5;
|
||||
# brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
|
||||
# SSE streaming for assistant chat
|
||||
location /api/assistant/chat/stream {
|
||||
@@ -114,3 +128,40 @@ server {
|
||||
proxy_pass http://$backend_upstream/api/health;
|
||||
}
|
||||
}
|
||||
|
||||
# --- Gitea HTTPS reverse proxy ---
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name git.dociva.io;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/git.dociva.io/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.dociva.io/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 100M;
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://gitea:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# Used by Gitea (and other WS-enabled apps)
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
1152
scripts/build_keyword_portfolio.py
Normal file
1152
scripts/build_keyword_portfolio.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user