Compare commits

..

14 Commits

Author SHA1 Message Date
Your Name
bc948505e5 feat: enhance Content Security Policy and add mobile web app capability 2026-04-06 14:18:02 +02:00
Your Name
f63c929f25 fix: update API endpoint for signing PDF to match new route 2026-04-06 12:03:09 +02:00
Your Name
4e0da90558 feat: add comprehensive code review guidelines and checklist 2026-04-06 11:10:22 +02:00
Your Name
a539ad43af feat: add PWA support with service worker and update prompt
- Updated package.json to include vite-plugin-pwa and workbox-window.
- Added icon SVGs for PWA: icon-512.svg and maskable-512.svg.
- Created a manifest.json for PWA configuration.
- Implemented PwaUpdatePrompt component to notify users of available updates.
- Enhanced CookieConsent and SiteAssistant components for better layout and responsiveness.
- Updated global CSS for safe-area insets and mobile-first enhancements.
- Registered service worker in usePwaRegistration hook for managing updates.
- Modified Vite configuration to integrate PWA features and caching strategies.
2026-04-06 08:12:32 +02:00
Your Name
c483e8508b security: Hide Gitea HTTP port behind nginx 2026-04-05 20:51:36 +02:00
Your Name
586d93eb41 fix: Use git.dociva.io TLS certificate for Gitea 2026-04-05 20:48:19 +02:00
Your Name
d066d8e414 fix: Prevent nginx crash before git.dociva.io cert exists 2026-04-05 19:47:29 +02:00
Your Name
f57779205b feat: Add git.dociva.io reverse proxy for Gitea 2026-04-05 19:43:20 +02:00
Your Name
3f4d874560 feat: Add Gitea service to production compose 2026-04-05 15:25:26 +02:00
Your Name
ade7abac46 feat(i18n): update translations and improve error handling messages
- Updated site tagline and footer description in multiple languages.
- Enhanced error messages for various scenarios in the API service.
- Added translations for new error codes related to AI features and PDF processing.
- Improved user feedback in the UI components by utilizing i18n for dynamic text.
- Refactored error handling in the API service to map backend error codes to user-friendly messages.
2026-04-05 10:12:22 +02:00
Your Name
8693834230 feat: add Google AdSense verification script + expand ad coverage
- index.html: add adsbygoogle.js script with ca-pub-3822257947737372 for verification
- BlogPostPage: add bottom-banner AdSlot below article content
- AllToolsPage: add bottom-banner AdSlot below tools grid
- ComparisonPage: add bottom-banner AdSlot below comparison content
- SeoPage: add bottom-banner AdSlot below FAQ section
- SeoCollectionPage: add bottom-banner AdSlot below FAQ section

Ad coverage: 13 tool pages + homepage + 5 content pages = ~32 ad placements
2026-04-05 00:08:31 +02:00
Your Name
009ac7f087 fix: disable brotli in nginx.prod.conf (not available in nginx:alpine) 2026-04-04 22:46:06 +02:00
Your Name
7928e688d5 perf: optimize frontend bundle - reduce main chunk 77%
- vite.config: separate lucide-react icons + analytics into own chunks
- App.tsx: defer SiteAssistant loading via requestIdleCallback
- HeroUploadZone: lazy-load ToolSelectorModal + dynamic import fileRouting
- HeroUploadZone: add aria-label on dropzone input (accessibility)
- SocialProofStrip: defer API call until component is in viewport
- index.html: remove dev-only modulepreload hint

Main bundle: 266KB -> 61KB (-77%)
2026-04-04 22:36:45 +02:00
Your Name
7e9edc2992 feat: Enhance Pricing Page with Enterprise Plan and Billing Toggle
- Added Enterprise plan with features and pricing.
- Introduced billing toggle for monthly and yearly subscriptions.
- Updated feature list to include enterprise-specific features.
- Improved UI for plan cards and added new styles for better visual appeal.
- Adjusted SEO metadata to reflect new pricing structure.
- Enhanced global styles for marketing elements.
2026-04-04 20:01:03 +02:00
67 changed files with 8154 additions and 1330 deletions

View File

@@ -80,6 +80,10 @@ INDEXNOW_AUTO_SUBMIT=true
INDEXNOW_STRICT=false INDEXNOW_STRICT=false
INDEXNOW_FULL_SUBMIT=false INDEXNOW_FULL_SUBMIT=false
# Gitea (optional)
GITEA_DOMAIN=
GITEA_ROOT_URL=
# Frontend Analytics / Ads (Vite) # Frontend Analytics / Ads (Vite)
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
VITE_PLAUSIBLE_DOMAIN=dociva.io VITE_PLAUSIBLE_DOMAIN=dociva.io

136
.github/skills/code-review/SKILL.md vendored Normal file
View File

@@ -0,0 +1,136 @@
---
name: code-review
description: 'مراجعة الكود وإصلاح الأخطاء في مشروع SaaS-PDF. استخدم عند: تشخيص مشكلة، مراجعة ملف أو ميزة، إصلاح خطأ، التحقق من الأمان (OWASP)، تغطية الاختبارات، أداء الكود، توافق Frontend/Backend. Use when: code review, bug fix, security audit, performance check, test coverage, logic review.'
argument-hint: 'اذكر الملف أو الميزة أو المشكلة المراد مراجعتها'
---
# مراجعة الكود وإصلاح الأخطاء — SaaS-PDF
## متى تُستخدم هذه المهارة
- عند الإبلاغ عن خطأ أو مشكلة في الكود
- عند مراجعة ملف أو خدمة أو endpoint محدد
- عند إضافة ميزة جديدة والتحقق من جودتها
- عند الرغبة في تقرير شامل بالأخطاء والتوصيات
---
## الخطوات المتبعة
### 1. فهم المشكلة والسياق
- اقرأ المعطيات الأولية: رسالة الخطأ، الملف المعني، السلوك المتوقع مقابل الفعلي.
- ابحث في قاعدة الكود بحثاً مستهدفاً (لا تفترض — تحقق).
- حدد نطاق التأثير: هل المشكلة في `backend/`، `frontend/`، أم في كليهما؟
### 2. البحث عن أفضل حل حديث
- قارن الحلول المتاحة بناءً على:
- التوافق مع الإصدار الحالي من المكتبات (`requirements.txt` / `package.json`)
- أفضل الممارسات لعام 2025+
- أقل تعقيد وأكبر صيانة
- اختر الحل الأكثر مباشرةً دون هندسة مفرطة.
### 3. قائمة التحقق الشاملة
#### الأمان (OWASP Top 10)
- [ ] لا يوجد SQL Injection أو NoSQL Injection
- [ ] لا يوجد XSS أو CSRF غير محمي
- [ ] التحقق من صحة المدخلات على الـ backend (لا تثق في الـ frontend فقط)
- [ ] لا توجد بيانات حساسة مكشوفة في الاستجابات أو logs
- [ ] الجلسات ورموز JWT محمية بشكل صحيح
- [ ] لا توجد أذونات مفرطة في المسارات المحمية
#### معايير الكود
- [ ] يتبع الكود أسلوب `backend/` الحالي (Flask Blueprints, Services pattern)
- [ ] يتبع الكود أسلوب `frontend/` الحالي (Vite/TypeScript, custom hooks)
- [ ] لا توجد وظائف متكررة موجودة مسبقاً في `services/` أو `utils/`
- [ ] أسماء المتغيرات والدوال واضحة ومعبّرة
#### الاختبارات
- [ ] يوجد اختبار وحدة يغطي السلوك الجديد أو المُصلَح
- [ ] اختبارات backend: `cd backend && python -m pytest tests/ -q`
- [ ] اختبارات frontend: `cd frontend && npx vitest run`
- [ ] لا يوجد اختبار مكسور قبل أو بعد التغيير
#### الأداء
- [ ] لا توجد استعلامات N+1 أو عمليات تكرارية غير ضرورية
- [ ] الملفات الكبيرة تُعالَج بشكل منقسم (streaming/chunked)
- [ ] لا يوجد blocking I/O في Celery tasks
- [ ] استجابة الـ API ضمن الحدود الطبيعية
#### منطق الأعمال
- [ ] السلوك يتطابق مع المتطلبات الموثقة في `docs/`
- [ ] حالات الحافة (edge cases) معالجة (ملف فارغ، مستخدم غير مسجل، إلخ)
- [ ] رسائل الخطأ مفيدة للمستخدم وليست كاشفة للنظام
#### توافق Frontend/Backend
- [ ] نقاط النهاية (endpoints) متطابقة بين `routes/` والاستدعاءات في `frontend/src/`
- [ ] هياكل البيانات المُرسَلة والمُستقبَلة متوافقة
- [ ] رموز HTTP الصحيحة مستخدمة (200, 201, 400, 401, 403, 404, 422, 500)
### 4. تطبيق الإصلاح
- طبّق أصغر تغيير ممكن يحل المشكلة.
- لا تعيد هيكلة كود لم يُطلب تغييره.
- اختبر التغيير فوراً بعد تطبيقه.
### 5. التحقق من عمل المشروع بالكامل
بعد الإصلاح، شغّل هذه الأوامر:
```bash
# Backend
cd backend && python -m pytest tests/ -q
# Frontend
cd frontend && npx vitest run
# Full stack (إن كان Docker متاحاً)
docker compose up --build
```
### 6. إنتاج التقرير
أنتج تقريراً يحتوي على:
```
## تقرير مراجعة الكود
### الملفات المراجعة
- [اذكر الملفات]
### المشاكل المكتشفة
| الأولوية | المشكلة | الملف | السطر | الحل المقترح |
|----------|---------|-------|-------|--------------|
| عالية | ... | ... | ... | ... |
### الإصلاحات المطبقة
- [ما تم إصلاحه بالفعل]
### توصيات لاحقة
- [مشاكل غير حرجة تستحق المعالجة لاحقاً]
### نتائج الاختبارات
- Backend: ✅ / ❌
- Frontend: ✅ / ❌
```
---
## هيكل المشروع (مرجع سريع)
| الطبقة | المسار | التقنية |
|--------|--------|---------|
| Backend | `backend/app/routes/` | Flask Blueprints |
| Services | `backend/app/services/` | Python classes |
| Tasks | `backend/app/tasks/` | Celery |
| Tests | `backend/tests/` | pytest |
| Frontend | `frontend/src/` | Vite + TypeScript + React |
| API hooks | `frontend/src/hooks/` | Custom React hooks |
## أوامر مفيدة
```bash
# تشغيل اختبار واحد
cd backend && python -m pytest tests/test_<name>.py -v
# فحص أخطاء TypeScript
cd frontend && npx tsc --noEmit
# عرض تغطية الاختبارات
cd backend && python -m pytest tests/ --cov=app --cov-report=term-missing
```

View File

@@ -80,6 +80,7 @@ def create_app(config_name=None, config_overrides=None):
"https://pagead2.googlesyndication.com", "https://pagead2.googlesyndication.com",
"https://www.googletagmanager.com", "https://www.googletagmanager.com",
"https://www.google-analytics.com", "https://www.google-analytics.com",
"https://www.clarity.ms",
], ],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
"font-src": ["'self'", "https://fonts.gstatic.com"], "font-src": ["'self'", "https://fonts.gstatic.com"],
@@ -99,6 +100,7 @@ def create_app(config_name=None, config_overrides=None):
"https://www.google-analytics.com", "https://www.google-analytics.com",
"https://pagead2.googlesyndication.com", "https://pagead2.googlesyndication.com",
"https://*.amazonaws.com", "https://*.amazonaws.com",
"https://*.adtrafficquality.google",
], ],
} }
talisman.init_app( talisman.init_app(

View File

@@ -143,7 +143,7 @@ def pptx_to_pdf_route():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sign PDF — POST /api/pdf-tools/sign # Sign PDF — POST /api/convert/sign
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pdf_convert_bp.route("/sign", methods=["POST"]) @pdf_convert_bp.route("/sign", methods=["POST"])
@limiter.limit("10/minute") @limiter.limit("10/minute")

View File

@@ -181,6 +181,27 @@ services:
- frontend_build:/app/dist - frontend_build:/app/dist
- indexnow_state:/app/.indexnow - 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: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
@@ -189,3 +210,4 @@ volumes:
db_data: db_data:
frontend_build: frontend_build:
indexnow_state: indexnow_state:
gitea_data:

View File

@@ -4,11 +4,16 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="apple-touch-icon" href="/icons/icon-512.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" <meta name="description"
content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." /> content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
<meta name="application-name" content="Dociva" /> <meta name="application-name" content="Dociva" />
<meta name="apple-mobile-web-app-title" content="Dociva" /> <meta name="apple-mobile-web-app-title" content="Dociva" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="theme-color" content="#2563eb" /> <meta name="theme-color" content="#2563eb" />
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" /> <meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
<meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" /> <meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" />
@@ -52,6 +57,8 @@
} }
})(); })();
</script> </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.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" /> <link rel="dns-prefetch" href="https://fonts.gstatic.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" /> <link rel="dns-prefetch" href="https://www.googletagmanager.com" />
@@ -65,7 +72,6 @@
<noscript> <noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Tajawal:wght@400;700&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Tajawal:wght@400;700&display=swap" />
</noscript> </noscript>
<link rel="modulepreload" href="/src/main.tsx" />
<title>Dociva — Free Online File Tools</title> <title>Dociva — Free Online File Tools</title>
</head> </head>

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,9 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs" "seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs",
"lint:i18n": "node scripts/check-i18n-keys.mjs",
"lint:hardcoded": "node scripts/check-hardcoded-text.mjs"
}, },
"dependencies": { "dependencies": {
"@microsoft/clarity": "^1.0.2", "@microsoft/clarity": "^1.0.2",
@@ -46,6 +48,8 @@
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^4.0.18" "vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.18",
"workbox-window": "^7.4.0"
} }
} }

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#7C3AED"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<path d="M160 128h112l80 80v176a16 16 0 0 1-16 16H160a16 16 0 0 1-16-16V144a16 16 0 0 1 16-16z" fill="rgba(255,255,255,0.15)" stroke="#fff" stroke-width="16"/>
<path d="M272 128v64a16 16 0 0 0 16 16h64" stroke="#fff" stroke-width="16" fill="none"/>
<path d="M192 256h128M192 296h96M192 336h64" stroke="#93C5FD" stroke-width="14" stroke-linecap="round"/>
<text x="312" y="432" font-family="Arial,Helvetica,sans-serif" font-weight="700" font-size="128" fill="#E0E7FF" opacity="0.6">D</text>
</svg>

After

Width:  |  Height:  |  Size: 868 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<!-- Maskable icon: safe zone is central 80% (409.6px), so content fits within ~51-461 -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#7C3AED"/>
</linearGradient>
</defs>
<!-- Full bleed background -->
<rect width="512" height="512" fill="url(#bg)"/>
<!-- Content centered within safe zone -->
<path d="M176 144h96l72 72v160a14 14 0 0 1-14 14H176a14 14 0 0 1-14-14V158a14 14 0 0 1 14-14z" fill="rgba(255,255,255,0.15)" stroke="#fff" stroke-width="14"/>
<path d="M272 144v56a14 14 0 0 0 14 14h56" stroke="#fff" stroke-width="14" fill="none"/>
<path d="M204 264h112M204 300h84M204 336h56" stroke="#93C5FD" stroke-width="12" stroke-linecap="round"/>
<text x="296" y="416" font-family="Arial,Helvetica,sans-serif" font-weight="700" font-size="112" fill="#E0E7FF" opacity="0.6">D</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,57 @@
{
"name": "Dociva — Free Online File Tools",
"short_name": "Dociva",
"description": "30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required.",
"start_url": "/",
"id": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "any",
"scope": "/",
"categories": ["productivity", "utilities"],
"dir": "auto",
"lang": "en",
"prefer_related_applications": false,
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/maskable-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"screenshots": [],
"shortcuts": [
{
"name": "Compress PDF",
"short_name": "Compress",
"url": "/compress-pdf",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
},
{
"name": "Merge PDF",
"short_name": "Merge",
"url": "/merge-pdf",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
},
{
"name": "Convert to PDF",
"short_name": "Convert",
"url": "/image-to-pdf",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
}
]
}

View File

@@ -2,18 +2,22 @@
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap> <sitemap>
<loc>https://dociva.io/sitemaps/static.xml</loc> <loc>https://dociva.io/sitemaps/static.xml</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
</sitemap> </sitemap>
<sitemap> <sitemap>
<loc>https://dociva.io/sitemaps/blog.xml</loc> <loc>https://dociva.io/sitemaps/blog.xml</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
</sitemap> </sitemap>
<sitemap> <sitemap>
<loc>https://dociva.io/sitemaps/tools.xml</loc> <loc>https://dociva.io/sitemaps/tools.xml</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
</sitemap> </sitemap>
<sitemap> <sitemap>
<loc>https://dociva.io/sitemaps/seo.xml</loc> <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> </sitemap>
</sitemapindex> </sitemapindex>

View File

@@ -2,31 +2,31 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc> <loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc> <loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc> <loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/merge-split-pdf-files</loc> <loc>https://dociva.io/blog/merge-split-pdf-files</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc> <loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>

View 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

View File

@@ -2,61 +2,61 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://dociva.io/</loc> <loc>https://dociva.io/</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>daily</changefreq> <changefreq>daily</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools</loc> <loc>https://dociva.io/tools</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/about</loc> <loc>https://dociva.io/about</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.4</priority> <priority>0.4</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/contact</loc> <loc>https://dociva.io/contact</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.4</priority> <priority>0.4</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/privacy</loc> <loc>https://dociva.io/privacy</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/terms</loc> <loc>https://dociva.io/terms</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/pricing</loc> <loc>https://dociva.io/pricing</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/pricing-transparency</loc> <loc>https://dociva.io/pricing-transparency</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog</loc> <loc>https://dociva.io/blog</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/developers</loc> <loc>https://dociva.io/developers</loc>
<lastmod>2026-04-03</lastmod> <lastmod>2026-04-05</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.5</priority> <priority>0.5</priority>
</url> </url>

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect } from 'react'; import { lazy, Suspense, useEffect, useState } from 'react';
import Clarity from '@microsoft/clarity'; import Clarity from '@microsoft/clarity';
import { Routes, Route, useLocation } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
@@ -34,6 +34,7 @@ const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
const ComparisonPage = lazy(() => import('@/pages/ComparisonPage')); const ComparisonPage = lazy(() => import('@/pages/ComparisonPage'));
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent')); const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant')); const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
const PwaUpdatePrompt = lazy(() => import('@/components/layout/PwaUpdatePrompt'));
// Tool components — derived from manifest using React.lazy // Tool components — derived from manifest using React.lazy
const ToolComponents = Object.fromEntries( const ToolComponents = Object.fromEntries(
@@ -48,11 +49,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() { export default function App() {
useDirection(); useDirection();
const location = useLocation(); const location = useLocation();
const refreshUser = useAuthStore((state) => state.refreshUser); const refreshUser = useAuthStore((state) => state.refreshUser);
const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; 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(() => { useEffect(() => {
initAnalytics(); initAnalytics();
@@ -103,7 +121,7 @@ export default function App() {
<div className="flex min-h-screen flex-col bg-slate-50 transition-colors duration-300 dark:bg-slate-950"> <div className="flex min-h-screen flex-col bg-slate-50 transition-colors duration-300 dark:bg-slate-950">
<Header /> <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> <ErrorBoundary>
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<Routes> <Routes>
@@ -148,8 +166,11 @@ export default function App() {
<Footer /> <Footer />
<Suspense fallback={null}> <Suspense fallback={null}>
<IdleLoad>
<SiteAssistant /> <SiteAssistant />
</IdleLoad>
<CookieConsent /> <CookieConsent />
<PwaUpdatePrompt />
</Suspense> </Suspense>
<Toaster <Toaster
position={isRTL ? 'top-left' : 'top-right'} position={isRTL ? 'top-left' : 'top-right'}

View File

@@ -70,7 +70,7 @@ export default function CookieConsent() {
<div <div
role="dialog" role="dialog"
aria-label={t('cookie.title', 'Cookie Consent')} aria-label={t('cookie.title', 'Cookie Consent')}
className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6" className="fixed inset-x-0 bottom-0 z-50 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:p-6 sm:pb-[max(1.5rem,env(safe-area-inset-bottom))]"
> >
<div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:flex sm:items-start sm:gap-4"> <div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:flex sm:items-start sm:gap-4">
<div className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400 sm:mb-0"> <div className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400 sm:mb-0">

View File

@@ -1,71 +1,123 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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: [ PDF: [
{ slug: 'pdf-to-word', label: 'PDF to Word' }, { slug: 'pdf-to-word', i18nKey: 'tools.pdfToWord.title' },
{ slug: 'compress-pdf', label: 'Compress PDF' }, { slug: 'compress-pdf', i18nKey: 'tools.compressPdf.title' },
{ slug: 'merge-pdf', label: 'Merge PDF' }, { slug: 'merge-pdf', i18nKey: 'tools.mergePdf.title' },
{ slug: 'split-pdf', label: 'Split PDF' }, { slug: 'split-pdf', i18nKey: 'tools.splitPdf.title' },
{ slug: 'pdf-to-images', label: 'PDF to Images' }, { slug: 'pdf-to-images', i18nKey: 'tools.pdfToImages.title' },
{ slug: 'protect-pdf', label: 'Protect PDF' }, { slug: 'protect-pdf', i18nKey: 'tools.protectPdf.title' },
{ slug: 'watermark-pdf', label: 'Watermark PDF' }, { slug: 'watermark-pdf', i18nKey: 'tools.watermarkPdf.title' },
{ slug: 'pdf-editor', label: 'PDF Editor' }, { slug: 'pdf-editor', i18nKey: 'tools.pdfEditor.title' },
], ],
'Image & Convert': [ 'Image & Convert': [
{ slug: 'compress-image', label: 'Compress Image' }, { slug: 'compress-image', i18nKey: 'tools.compressImage.title' },
{ slug: 'image-converter', label: 'Image Converter' }, { slug: 'image-converter', i18nKey: 'tools.imageConvert.title' },
{ slug: 'image-resize', label: 'Image Resize' }, { slug: 'image-resize', i18nKey: 'tools.imageResize.title' },
{ slug: 'remove-background', label: 'Remove Background' }, { slug: 'remove-background', i18nKey: 'tools.removeBg.title' },
{ slug: 'word-to-pdf', label: 'Word to PDF' }, { slug: 'word-to-pdf', i18nKey: 'tools.wordToPdf.title' },
{ slug: 'html-to-pdf', label: 'HTML to PDF' }, { slug: 'html-to-pdf', i18nKey: 'tools.htmlToPdf.title' },
{ slug: 'pdf-to-excel', label: 'PDF to Excel' }, { slug: 'pdf-to-excel', i18nKey: 'tools.pdfToExcel.title' },
], ],
'AI & Utility': [ 'AI & Utility': [
{ slug: 'chat-pdf', label: 'Chat with PDF' }, { slug: 'chat-pdf', i18nKey: 'tools.chatPdf.title' },
{ slug: 'summarize-pdf', label: 'Summarize PDF' }, { slug: 'summarize-pdf', i18nKey: 'tools.summarizePdf.title' },
{ slug: 'translate-pdf', label: 'Translate PDF' }, { slug: 'translate-pdf', i18nKey: 'tools.translatePdf.title' },
{ slug: 'ocr', label: 'OCR' }, { slug: 'ocr', i18nKey: 'tools.ocr.title' },
{ slug: 'qr-code', label: 'QR Code Generator' }, { slug: 'qr-code', i18nKey: 'tools.qrCode.title' },
{ slug: 'video-to-gif', label: 'Video to GIF' }, { slug: 'video-to-gif', i18nKey: 'tools.videoToGif.title' },
{ slug: 'word-counter', label: 'Word Counter' }, { slug: 'word-counter', i18nKey: 'tools.wordCounter.title' },
], ],
Guides: [ Guides: [
{ slug: 'best-pdf-tools', label: 'Best PDF Tools', isLanding: true }, { slug: 'best-pdf-tools', i18nKey: 'footer.guides.bestPdfTools', isLanding: true },
{ slug: 'free-pdf-tools-online', label: 'Free PDF Tools Online', isLanding: true }, { slug: 'free-pdf-tools-online', i18nKey: 'footer.guides.freePdfToolsOnline', isLanding: true },
{ slug: 'convert-files-online', label: 'Convert Files Online', isLanding: true }, { slug: 'convert-files-online', i18nKey: 'footer.guides.convertFilesOnline', isLanding: true },
], ],
Comparisons: [ Comparisons: [
{ slug: 'compress-pdf-vs-ilovepdf', label: 'Dociva vs iLovePDF', isComparison: true }, { slug: 'compress-pdf-vs-ilovepdf', i18nKey: 'footer.comparisons.compressPdfVsIlovepdf', isComparison: true },
{ slug: 'merge-pdf-vs-smallpdf', label: 'Dociva vs Smallpdf', isComparison: true }, { slug: 'merge-pdf-vs-smallpdf', i18nKey: 'footer.comparisons.mergePdfVsSmallpdf', isComparison: true },
{ slug: 'pdf-to-word-vs-adobe-acrobat', label: 'Dociva vs Adobe Acrobat', isComparison: true }, { slug: 'pdf-to-word-vs-adobe-acrobat', i18nKey: 'footer.comparisons.pdfToWordVsAdobeAcrobat', isComparison: true },
{ slug: 'compress-image-vs-tinypng', label: 'Dociva vs TinyPNG', isComparison: true }, { slug: 'compress-image-vs-tinypng', i18nKey: 'footer.comparisons.compressImageVsTinypng', isComparison: true },
{ slug: 'ocr-vs-adobe-scan', label: 'Dociva vs Adobe Scan', isComparison: true }, { slug: 'ocr-vs-adobe-scan', i18nKey: 'footer.comparisons.ocrVsAdobeScan', isComparison: true },
], ],
}; };
const CATEGORY_KEYS: Record<string, string> = {
'PDF': 'footer.categories.pdf',
'Image & Convert': 'footer.categories.imageConvert',
'AI & Utility': 'footer.categories.aiUtility',
'Guides': 'footer.categories.guides',
'Comparisons': 'footer.categories.comparisons',
};
export default function Footer() { export default function Footer() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<footer className="border-t border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900"> <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-10 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Tool link grid */} <div className="marketing-panel overflow-hidden px-6 py-8 sm:px-8 sm:py-10">
<div className="mb-8 grid gap-8 sm:grid-cols-2 lg:grid-cols-3"> <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]) => ( {Object.entries(FOOTER_TOOLS).map(([category, tools]) => (
<div key={category}> <div key={category}>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-slate-900 dark:text-white"> <h3 className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-slate-500 dark:text-slate-400">
{category} {t(CATEGORY_KEYS[category] ?? category)}
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2.5">
{tools.map((tool) => ( {tools.map((tool) => (
<li key={tool.slug}> <li key={tool.slug}>
<Link <Link
to={(tool as { slug: string; isLanding?: boolean; isComparison?: boolean }).isComparison ? `/compare/${tool.slug}` : (tool as { slug: string; isLanding?: boolean }).isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`} to={tool.isComparison ? `/compare/${tool.slug}` : tool.isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`}
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400" className="text-sm text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
> >
{tool.label} {t(tool.i18nKey)}
</Link> </Link>
</li> </li>
))} ))}
@@ -73,75 +125,23 @@ export default function Footer() {
</div> </div>
))} ))}
</div> </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="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-6"> <div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link <FileText className="h-4 w-4" />
to="/privacy" <span>© {new Date().getFullYear()} {t('common.appName')}</span>
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> </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> </div>
</div> </div>

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from 'react'; 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 { 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 { useAuthStore } from '@/stores/authStore';
import { ensureLanguageResources } from '@/i18n'; import { ensureLanguageResources } from '@/i18n';
interface LangOption { interface LangOption {
code: string; code: string;
label: string; label: string;
@@ -16,6 +17,14 @@ const languages: LangOption[] = [
{ code: 'fr', label: 'Français', flag: '🇫🇷' }, { 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() { function useDarkMode() {
const [isDark, setIsDark] = useState(() => { const [isDark, setIsDark] = useState(() => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
@@ -41,6 +50,7 @@ function useDarkMode() {
export default function Header() { export default function Header() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { isDark, toggle: toggleDark } = useDarkMode(); const { isDark, toggle: toggleDark } = useDarkMode();
const location = useLocation();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const credits = useAuthStore((state) => state.credits); const credits = useAuthStore((state) => state.credits);
const [langOpen, setLangOpen] = useState(false); const [langOpen, setLangOpen] = useState(false);
@@ -60,96 +70,99 @@ export default function Header() {
return () => document.removeEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick);
}, []); }, []);
useEffect(() => {
setMobileOpen(false);
setLangOpen(false);
}, [location.pathname]);
const switchLang = async (code: string) => { const switchLang = async (code: string) => {
const resolved = await ensureLanguageResources(code); const resolved = await ensureLanguageResources(code);
void i18n.changeLanguage(resolved); void i18n.changeLanguage(resolved);
setLangOpen(false); 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 ( 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"> <header className="sticky-header-safe 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-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8"> <div className="mx-auto flex h-20 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
{/* Logo */} <div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-2.5 group"> <Link to="/" className="group flex items-center gap-3">
<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"> <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">
<FileText className="h-5 w-5 text-white" /> <Layers3 className="h-5 w-5 text-white" />
</div> </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')} {t('common.appName')}
</span> </span>
<span className="hidden text-xs font-medium text-slate-500 dark:text-slate-400 sm:block">
{t('common.siteTagline')}
</span>
</div>
</Link> </Link>
{/* Desktop Navigation */} <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 className="hidden items-center gap-1 md:flex"> {NAV_LINKS.map((link) => (
<Link <NavLink key={link.to} to={link.to} className={desktopNavClassName}>
to="/" {t(link.key, link.fallback)}
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" </NavLink>
> ))}
{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> </nav>
</div>
{/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Account / credits pill */}
<Link <Link
to="/account" 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> <span className="truncate">{user?.email || t('common.account')}</span>
{user && credits && ( {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"> <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" /> <Coins className="h-3 w-3" />
{credits.credits_remaining} {credits.credits_remaining}
</span> </span>
)} ) : null}
</Link> </Link>
{/* CTA — Start Free */} {!user ? (
{!user && (
<Link <Link
to="/account" 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" /> <ArrowRight className="h-3.5 w-3.5" />
</Link> </Link>
)} ) : null}
{/* Dark Mode Toggle */}
<button <button
onClick={toggleDark} 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')} aria-label={isDark ? t('common.lightMode') : t('common.darkMode')}
title={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" />} {isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button> </button>
{/* Language Dropdown */}
<div className="relative" ref={langRef}> <div className="relative" ref={langRef}>
<button <button
onClick={() => setLangOpen((v) => !v)} onClick={() => setLangOpen((value) => !value)}
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" 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-label={t('common.language')}
aria-expanded={langOpen} aria-expanded={langOpen}
aria-haspopup="listbox" aria-haspopup="listbox"
@@ -159,36 +172,34 @@ export default function Header() {
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${langOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-4 w-4 transition-transform duration-200 ${langOpen ? 'rotate-180' : ''}`} />
</button> </button>
{/* Dropdown Menu */} {langOpen ? (
{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">
<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">
{languages.map((lang) => ( {languages.map((lang) => (
<button <button
key={lang.code} key={lang.code}
onClick={() => void switchLang(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 lang.code === i18n.language
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400' ? '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-700' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800'
}`} }`}
role="option" role="option"
aria-selected={lang.code === i18n.language} aria-selected={lang.code === i18n.language}
> >
<span className="text-lg leading-none">{lang.flag}</span> <span className="text-lg leading-none">{lang.flag}</span>
<span>{lang.label}</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> <span className="ms-auto text-primary-600 dark:text-primary-400"></span>
)} ) : null}
</button> </button>
))} ))}
</div> </div>
)} ) : null}
</div> </div>
{/* Mobile Menu Toggle */}
<button <button
onClick={() => setMobileOpen((v) => !v)} onClick={() => setMobileOpen((value) => !value)}
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" 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" aria-label="Menu"
> >
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} {mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
@@ -196,62 +207,40 @@ export default function Header() {
</div> </div>
</div> </div>
{/* Mobile Navigation */} {mobileOpen ? (
{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">
<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"> <div className="mx-auto flex max-w-7xl flex-col gap-2">
<Link {NAV_LINKS.map((link) => (
to="/" <NavLink key={link.to} to={link.to} className={mobileNavClassName}>
onClick={() => setMobileOpen(false)} {t(link.key, link.fallback)}
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" </NavLink>
> ))}
{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>
<Link <Link
to="/account" to="/account"
onClick={() => setMobileOpen(false)} 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"
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"
> >
<span>{user?.email || t('common.account')}</span> <span>{user?.email || t('common.account')}</span>
{user && credits && ( {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"> <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" /> <Coins className="h-3 w-3" />
{credits.credits_remaining} {credits.credits_remaining}
</span> </span>
)} ) : null}
</Link> </Link>
<Link
to="/developers" {!user ? (
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 && (
<Link <Link
to="/account" to="/account"
onClick={() => setMobileOpen(false)} 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"
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"
> >
{t('home.startFree', 'Start Free')} {t('home.startFree', 'Start Free')}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
)} ) : null}
</div>
</nav> </nav>
)} ) : null}
</header> </header>
); );
} }

View 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>
);
}

View File

@@ -0,0 +1,47 @@
import { RefreshCw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePwaRegistration } from '../../hooks/usePwaRegistration';
/**
* Renders a bottom-right toast when a new service-worker version is available.
* The user can choose to reload immediately or dismiss.
*/
export default function PwaUpdatePrompt() {
const { needRefresh, acceptUpdate, dismissUpdate } = usePwaRegistration();
const { t } = useTranslation();
if (!needRefresh) return null;
return (
<div
role="alert"
className="fixed bottom-4 right-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:bottom-6 sm:right-6"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
<RefreshCw className="h-5 w-5" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{t('pwa.updateAvailable', 'Update available')}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{t('pwa.updateDescription', 'A new version is ready. Reload to get the latest features.')}
</p>
<button
onClick={acceptUpdate}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700 active:scale-[0.98]"
>
<RefreshCw className="h-3.5 w-3.5" />
{t('pwa.reload', 'Reload')}
</button>
</div>
<button
onClick={dismissUpdate}
className="rounded-lg p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -195,7 +195,7 @@ export default function SiteAssistant() {
}; };
return ( return (
<div className="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto"> <div className="pointer-events-none fixed inset-x-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto">
<div className="pointer-events-auto w-full max-w-sm"> <div className="pointer-events-auto w-full max-w-sm">
{open && ( {open && (
<div className="mb-3 overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_20px_80px_rgba(15,23,42,0.16)] backdrop-blur dark:border-slate-700/80 dark:bg-slate-950/95"> <div className="mb-3 overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_20px_80px_rgba(15,23,42,0.16)] backdrop-blur dark:border-slate-700/80 dark:bg-slate-950/95">
@@ -225,7 +225,7 @@ export default function SiteAssistant() {
</p> </p>
</div> </div>
<div ref={scrollRef} className="max-h-[26rem] space-y-3 overflow-y-auto px-4 py-4"> <div ref={scrollRef} className="max-h-[50dvh] space-y-3 overflow-y-auto overscroll-contain px-4 py-4 sm:max-h-[26rem]">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="rounded-3xl border border-sky-100 bg-sky-50/80 p-4 text-sm text-slate-700 dark:border-sky-900/50 dark:bg-slate-900 dark:text-slate-200"> <div className="rounded-3xl border border-sky-100 bg-sky-50/80 p-4 text-sm text-slate-700 dark:border-sky-900/50 dark:bg-slate-900 dark:text-slate-200">
<p className="font-medium text-slate-900 dark:text-slate-100"> <p className="font-medium text-slate-900 dark:text-slate-100">

View File

@@ -39,15 +39,26 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
const toolTitle = t(`tools.${seo.i18nKey}.title`); const toolTitle = t(`tools.${seo.i18nKey}.title`);
const toolDesc = t(`tools.${seo.i18nKey}.description`); 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 origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const path = `/tools/${slug}`; const path = `/tools/${slug}`;
const canonicalUrl = `${origin}${path}`; const canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin); const socialImageUrl = buildSocialImageUrl(origin);
const currentOgLocale = getOgLocale(i18n.language); const currentOgLocale = getOgLocale(i18n.language);
const metaTitle = `${toolTitle}${localizedTitleSuffix}`;
const toolSchema = generateToolSchema({ const toolSchema = generateToolSchema({
name: toolTitle, name: toolTitle,
description: seo.metaDescription, description: localizedMetaDescription,
url: canonicalUrl, url: canonicalUrl,
category: seo.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication', category: seo.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication',
ratingValue: ratingData.average, ratingValue: ratingData.average,
@@ -60,12 +71,12 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{ name: toolTitle, url: canonicalUrl }, { 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 howToSteps = t(`seo.${seo.i18nKey}.howToUse`, { returnObjects: true }) as string[];
const howToSchema = Array.isArray(howToSteps) && howToSteps.length > 0 const howToSchema = Array.isArray(howToSteps) && howToSteps.length > 0
? generateHowTo({ ? generateHowTo({
name: toolTitle, name: toolTitle,
description: seo.metaDescription, description: localizedMetaDescription,
steps: howToSteps, steps: howToSteps,
url: canonicalUrl, url: canonicalUrl,
}) })
@@ -74,14 +85,14 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
return ( return (
<> <>
<Helmet> <Helmet>
<title>{toolTitle} {seo.titleSuffix} | {t('common.appName')}</title> <title>{metaTitle} | {t('common.appName')}</title>
<meta name="description" content={seo.metaDescription} /> <meta name="description" content={localizedMetaDescription} />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" /> <meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
<link rel="canonical" href={canonicalUrl} /> <link rel="canonical" href={canonicalUrl} />
{/* Open Graph */} {/* Open Graph */}
<meta property="og:title" content={`${toolTitle}${seo.titleSuffix}`} /> <meta property="og:title" content={metaTitle} />
<meta property="og:description" content={seo.metaDescription} /> <meta property="og:description" content={localizedMetaDescription} />
<meta property="og:url" content={canonicalUrl} /> <meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content={socialImageUrl} /> <meta property="og:image" content={socialImageUrl} />
@@ -90,8 +101,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{/* Twitter */} {/* Twitter */}
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${toolTitle}${seo.titleSuffix}`} /> <meta name="twitter:title" content={metaTitle} />
<meta name="twitter:description" content={seo.metaDescription} /> <meta name="twitter:description" content={localizedMetaDescription} />
<meta name="twitter:image" content={socialImageUrl} /> <meta name="twitter:image" content={socialImageUrl} />
<meta name="twitter:image:alt" content={`${toolTitle} social preview`} /> <meta name="twitter:image:alt" content={`${toolTitle} social preview`} />
@@ -208,11 +219,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{/* FAQ Section */} {/* FAQ Section */}
{(() => { {(() => {
const faqData = t(`seo.${seo.i18nKey}.faq`, { returnObjects: true }) as SEOFAQ[]; return <FAQSection faqs={localizedFaqs} />;
const faqs = Array.isArray(faqData)
? faqData.map((f) => ({ question: f.q, answer: f.a }))
: [];
return <FAQSection faqs={faqs} />;
})()} })()}
{/* Related Tools */} {/* Related Tools */}

View File

@@ -95,8 +95,8 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Download className="h-5 w-5" /> <Download className="h-5 w-5 shrink-0" />
{t('common.download')} {result.filename} <span className="truncate">{t('common.download')} {result.filename}</span>
</a> </a>
) : ( ) : (
<button <button

View File

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

View File

@@ -1,14 +1,14 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, lazy, Suspense } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { UploadCloud, PenLine, ChevronRight, FileCheck } from 'lucide-react'; import { UploadCloud, PenLine, ChevronRight, FileCheck } from 'lucide-react';
import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
import { useFileStore } from '@/stores/fileStore'; import { useFileStore } from '@/stores/fileStore';
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
import type { ToolOption } from '@/utils/fileRouting'; import type { ToolOption } from '@/utils/fileRouting';
import { useConfig } from '@/hooks/useConfig'; import { useConfig } from '@/hooks/useConfig';
const ToolSelectorModal = lazy(() => import('@/components/shared/ToolSelectorModal'));
/** /**
* The MIME types we accept on the homepage smart upload zone. * The MIME types we accept on the homepage smart upload zone.
* Covers PDF, images, video, and Word documents. * Covers PDF, images, video, and Word documents.
@@ -45,12 +45,13 @@ export default function HeroUploadZone() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
setError(null); setError(null);
if (acceptedFiles.length === 0) return; if (acceptedFiles.length === 0) return;
const file = acceptedFiles[0]; const file = acceptedFiles[0];
const { getToolsForFile, detectFileCategory, getCategoryLabel } = await import('@/utils/fileRouting');
const tools = getToolsForFile(file); const tools = getToolsForFile(file);
if (tools.length === 0) { if (tools.length === 0) {
@@ -107,7 +108,7 @@ export default function HeroUploadZone() {
{...getRootProps()} {...getRootProps()}
className={`hero-upload-zone group ${isDragActive ? 'drag-active' : ''}`} 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 */} {/* Cloud icon with animated ring */}
<div className="relative mb-6"> <div className="relative mb-6">
@@ -210,6 +211,7 @@ export default function HeroUploadZone() {
</div> </div>
{/* Tool Selector Modal */} {/* Tool Selector Modal */}
<Suspense fallback={null}>
<ToolSelectorModal <ToolSelectorModal
isOpen={modalOpen} isOpen={modalOpen}
onClose={handleCloseModal} onClose={handleCloseModal}
@@ -217,6 +219,7 @@ export default function HeroUploadZone() {
tools={matchedTools} tools={matchedTools}
fileTypeLabel={fileTypeLabel} fileTypeLabel={fileTypeLabel}
/> />
</Suspense>
</> </>
); );
} }

View 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} />;
}

View File

@@ -37,7 +37,7 @@ export default function ProgressBar({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Main Progress Card */} {/* Main Progress Card */}
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"> <div className="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200 sm:p-5 dark:bg-slate-800 dark:ring-slate-700">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isActive && ( {isActive && (
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" /> <Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />

View 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>
);
}

View File

@@ -111,7 +111,7 @@ export default function SharePanel({
</button> </button>
{open && ( {open && (
<div className="mt-3 w-full max-w-md rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-slate-700 dark:bg-slate-900/95"> <div className="mt-3 w-full max-w-[calc(100vw-2rem)] rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur sm:max-w-md dark:border-slate-700 dark:bg-slate-900/95">
<div className="rounded-2xl bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-4 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800"> <div className="rounded-2xl bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-4 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600 dark:text-sky-300"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600 dark:text-sky-300">
{variant === 'result' ? t('share.resultLabel') : t('share.toolLabel')} {variant === 'result' ? t('share.resultLabel') : t('share.toolLabel')}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
@@ -12,8 +12,27 @@ interface SocialProofStripProps {
export default function SocialProofStrip({ className = '' }: SocialProofStripProps) { export default function SocialProofStrip({ className = '' }: SocialProofStripProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [stats, setStats] = useState<PublicStatsSummary | null>(null); const [stats, setStats] = useState<PublicStatsSummary | null>(null);
const sectionRef = useRef<HTMLElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => { 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; let cancelled = false;
getPublicStats() getPublicStats()
@@ -31,11 +50,12 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [isVisible]);
if (!stats) { if (!stats) {
return ( return (
<section <section
ref={sectionRef}
aria-hidden="true" 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()} 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)); ].filter((card): card is { label: string; value: string } => Boolean(card));
return ( 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="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-2xl"> <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-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">

View File

@@ -86,7 +86,7 @@ export default function ToolSelectorModal({
aria-modal="true" aria-modal="true"
aria-labelledby="tool-selector-title" aria-labelledby="tool-selector-title"
> >
<div className="modal-content flex w-full max-w-lg max-h-[90vh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"> <div className="modal-content flex w-full max-w-lg max-h-[90dvh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */} {/* Header */}
<div className="mb-5 flex items-start justify-between"> <div className="mb-5 flex items-start justify-between">
<div> <div>

View File

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

View File

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

View File

@@ -95,7 +95,7 @@ export default function ChatPdf() {
<textarea <textarea
value={question} onChange={(e) => setQuestion(e.target.value)} value={question} onChange={(e) => setQuestion(e.target.value)}
placeholder={t('tools.chatPdf.questionPlaceholder')} placeholder={t('tools.chatPdf.questionPlaceholder')}
rows={3} rows={2}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200" className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/> />
</div> </div>

View File

@@ -118,7 +118,7 @@ export default function ImageConverter() {
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">
Convert to: Convert to:
</label> </label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
{formats.map((f) => ( {formats.map((f) => (
<button <button
key={f.value} key={f.value}

View File

@@ -134,7 +134,7 @@ export default function ImageResize() {
{t('tools.imageResize.lockAspect')} {t('tools.imageResize.lockAspect')}
</label> </label>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div> <div>
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400"> <label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
{t('tools.imageResize.width')} {t('tools.imageResize.width')}
@@ -143,7 +143,7 @@ export default function ImageResize() {
type="number" type="number"
min="1" min="1"
max="10000" max="10000"
placeholder="e.g. 800" placeholder={t('tools.imageResize.widthPlaceholder')}
value={width} value={width}
onChange={(e) => { onChange={(e) => {
setWidth(e.target.value); setWidth(e.target.value);
@@ -160,7 +160,7 @@ export default function ImageResize() {
type="number" type="number"
min="1" min="1"
max="10000" max="10000"
placeholder="e.g. 600" placeholder={t('tools.imageResize.heightPlaceholder')}
value={height} value={height}
onChange={(e) => { onChange={(e) => {
setHeight(e.target.value); setHeight(e.target.value);

View File

@@ -35,7 +35,7 @@ export default function PdfCompressor() {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })} {t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
</label> </label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
{qualityOptions.map((opt) => ( {qualityOptions.map((opt) => (
<button <button
key={opt.value} key={opt.value}

View File

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

View File

@@ -38,7 +38,7 @@ export default function SignPdf() {
fd.append('file', pdfFile); fd.append('file', pdfFile);
fd.append('signature', sigFile); fd.append('signature', sigFile);
fd.append('page', String(page)); fd.append('page', String(page));
const res = await api.post<TaskResponse>('/pdf-tools/sign', fd); const res = await api.post<TaskResponse>('/convert/sign', fd);
setTaskId(res.data.task_id); setTaskId(res.data.task_id);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : t('common.errors.processingFailed'); const msg = err instanceof Error ? err.message : t('common.errors.processingFailed');

View File

@@ -79,11 +79,11 @@ export const TOOLS_SEO: ToolSEO[] = [
{ {
i18nKey: 'compressPdf', i18nKey: 'compressPdf',
slug: 'compress-pdf', slug: 'compress-pdf',
titleSuffix: 'Free Online PDF Compressor Reduce File Size', titleSuffix: 'Free Online PDF Compressor to Reduce PDF File Size',
metaDescription: 'Compress PDF files online for free. Reduce PDF size by up to 90% while maintaining quality. Fast and secure PDF compression.', 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', category: 'PDF',
relatedSlugs: ['merge-pdf', 'split-pdf', 'pdf-to-word', 'compress-image'], 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: [ features: [
'Reduce PDF file size by up to 90%', 'Reduce PDF file size by up to 90%',
'Choose your compression level (low, medium, high)', 'Choose your compression level (low, medium, high)',
@@ -92,10 +92,10 @@ export const TOOLS_SEO: ToolSEO[] = [
'Process files securely on our servers', 'Process files securely on our servers',
], ],
faqs: [ 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: '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: '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 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: '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: '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: 'Is there a file size limit?', answer: 'You can compress PDFs up to 20MB in size.' }, { 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', i18nKey: 'splitPdf',
slug: 'split-pdf', slug: 'split-pdf',
titleSuffix: 'Free Online PDF Splitter — Extract Pages', titleSuffix: 'Free Online PDF Splitter to Split or Separate PDF Pages',
metaDescription: 'Split PDF files into individual pages or extract specific page ranges online for free. Fast, secure, and no signup needed.', 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', category: 'PDF',
relatedSlugs: ['merge-pdf', 'extract-pages', 'rotate-pdf', 'reorder-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: [ features: [
'Split a PDF into individual pages', 'Split a PDF into individual pages',
'Extract specific page ranges', 'Extract specific page ranges',
@@ -136,9 +136,10 @@ export const TOOLS_SEO: ToolSEO[] = [
'Secure — files deleted after processing', 'Secure — files deleted after processing',
], ],
faqs: [ 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: '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 extract specific pages?', answer: 'Yes, you can specify individual pages (e.g., 1, 3, 5) or ranges (e.g., 1-5) to extract.' }, { 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: 'Is splitting a PDF free?', answer: 'Yes, our PDF splitter is completely free with no limitations.' }, { 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', i18nKey: 'extractPages',
slug: 'extract-pages', slug: 'extract-pages',
titleSuffix: 'Free Online PDF Page Extractor', titleSuffix: 'Free Online PDF Page Extractor to Extract Pages from PDF',
metaDescription: 'Extract specific pages from a PDF into a new document online for free. Select the exact pages you need.', 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', category: 'PDF',
relatedSlugs: ['split-pdf', 'merge-pdf', 'reorder-pdf', 'compress-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: [ features: [
'Extract specific pages from any PDF', 'Extract specific pages from any PDF',
'Select individual pages or ranges', 'Select individual pages or ranges',
@@ -327,8 +328,10 @@ export const TOOLS_SEO: ToolSEO[] = [
'Free and completely secure', 'Free and completely secure',
], ],
faqs: [ 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: '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: '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: '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.' },
], ],
}, },
{ {

View File

@@ -58,15 +58,15 @@ describe('Tool Manifest ↔ SEO Data sync', () => {
}); });
}); });
describe('Tool Manifest ↔ HomePage ICON_MAP sync', () => { describe('Tool Manifest ↔ ManifestToolIcon ICON_MAP sync', () => {
const homePageSource = readFileSync( const iconSource = readFileSync(
resolve(__dirname, '../pages/HomePage.tsx'), resolve(__dirname, '../components/shared/ManifestToolIcon.tsx'),
'utf-8' 'utf-8'
); );
// Extract icon names from the ICON_MAP object literal // Extract icon names from the ICON_MAP object literal
// Match from "= {" to "};" to skip the type annotation that also contains braces // 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( const iconMapKeys = new Set(
iconMapMatch iconMapMatch
? iconMapMatch[1] ? iconMapMatch[1]

View File

@@ -590,6 +590,11 @@ export function getHomepageTools(section: 'pdf' | 'other'): readonly ToolEntry[]
return TOOL_MANIFEST.filter((t) => t.homepage && t.homepageSection === section); 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 */ /** Lookup a single tool by slug */
export function getToolEntry(slug: string): ToolEntry | undefined { export function getToolEntry(slug: string): ToolEntry | undefined {
return TOOL_MANIFEST.find((t) => t.slug === slug); return TOOL_MANIFEST.find((t) => t.slug === slug);

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
/**
* Lightweight hook that registers the Workbox service-worker generated by
* vite-plugin-pwa and exposes an "update available" flag so the UI can
* prompt the user to refresh.
*/
export function usePwaRegistration() {
const [needRefresh, setNeedRefresh] = useState(false);
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const register = async () => {
try {
const { registerSW } = await import('virtual:pwa-register');
const updateSW = registerSW({
immediate: false,
onRegisteredSW(_swUrl: string, reg: ServiceWorkerRegistration | undefined) {
if (reg) setRegistration(reg);
},
onNeedRefresh() {
setNeedRefresh(true);
},
});
// Store updateSW so we can call it from acceptUpdate
(window as unknown as Record<string, unknown>).__pwaUpdateSW = updateSW;
} catch {
// SW registration failed (e.g. non-HTTPS in dev) — silently ignore
}
};
register();
}, []);
const acceptUpdate = () => {
const updateSW = (window as unknown as Record<string, unknown>).__pwaUpdateSW as
| ((reloadPage?: boolean) => Promise<void>)
| undefined;
if (updateSW) {
updateSW(true);
}
setNeedRefresh(false);
};
const dismissUpdate = () => setNeedRefresh(false);
return { needRefresh, acceptUpdate, dismissUpdate, registration };
}

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,37 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateWebPage, getSiteOrigin } from '@/utils/seo'; 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'; 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() { export default function AboutPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[];
return ( return (
<> <>
@@ -23,87 +47,106 @@ export default function AboutPage() {
})} })}
/> />
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-6xl">
<h1 className="mb-8 text-3xl font-bold text-slate-900 dark:text-white"> {/* Hero Banner */}
{t('pages.about.title')} <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> </h1>
<p className="relative mt-4 max-w-2xl text-lg leading-relaxed text-white/90">
{/* 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">
{t('pages.about.missionText')} {t('pages.about.missionText')}
</p> </p>
</section> </section>
{/* Technology */} {/* Our Team */}
<section className="mb-10"> <section className="mb-16">
<div className="flex items-center gap-3 mb-3"> <h2 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white">
<Cpu className="h-6 w-6 text-primary-600 dark:text-primary-400" /> {t('pages.about.teamTitle', 'Our Team')}
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
{t('pages.about.technologyTitle')}
</h2> </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> </div>
<p className="text-slate-600 dark:text-slate-400 leading-relaxed"> <p className="mt-3 text-sm font-semibold text-slate-700 dark:text-slate-300">
{t('pages.about.technologyText')} {t(member.nameKey, member.role)}
</p> </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> </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> </section>
{/* CTA */} {/* CTA */}
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center dark:border-slate-700 dark:bg-slate-800"> <div className="rounded-2xl 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"> <p className="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"> {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')} {t('common.contact')}
</Link> </Link>
</p>
</div> </div>
</div> </div>
</> </>

View File

@@ -607,7 +607,7 @@ export default function AccountPage() {
</section> </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="border-b border-slate-200 px-6 py-5 dark:border-slate-700">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FolderClock className="h-5 w-5 text-primary-600 dark:text-primary-400" /> <FolderClock className="h-5 w-5 text-primary-600 dark:text-primary-400" />

View File

@@ -1,22 +1,50 @@
import { useDeferredValue, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowRight, Search } from 'lucide-react';
import AdSlot from '@/components/layout/AdSlot';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import BreadcrumbNav from '@/components/seo/BreadcrumbNav'; import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
import ManifestToolIcon from '@/components/shared/ManifestToolIcon';
import { TOOLS_SEO } from '@/config/seoData'; import { TOOLS_SEO } from '@/config/seoData';
import { TOOL_MANIFEST } from '@/config/toolManifest';
import { generateBreadcrumbs, generateCollectionPage, generateItemList, getSiteOrigin } from '@/utils/seo'; 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() { export default function AllToolsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const path = '/tools'; const path = '/tools';
const url = `${origin}${path}`; 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) => ({ const filteredTools = useMemo(() => {
category, let tools = TOOLS_SEO;
items: TOOLS_SEO.filter((tool) => tool.category === category), if (activeTab !== 'All') {
})).filter((group) => group.items.length > 0); 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 = [ const jsonLd = [
generateCollectionPage({ generateCollectionPage({
@@ -45,54 +73,94 @@ export default function AllToolsPage() {
jsonLd={jsonLd} jsonLd={jsonLd}
/> />
<div className="mx-auto max-w-6xl space-y-10"> <div className="mx-auto max-w-6xl space-y-8">
<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"> {/* Header */}
<section>
<BreadcrumbNav <BreadcrumbNav
className="mb-6" className="mb-4"
items={[ items={[
{ label: t('common.home'), to: '/' }, { label: t('common.home'), to: '/' },
{ label: t('common.allTools') }, { label: t('common.allTools') },
]} ]}
/> />
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl"> <h1 className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white sm:text-5xl">
{t('pages.toolsHub.title')} {t('pages.toolsHub.title', 'All PDF Tools')}
</h1> </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> </section>
{groupedTools.map((group) => ( {/* Search + category tabs */}
<section <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
key={group.category} <div className="relative flex-1">
className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70" <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(tab.labelKey, tab.labelDefault)}
{t(`pages.toolsHub.categories.${group.category}`)} </button>
</h2> ))}
</div>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3"> {/* Tools grid */}
{group.items.map((tool) => ( <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 <Link
key={tool.slug} key={tool.slug}
to={`/tools/${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"> <div className="mb-4 flex items-center gap-3">
{group.category} {manifest ? (
</p> <div className={`flex h-10 w-10 items-center justify-center rounded-xl ${manifest.bgColor}`}>
<h3 className="mt-2 text-lg font-semibold text-slate-900 dark:text-white"> <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`)} {t(`tools.${tool.i18nKey}.title`)}
</h3> </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`)} {t(`tools.${tool.i18nKey}.shortDesc`)}
</p> </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> </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> </div>
</> </>
); );

View File

@@ -1,6 +1,7 @@
import { Calendar, ChevronLeft, Clock } from 'lucide-react'; import { Calendar, ChevronLeft, Clock } from 'lucide-react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import AdSlot from '@/components/layout/AdSlot';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { getToolSEO } from '@/config/seoData'; import { getToolSEO } from '@/config/seoData';
import { import {
@@ -184,6 +185,8 @@ export default function BlogPostPage() {
</section> </section>
</aside> </aside>
</div> </div>
<AdSlot slot="bottom-banner" format="horizontal" className="mt-8" />
</article> </article>
</> </>
); );

View File

@@ -2,6 +2,7 @@ import { useParams, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { CheckCircle, XCircle, MinusCircle, ArrowRight, Swords, Trophy, ExternalLink } from 'lucide-react'; 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 { getComparisonPage, getComparisonPagesByTool, type ComparisonFeature } from '@/config/comparisonData';
import { getToolSEO } from '@/config/seoData'; import { getToolSEO } from '@/config/seoData';
import { getSiteOrigin, buildSocialImageUrl, getOgLocale, generateWebPage, generateFAQ } from '@/utils/seo'; import { getSiteOrigin, buildSocialImageUrl, getOgLocale, generateWebPage, generateFAQ } from '@/utils/seo';
@@ -272,6 +273,8 @@ export default function ComparisonPage() {
</div> </div>
</section> </section>
)} )}
<AdSlot slot="bottom-banner" format="horizontal" className="mb-12" />
</div> </div>
</> </>
); );

View File

@@ -1,7 +1,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async'; 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 { isAxiosError } from 'axios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
@@ -9,11 +23,27 @@ import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { getApiClient } from '@/services/api'; import { getApiClient } from '@/services/api';
const CONTACT_EMAIL = 'support@dociva.io'; 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_BASE = import.meta.env.VITE_API_URL || '';
const api = getApiClient(); const api = getApiClient();
type Category = 'general' | 'bug' | 'feature'; 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() { export default function ContactPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
@@ -21,6 +51,7 @@ export default function ContactPage() {
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [openFaq, setOpenFaq] = useState<number | null>(null);
const placeholderKey = `pages.contact.${category}Placeholder` as const; 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="mx-auto max-w-6xl">
<div className="mb-8 text-center"> {/* Page header */}
<h1 className="text-3xl font-bold text-slate-800 dark:text-slate-100"> <div className="mb-10">
{t('pages.contact.title')} <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> </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')} {t('pages.contact.subtitle')}
</p> </p>
</div> </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"> {/* Two-column layout */}
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200"> <div className="grid gap-10 lg:grid-cols-2">
{t('pages.contact.formTitle')} {/* Left column — Contact form */}
</h2>
{/* Category */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"> <form onSubmit={handleSubmit} className="space-y-5">
{t('pages.contact.categoryLabel')} {/* Category */}
</label>
<select <select
value={category} value={category}
onChange={(e) => setCategory(e.target.value as 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="general">{t('pages.contact.categories.general')}</option>
<option value="bug">{t('pages.contact.categories.bug')}</option> <option value="bug">{t('pages.contact.categories.bug')}</option>
<option value="feature">{t('pages.contact.categories.feature')}</option> <option value="feature">{t('pages.contact.categories.feature')}</option>
</select> </select>
</div>
{/* Name */} {/* 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 <input
id="name"
name="name" name="name"
type="text" type="text"
required required
placeholder={t('pages.contact.namePlaceholder')} placeholder={t('pages.contact.namePlaceholder', 'Name')}
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"
/> />
</div>
{/* Email */} {/* 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 <input
id="email"
name="email" name="email"
type="email" type="email"
required required
placeholder={t('pages.contact.emailPlaceholder')} placeholder={t('pages.contact.emailPlaceholder', 'Email')}
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"
/> />
</div>
{/* Subject */} {/* 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 <input
id="subject"
name="subject" name="subject"
type="text" type="text"
required required
placeholder={t('pages.contact.subjectPlaceholder')} placeholder={t('pages.contact.subjectPlaceholder', 'Subject')}
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"
/> />
</div>
{/* Message */} {/* 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 <textarea
id="message"
name="message" name="message"
rows={6} rows={5}
required required
placeholder={t(placeholderKey)} placeholder={t(placeholderKey, 'Message')}
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"
/> />
</div>
{/* Error */} {/* Error */}
{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" /> <AlertCircle className="h-4 w-4 shrink-0" />
{error} {error}
</div> </div>
@@ -196,28 +199,113 @@ export default function ContactPage() {
<button <button
type="submit" type="submit"
disabled={loading} 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 ? <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> </button>
</form> </form>
</div>
{/* Direct email fallback */} {/* Right column — Contact info cards */}
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400"> <div className="space-y-5">
<p> {/* Email card */}
{t('pages.contact.directEmail')}{' '} <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 <a
href={`mailto:${CONTACT_EMAIL}`} 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} {CONTACT_EMAIL}
</a> </a>
</p>
<p className="mt-1">{t('pages.contact.responseTime')}</p>
</div> </div>
</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>
</> </>
); );
} }

View File

@@ -1,93 +1,92 @@
import { useDeferredValue } from 'react'; import { useDeferredValue } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom'; import { Link, 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 { 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, ArrowRight,
Star,
CheckCircle2, CheckCircle2,
Download,
Globe,
Layers,
Lock,
MousePointerClick,
Search,
ShieldCheck,
Sparkles,
Star,
UploadCloud,
X,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import ToolCard from '@/components/shared/ToolCard'; import MarketingPageLayout from '@/components/layout/MarketingPageLayout';
import HeroUploadZone from '@/components/shared/HeroUploadZone';
import AdSlot from '@/components/layout/AdSlot'; 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 SocialProofStrip from '@/components/shared/SocialProofStrip';
import { getHomepageTools, type ToolEntry } from '@/config/toolManifest'; import ToolCard from '@/components/shared/ToolCard';
import SEOHead from '@/components/seo/SEOHead';
// Map icon names from manifest to lucide components import { TOOL_MANIFEST, getHomepageTools, type ToolEntry } from '@/config/toolManifest';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = { import { generateOrganization, generateWebSite, getSiteOrigin } from '@/utils/seo';
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}`} />;
}
interface ToolInfo { interface ToolInfo {
key: string; key: string;
path: string; path: string;
icon: React.ReactNode; icon: React.ReactNode;
bgColor: string; bgColor: string;
iconName: string;
iconColor: string;
} }
function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] { function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] {
return tools.map((t) => ({ return tools.map((t) => ({
key: t.i18nKey, key: t.i18nKey,
path: `/tools/${t.slug}`, path: `/tools/${t.slug}`,
icon: renderToolIcon(t), icon: <ManifestToolIcon iconName={t.iconName} className={`h-6 w-6 ${t.iconColor}`} />,
bgColor: t.bgColor, bgColor: t.bgColor,
iconName: t.iconName,
iconColor: t.iconColor,
})); }));
} }
const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf')); const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf'));
const otherTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('other')); 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 = [ const HOW_IT_WORKS = [
{ {
step: '01', step: '01',
@@ -127,6 +126,25 @@ export default function HomePage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || ''; const query = searchParams.get('q') || '';
const deferredQuery = useDeferredValue(query.trim().toLowerCase()); 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) => { const matchesTool = (tool: ToolInfo) => {
if (!deferredQuery) { if (!deferredQuery) {
@@ -151,7 +169,95 @@ export default function HomePage() {
}; };
return ( 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 <SEOHead
title={t('common.appName')} title={t('common.appName')}
description={t('home.heroSub')} description={t('home.heroSub')}
@@ -165,77 +271,31 @@ export default function HomePage() {
]} ]}
/> />
{/* ── Hero Section ──────────────────────────────────────────── */} <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<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 ───────────────────────────────────────────────── */}
<AdSlot slot="home-top" format="horizontal" className="mb-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> </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"> <div className="relative grid gap-6 sm:grid-cols-3">
{HOW_IT_WORKS.map(({ step, icon: Icon, titleKey, titleDefault, descKey, descDefault, color, glow }, idx) => ( {HOW_IT_WORKS.map(({ step, icon: Icon, titleKey, titleDefault, descKey, descDefault, color, glow }, idx) => (
<div key={step} className="relative"> <div key={step} className="relative">
{/* Connector line (between steps, hidden on mobile) */}
{idx < HOW_IT_WORKS.length - 1 && ( {idx < HOW_IT_WORKS.length - 1 && (
<div className="step-connector" /> <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"> <div className="marketing-card flex flex-col items-center text-center p-7">
{/* Numbered icon */}
<div className={`relative mb-5 flex h-16 w-16 items-center justify-center rounded-2xl ${color} shadow-lg ${glow} text-white`}> <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" /> <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"> <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> </div>
</section> </section>
{/* ── Search & Tools ────────────────────────────────────────── */} <section className="mx-auto max-w-7xl px-4 pb-14 sm:px-6 lg:px-8">
<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="marketing-panel p-6 sm:p-8 lg:p-10">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="grid gap-8 xl:grid-cols-[280px_1fr]">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white"> <SectionIntro
{t('common.search')} eyebrow={t('common.search')}
</h2> title={t('home.toolsDirectoryTitle', 'Find the right tool faster')}
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400"> description={t(
{t('home.searchToolsPlaceholder')} 'home.toolsDirectorySubtitle',
</p> 'Search by task, format, or output and jump directly into the workflow you need.'
</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" /> <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 <input
value={query} value={query}
onChange={(event) => updateQuery(event.target.value)} onChange={(event) => updateQuery(event.target.value)}
placeholder={t('home.searchToolsPlaceholder')} 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> </label>
{query && (
{query ? (
<button <button
type="button" type="button"
onClick={() => updateQuery('')} 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" /> <X className="h-4 w-4" />
{t('common.clear')} {t('common.clear')}
</button> </button>
)} ) : null}
</div>
</div>
</section>
{/* ── PDF Tools Grid ────────────────────────────────────────── */} <div className="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<section className="mb-12"> {stats.map((stat) => (
<div className="mb-6 flex items-center justify-between"> <div key={stat.label} className="metric-card">
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200"> <p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">
{t('home.pdfTools')} {stat.label}
</h2> </p>
<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"> <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')} {t('common.allTools')}
<ArrowRight className="h-3.5 w-3.5" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</div> </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) => ( {filteredPdfTools.map((tool) => (
<ToolCard <ToolCard
key={tool.key} key={tool.key}
@@ -313,10 +382,11 @@ export default function HomePage() {
))} ))}
</div> </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')} {t('home.otherTools', 'Other Tools')}
</h2> </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) => ( {filteredOtherTools.map((tool) => (
<ToolCard <ToolCard
key={tool.key} key={tool.key}
@@ -328,160 +398,130 @@ export default function HomePage() {
/> />
))} ))}
</div> </div>
</div>
{filteredPdfTools.length + filteredOtherTools.length === 0 && ( {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"> <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-medium text-slate-700 dark:text-slate-200"> <p className="text-base font-semibold text-slate-700 dark:text-slate-200">
{t('home.noSearchResults')} {t('home.noSearchResults')}
</p> </p>
</div> </div>
)} ) : null}
</div>
</div>
</div>
</section> </section>
{/* ── Features / Why Choose Us ──────────────────────────────── */} <section className="mx-auto max-w-7xl px-4 pb-14 sm:px-6 lg:px-8">
<section className="mb-14 overflow-hidden rounded-3xl bg-slate-50 px-6 py-16 dark:bg-slate-900 sm:px-12"> <SectionIntro
<div className="mb-12 text-center"> align="center"
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400"> eyebrow={t('home.whyChooseLabel', 'Why Dociva')}
{t('home.whyChooseLabel', 'Why Dociva')} title={t('home.featuresTitle', 'A clearer, faster way to work with files')}
</p> description={t(
<h2 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white"> 'home.featuresSubtitle',
{t('home.featuresTitle', 'A smarter way to work with files')} 'The redesign is built around workflow clarity: one workspace, strong defaults, and fewer decisions before value.'
</h2> )}
</div> className="mb-10"
/>
<div className="grid gap-8 sm:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{[ {FEATURE_PANELS.map((panel) => {
{ const Icon = panel.icon;
icon: Layers, const perks = panel.perks.map((perkKey, index) => t(perkKey, panel.fallbackPerks[index]));
bg: 'bg-blue-100 dark:bg-blue-900/30',
color: 'text-blue-600 dark:text-blue-400', return (
titleKey: 'home.feature1Title', <div key={panel.titleKey} className="marketing-card flex h-full flex-col p-7">
titleDefault: 'One complete workspace', <div className={`mb-5 flex h-14 w-14 items-center justify-center rounded-2xl ${panel.bgClassName}`}>
descKey: 'home.feature1Desc', <Icon className={`h-7 w-7 ${panel.iconClassName}`} />
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> </div>
<h3 className="mb-2 text-lg font-bold text-slate-900 dark:text-slate-100"> <h3 className="text-lg font-bold text-slate-950 dark:text-white">
{t(titleKey, titleDefault)} {t(panel.titleKey, panel.titleDefault)}
</h3> </h3>
<p className="mb-5 text-sm leading-relaxed text-slate-500 dark:text-slate-400"> <p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">
{t(descKey, descDefault)} {t(panel.descKey, panel.descDefault)}
</p> </p>
<ul className="mt-auto space-y-2"> <ul className="mt-6 space-y-2">
{perks.map((perk) => ( {perks.map((perk) => (
<li key={perk} className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300"> <li key={perk} className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-200">
<Star className="h-3.5 w-3.5 flex-shrink-0 text-amber-400" /> <Star className="h-4 w-4 shrink-0 text-amber-400" />
{perk} {perk}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
))} );
})}
</div> </div>
</section> </section>
{/* ── Developer API Banner ──────────────────────────────────── */} <section className="mx-auto max-w-7xl px-4 pb-10 sm:px-6 lg:px-8">
<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"> <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="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-2xl"> <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')} {t('common.developers')}
</p> </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')} {t('pages.developers.ctaTitle')}
</h2> </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')} {t('pages.developers.ctaSubtitle')}
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Link <Link
to="/developers" 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')} {t('pages.developers.openDocs')}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
<Link <Link
to="/account" 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')} {t('pages.developers.getApiKey')}
</Link> </Link>
</div> </div>
</div> </div>
</div>
</section> </section>
{/* ── Bottom CTA Banner ─────────────────────────────────────── */} <section className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<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"> <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">
{/* Decorative blobs */}
<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 -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="pointer-events-none absolute -bottom-16 -left-16 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
<div className="relative"> <div className="relative mx-auto max-w-3xl">
<p className="mb-2 text-sm font-bold uppercase tracking-widest text-primary-200"> <p className="text-xs font-bold uppercase tracking-[0.22em] text-primary-200">
{t('home.ctaBannerLabel', 'Get started today')} {t('home.ctaBannerLabel', 'Get started today')}
</p> </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?')} {t('home.ctaBannerTitle', 'Ready to convert your files?')}
</h2> </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.')} {t('home.ctaBannerSubtitle', 'Join thousands of users who convert, compress, and edit their files every day — completely free.')}
</p> </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 <Link
to="/tools" 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')} {t('home.ctaBrowseTools', 'Browse All Tools')}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
<Link <Link
to="/account" 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')} {t('home.ctaCreateAccount', 'Create Free Account')}
</Link> </Link>
</div> </div>
</div> </div>
</section> </div>
{/* ── Ad Slot - Bottom ──────────────────────────────────────── */}
<AdSlot slot="home-bottom" className="mt-12" /> <AdSlot slot="home-bottom" className="mt-12" />
</> </section>
</MarketingPageLayout>
); );
} }

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateWebPage, getSiteOrigin } from '@/utils/seo'; 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 { useAuthStore } from '@/stores/authStore';
import SocialProofStrip from '@/components/shared/SocialProofStrip'; import SocialProofStrip from '@/components/shared/SocialProofStrip';
import { getApiClient } from '@/services/api'; import { getApiClient } from '@/services/api';
@@ -15,35 +15,48 @@ interface PlanFeature {
key: string; key: string;
free: boolean | string; free: boolean | string;
pro: boolean | string; pro: boolean | string;
enterprise: boolean | string;
} }
const FEATURES: PlanFeature[] = [ const FEATURES: PlanFeature[] = [
{ key: 'credits', free: '50 credits/30 days', pro: '500 credits/30 days' }, { key: 'credits', free: '50 credits/30 days', pro: '500 credits/30 days', enterprise: 'Unlimited' },
{ key: 'apiAccess', free: false, pro: true }, { key: 'apiAccess', free: false, pro: true, enterprise: true },
{ key: 'apiRequests', free: '—', pro: '1,000/month' }, { key: 'apiRequests', free: '—', pro: '1,000/month', enterprise: 'Unlimited' },
{ key: 'maxFileSize', free: '50 MB', pro: '100 MB' }, { key: 'maxFileSize', free: '50 MB', pro: '100 MB', enterprise: '500 MB' },
{ key: 'historyRetention', free: '25 files', pro: '250 files' }, { key: 'historyRetention', free: '25 files', pro: '250 files', enterprise: 'Unlimited' },
{ key: 'allTools', free: true, pro: true }, { key: 'allTools', free: true, pro: true, enterprise: true },
{ key: 'aiTools', free: true, pro: true }, { key: 'aiTools', free: true, pro: true, enterprise: true },
{ key: 'priorityProcessing', free: false, pro: true }, { key: 'priorityProcessing', free: false, pro: true, enterprise: true },
{ key: 'noAds', free: false, pro: true }, { key: 'noAds', free: false, pro: true, enterprise: true },
{ key: 'emailSupport', free: false, pro: 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() { export default function PricingPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [loading, setLoading] = useState(false); 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 // Track interest in paid plan
try { try {
await api.post('/internal/admin/plan-interest/record', { plan: 'pro', billing }); await api.post('/internal/admin/plan-interest/record', { plan, billing });
} catch { } catch {
// Non-critical — don't block the flow // Non-critical — don't block the flow
} }
if (plan === 'enterprise') {
window.location.href = '/contact';
return;
}
if (!user) { if (!user) {
window.location.href = '/account?redirect=pricing'; window.location.href = '/account?redirect=pricing';
return; return;
@@ -53,42 +66,64 @@ export default function PricingPage() {
const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing }); const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing });
if (data.url) window.location.href = data.url; if (data.url) window.location.href = data.url;
} catch { } catch {
// Stripe not configured yet — show message
alert(t('pages.pricing.stripeNotReady', 'Payment system is being set up. Please try again later.')); alert(t('pages.pricing.stripeNotReady', 'Payment system is being set up. Please try again later.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
function renderValue(val: boolean | string) { const prices = billing === 'yearly' ? YEARLY_PRICES : MONTHLY_PRICES;
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>;
}
return ( return (
<> <>
<SEOHead <SEOHead
title={t('pages.pricing.title', 'Pricing')} 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" path="/pricing"
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.pricing.title', 'Pricing'), 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`, url: `${siteOrigin}/pricing`,
})} })}
/> />
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-6xl">
{/* Header */} {/* Header + billing toggle */}
<div className="mb-12 text-center"> <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')} {t('pages.pricing.title', 'Simple, Transparent Pricing')}
</h1> </h1>
<p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400"> <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> </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="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 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex gap-3"> <div className="flex gap-3">
@@ -120,88 +155,76 @@ export default function PricingPage() {
<SocialProofStrip /> <SocialProofStrip />
</div> </div>
{/* Plan Cards */} {/* 3-tier Plan Cards */}
<div className="mb-16 grid gap-8 md:grid-cols-2"> <div className="mb-16 grid gap-8 md:grid-cols-3">
{/* Free Plan */} {/* 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="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 flex items-center gap-3"> <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">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-700"> <h2 className="text-lg font-bold text-primary-700 dark:text-primary-300">
<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">
{t('pages.pricing.freePlan', 'Free')} {t('pages.pricing.freePlan', 'Free')}
</h2> </h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('pages.pricing.freeDesc', 'For personal use')}
</p>
</div>
</div> </div>
<div className="mb-6"> <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> <span className="text-slate-500 dark:text-slate-400"> / {t('pages.pricing.month', 'month')}</span>
</div> </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) => ( {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"> <li key={f.key} className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
<Check className="h-4 w-4 shrink-0 text-green-500" /> <Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
<span>
{t(`pages.pricing.features.${f.key}`, f.key)} {t(`pages.pricing.features.${f.key}`, f.key)}
{typeof f.free === 'string' && ( {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> </li>
))} ))}
</ul> </ul>
<Link <Link
to="/" 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> </Link>
</div> </div>
{/* Pro Plan */} {/* Pro Plan */}
<div className="relative rounded-2xl border-2 border-primary-500 bg-white p-8 shadow-lg dark:bg-slate-800"> <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-primary-600 px-4 py-1 text-xs font-bold text-white"> <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')} {t('pages.pricing.popular', 'MOST POPULAR')}
</div> </div>
<div className="mb-6 flex items-center gap-3"> <div className="mb-6 rounded-xl bg-gradient-to-r from-primary-600 to-primary-500 py-3 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"> <h2 className="text-lg font-bold text-white">
<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">
{t('pages.pricing.proPlan', 'Pro')} {t('pages.pricing.proPlan', 'Pro')}
</h2> </h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('pages.pricing.proDesc', 'For professionals & teams')}
</p>
</div>
</div> </div>
<div className="mb-6"> <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> <span className="text-slate-500 dark:text-slate-400"> / {t('pages.pricing.month', 'month')}</span>
</div> </div>
<ul className="mb-8 space-y-3"> <ul className="mb-8 flex-1 space-y-3">
{FEATURES.map((f) => ( {FEATURES.filter((f) => f.pro !== false).map((f) => (
<li key={f.key} className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300"> <li key={f.key} className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
<Check className="h-4 w-4 shrink-0 text-primary-500" /> <Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
<span>
{t(`pages.pricing.features.${f.key}`, f.key)} {t(`pages.pricing.features.${f.key}`, f.key)}
{typeof f.pro === 'string' && ( {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> </li>
))} ))}
</ul> </ul>
<button <button
onClick={() => handleUpgrade('monthly')} onClick={() => handleUpgrade('pro')}
disabled={loading || user?.plan === '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" 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' ? ( ) : user?.plan === 'pro' ? (
t('pages.pricing.currentPlan', 'Current Plan') t('pages.pricing.currentPlan', 'Current Plan')
) : ( ) : (
t('pages.pricing.upgradeToPro', 'Upgrade to Pro') t('pages.pricing.startFreeTrial', 'Start Your Free Trial')
)} )}
</button> </button>
<p className="mt-2 text-center text-xs text-slate-500 dark:text-slate-400"> </div>
{t('pages.pricing.securePayment', 'Secure payment via Stripe')}
</p> {/* 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>
</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"> <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"> <div className="max-w-3xl">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white"> <h2 className="text-2xl font-bold text-slate-900 dark:text-white">
@@ -244,39 +300,16 @@ export default function PricingPage() {
</div> </div>
</section> </section>
{/* Comparison Table */} {/* Bottom trust badges */}
<div className="mb-16 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-700"> <div className="mb-16 flex flex-wrap items-center justify-center gap-8 text-sm text-slate-500 dark:text-slate-400">
<table className="w-full text-sm"> <div className="flex items-center gap-2">
<thead> <Shield className="h-5 w-5" />
<tr className="border-b border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50"> {t('pages.pricing.securePayment', 'Secure Payment')}
<th className="px-6 py-4 text-left font-semibold text-slate-700 dark:text-slate-200"> </div>
{t('pages.pricing.feature', 'Feature')} <div className="flex items-center gap-2">
</th> <Zap className="h-5 w-5" />
<th className="px-6 py-4 text-center font-semibold text-slate-700 dark:text-slate-200"> {t('pages.pricing.moneyBack', '30-Day Money Back Guarantee')}
{t('pages.pricing.freePlan', 'Free')} </div>
</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>
</div> </div>
{/* FAQ */} {/* FAQ */}

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowRight, FolderKanban, Link2 } from 'lucide-react'; import { ArrowRight, FolderKanban, Link2 } from 'lucide-react';
import AdSlot from '@/components/layout/AdSlot';
import BreadcrumbNav from '@/components/seo/BreadcrumbNav'; import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import FAQSection from '@/components/seo/FAQSection'; import FAQSection from '@/components/seo/FAQSection';
@@ -241,6 +242,8 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
</section> </section>
<FAQSection faqs={faqItems} /> <FAQSection faqs={faqItems} />
<AdSlot slot="bottom-banner" format="horizontal" className="mt-8" />
</div> </div>
</> </>
); );

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react'; import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react';
import AdSlot from '@/components/layout/AdSlot';
import BreadcrumbNav from '@/components/seo/BreadcrumbNav'; import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import FAQSection from '@/components/seo/FAQSection'; import FAQSection from '@/components/seo/FAQSection';
@@ -322,6 +323,8 @@ export default function SeoPage({ slug }: SeoPageProps) {
</section> </section>
<FAQSection faqs={faqItems} /> <FAQSection faqs={faqItems} />
<AdSlot slot="bottom-banner" format="horizontal" className="mt-8" />
</div> </div>
</> </>
); );

View File

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

View File

@@ -21,6 +21,7 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
overscroll-behavior-y: contain;
} }
body { body {
@@ -41,15 +42,15 @@
@layer components { @layer components {
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-primary-500 dark:hover:bg-primary-600; @apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-primary-500 dark:hover:bg-primary-600;
} }
.btn-secondary { .btn-secondary {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-700; @apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-700;
} }
.btn-success { .btn-success {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-emerald-500 dark:hover:bg-emerald-600; @apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed dark:bg-emerald-500 dark:hover:bg-emerald-600;
} }
.card { .card {
@@ -71,7 +72,7 @@
/* Upload zone styles */ /* Upload zone styles */
.upload-zone { .upload-zone {
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-8 text-center transition-colors cursor-pointer dark:border-slate-600 dark:bg-slate-800/50; @apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-4 text-center transition-colors cursor-pointer sm:p-6 lg:p-8 dark:border-slate-600 dark:bg-slate-800/50;
} }
.upload-zone:hover, .upload-zone:hover,
@@ -118,7 +119,7 @@
Hero Upload Zone — premium glassmorphism card for the homepage Hero Upload Zone — premium glassmorphism card for the homepage
────────────────────────────────────────────────────────────────────────── */ ────────────────────────────────────────────────────────────────────────── */
.hero-upload-zone { .hero-upload-zone {
@apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-10 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm; @apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-6 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-10 lg:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm;
background-image: radial-gradient(ellipse at top, rgba(219, 234, 254, 0.3) 0%, transparent 70%); background-image: radial-gradient(ellipse at top, rgba(219, 234, 254, 0.3) 0%, transparent 70%);
} }
@@ -169,6 +170,40 @@
linear-gradient(180deg, #0f172a 0%, #0f172a 100%); 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 Shimmer loading effect
────────────────────────────────────────────────────────────────────────── */ ────────────────────────────────────────────────────────────────────────── */
@@ -227,3 +262,51 @@
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: 1px 2000px; contain-intrinsic-size: 1px 2000px;
} }
/* ──────────────────────────────────────────────────────────────────────────
Mobile-first enhancements
────────────────────────────────────────────────────────────────────────── */
/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Guard hover-dependent styles for touch devices */
@media (hover: hover) {
.tool-card:hover {
--tw-translate-y: -0.25rem;
}
.hero-upload-zone:hover {
--tw-translate-y: -0.25rem;
}
.marketing-card:hover {
--tw-translate-y: -0.25rem;
}
}
/* Remove 300ms tap delay and prevent double-tap zoom on interactive elements */
a,
button,
input,
select,
textarea,
[role="button"] {
touch-action: manipulation;
}
/* Safe-area inset support for notched phones */
.sticky-header-safe {
padding-top: env(safe-area-inset-top);
}
.bottom-safe {
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -1,6 +1,7 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path'; import path from 'path';
function getAllowedHosts() { function getAllowedHosts() {
@@ -20,7 +21,61 @@ function getAllowedHosts() {
} }
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: 'prompt',
includeAssets: ['favicon.svg', 'logo.svg', 'icons/*.svg'],
manifest: false, // use the static manifest.json in public/
workbox: {
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: false,
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-stylesheets',
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
{
urlPattern: /^https:\/\/.*\.(?:js|css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 7 },
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
@@ -88,6 +143,14 @@ export default defineConfig({
return 'editor'; return 'editor';
} }
if (id.includes('lucide-react')) {
return 'icons';
}
if (id.includes('@microsoft/clarity')) {
return 'analytics';
}
return undefined; return undefined;
}, },
}, },

View File

@@ -38,7 +38,7 @@ server {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com https://plausible.io; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io https://www.clarity.ms; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com https://plausible.io https://*.adtrafficquality.google; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always;
# API requests → Flask backend # API requests → Flask backend
location /api/ { location /api/ {

View File

@@ -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 # Redirect www to non-www
server { server {
listen 443 ssl; listen 443 ssl;
@@ -51,7 +65,7 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Canonical-Host "dociva.io" always; add_header X-Canonical-Host "dociva.io" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com https://plausible.io; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io https://www.clarity.ms; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com https://plausible.io https://*.adtrafficquality.google; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always;
# SEO files — no cache, always fresh # SEO files — no cache, always fresh
location ~* ^/(sitemap\.xml|robots\.txt|llms\.txt|humans\.txt)$ { location ~* ^/(sitemap\.xml|robots\.txt|llms\.txt|humans\.txt)$ {
@@ -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_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000; gzip_min_length 1000;
# Brotli (if module is available) # Brotli (requires ngx_brotli module — disabled for stock nginx:alpine)
brotli on; # brotli on;
brotli_comp_level 5; # brotli_comp_level 5;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; # brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# SSE streaming for assistant chat # SSE streaming for assistant chat
location /api/assistant/chat/stream { location /api/assistant/chat/stream {
@@ -114,3 +128,40 @@ server {
proxy_pass http://$backend_upstream/api/health; 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;
}

File diff suppressed because it is too large Load Diff