fix: Add scrollable container to ToolSelectorModal for small screens

- Add max-h-[90vh] and flex-col to modal content container
- Wrap tools grid in max-h-[50vh] overflow-y-auto container
- Add overscroll-contain for smooth scroll behavior on mobile
- Fixes issue where 21 PDF tools overflow viewport on small screens
This commit is contained in:
Your Name
2026-04-01 22:22:48 +02:00
parent 3e1c0e5f99
commit 314f847ece
49 changed files with 2142 additions and 361 deletions

View File

@@ -9,6 +9,7 @@ import ToolLandingPage from '@/components/seo/ToolLandingPage';
import { useDirection } from '@/hooks/useDirection';
import { initAnalytics, trackPageView } from '@/services/analytics';
import { useAuthStore } from '@/stores/authStore';
import { TOOL_MANIFEST } from '@/config/toolManifest';
let clarityInitialized = false;
@@ -32,53 +33,10 @@ const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
// Tool Pages
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord'));
const WordToPdf = lazy(() => import('@/components/tools/WordToPdf'));
const PdfCompressor = lazy(() => import('@/components/tools/PdfCompressor'));
const ImageConverter = lazy(() => import('@/components/tools/ImageConverter'));
const VideoToGif = lazy(() => import('@/components/tools/VideoToGif'));
const WordCounter = lazy(() => import('@/components/tools/WordCounter'));
const TextCleaner = lazy(() => import('@/components/tools/TextCleaner'));
const MergePdf = lazy(() => import('@/components/tools/MergePdf'));
const SplitPdf = lazy(() => import('@/components/tools/SplitPdf'));
const RotatePdf = lazy(() => import('@/components/tools/RotatePdf'));
const PdfToImages = lazy(() => import('@/components/tools/PdfToImages'));
const ImagesToPdf = lazy(() => import('@/components/tools/ImagesToPdf'));
const WatermarkPdf = lazy(() => import('@/components/tools/WatermarkPdf'));
const ProtectPdf = lazy(() => import('@/components/tools/ProtectPdf'));
const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf'));
const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers'));
const PdfEditor = lazy(() => import('@/components/tools/PdfEditor'));
const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart'));
const ImageResize = lazy(() => import('@/components/tools/ImageResize'));
const OcrTool = lazy(() => import('@/components/tools/OcrTool'));
const RemoveBackground = lazy(() => import('@/components/tools/RemoveBackground'));
const CompressImage = lazy(() => import('@/components/tools/CompressImage'));
const PdfToExcel = lazy(() => import('@/components/tools/PdfToExcel'));
const RemoveWatermark = lazy(() => import('@/components/tools/RemoveWatermark'));
const ReorderPdf = lazy(() => import('@/components/tools/ReorderPdf'));
const ExtractPages = lazy(() => import('@/components/tools/ExtractPages'));
const QrCodeGenerator = lazy(() => import('@/components/tools/QrCodeGenerator'));
const HtmlToPdf = lazy(() => import('@/components/tools/HtmlToPdf'));
const ChatPdf = lazy(() => import('@/components/tools/ChatPdf'));
const SummarizePdf = lazy(() => import('@/components/tools/SummarizePdf'));
const TranslatePdf = lazy(() => import('@/components/tools/TranslatePdf'));
const TableExtractor = lazy(() => import('@/components/tools/TableExtractor'));
// Phase 2 lazy imports
const PdfToPptx = lazy(() => import('@/components/tools/PdfToPptx'));
const ExcelToPdf = lazy(() => import('@/components/tools/ExcelToPdf'));
const PptxToPdf = lazy(() => import('@/components/tools/PptxToPdf'));
const SignPdf = lazy(() => import('@/components/tools/SignPdf'));
const CropPdf = lazy(() => import('@/components/tools/CropPdf'));
const FlattenPdf = lazy(() => import('@/components/tools/FlattenPdf'));
const RepairPdf = lazy(() => import('@/components/tools/RepairPdf'));
const PdfMetadata = lazy(() => import('@/components/tools/PdfMetadata'));
const ImageCrop = lazy(() => import('@/components/tools/ImageCrop'));
const ImageToSvg = lazy(() => import('@/components/tools/ImageToSvg'));
const ImageRotateFlip = lazy(() => import('@/components/tools/ImageRotateFlip'));
const BarcodeGenerator = lazy(() => import('@/components/tools/BarcodeGenerator'));
// Tool components — derived from manifest using React.lazy
const ToolComponents = Object.fromEntries(
TOOL_MANIFEST.map((tool) => [tool.slug, lazy(tool.component)])
) as Record<string, React.LazyExoticComponent<React.ComponentType>>;
function LoadingFallback() {
return (
@@ -165,71 +123,17 @@ export default function App() {
<Route path="/ar/:slug" element={<SeoRoutePage />} />
<Route path="/:slug" element={<SeoRoutePage />} />
{/* PDF Tools */}
<Route path="/tools/pdf-to-word" element={<ToolLandingPage slug="pdf-to-word"><PdfToWord /></ToolLandingPage>} />
<Route path="/tools/word-to-pdf" element={<ToolLandingPage slug="word-to-pdf"><WordToPdf /></ToolLandingPage>} />
<Route path="/tools/compress-pdf" element={<ToolLandingPage slug="compress-pdf"><PdfCompressor /></ToolLandingPage>} />
<Route path="/tools/merge-pdf" element={<ToolLandingPage slug="merge-pdf"><MergePdf /></ToolLandingPage>} />
<Route path="/tools/split-pdf" element={<ToolLandingPage slug="split-pdf"><SplitPdf /></ToolLandingPage>} />
<Route path="/tools/rotate-pdf" element={<ToolLandingPage slug="rotate-pdf"><RotatePdf /></ToolLandingPage>} />
<Route path="/tools/pdf-to-images" element={<ToolLandingPage slug="pdf-to-images"><PdfToImages /></ToolLandingPage>} />
<Route path="/tools/images-to-pdf" element={<ToolLandingPage slug="images-to-pdf"><ImagesToPdf /></ToolLandingPage>} />
<Route path="/tools/watermark-pdf" element={<ToolLandingPage slug="watermark-pdf"><WatermarkPdf /></ToolLandingPage>} />
<Route path="/tools/protect-pdf" element={<ToolLandingPage slug="protect-pdf"><ProtectPdf /></ToolLandingPage>} />
<Route path="/tools/unlock-pdf" element={<ToolLandingPage slug="unlock-pdf"><UnlockPdf /></ToolLandingPage>} />
<Route path="/tools/page-numbers" element={<ToolLandingPage slug="page-numbers"><AddPageNumbers /></ToolLandingPage>} />
<Route path="/tools/pdf-editor" element={<ToolLandingPage slug="pdf-editor"><PdfEditor /></ToolLandingPage>} />
<Route path="/tools/pdf-flowchart" element={<ToolLandingPage slug="pdf-flowchart"><PdfFlowchart /></ToolLandingPage>} />
{/* Image Tools */}
<Route path="/tools/image-converter" element={<ToolLandingPage slug="image-converter"><ImageConverter /></ToolLandingPage>} />
<Route path="/tools/image-resize" element={<ToolLandingPage slug="image-resize"><ImageResize /></ToolLandingPage>} />
<Route path="/tools/compress-image" element={<ToolLandingPage slug="compress-image"><CompressImage /></ToolLandingPage>} />
<Route path="/tools/ocr" element={<ToolLandingPage slug="ocr"><OcrTool /></ToolLandingPage>} />
<Route path="/tools/remove-background" element={<ToolLandingPage slug="remove-background"><RemoveBackground /></ToolLandingPage>} />
<Route path="/tools/image-to-svg" element={<ToolLandingPage slug="image-to-svg"><ImageToSvg /></ToolLandingPage>} />
{/* Convert Tools */}
<Route path="/tools/pdf-to-excel" element={<ToolLandingPage slug="pdf-to-excel"><PdfToExcel /></ToolLandingPage>} />
<Route path="/tools/html-to-pdf" element={<ToolLandingPage slug="html-to-pdf"><HtmlToPdf /></ToolLandingPage>} />
{/* PDF Extra Tools */}
<Route path="/tools/remove-watermark-pdf" element={<ToolLandingPage slug="remove-watermark-pdf"><RemoveWatermark /></ToolLandingPage>} />
<Route path="/tools/reorder-pdf" element={<ToolLandingPage slug="reorder-pdf"><ReorderPdf /></ToolLandingPage>} />
<Route path="/tools/extract-pages" element={<ToolLandingPage slug="extract-pages"><ExtractPages /></ToolLandingPage>} />
{/* AI Tools */}
<Route path="/tools/chat-pdf" element={<ToolLandingPage slug="chat-pdf"><ChatPdf /></ToolLandingPage>} />
<Route path="/tools/summarize-pdf" element={<ToolLandingPage slug="summarize-pdf"><SummarizePdf /></ToolLandingPage>} />
<Route path="/tools/translate-pdf" element={<ToolLandingPage slug="translate-pdf"><TranslatePdf /></ToolLandingPage>} />
<Route path="/tools/extract-tables" element={<ToolLandingPage slug="extract-tables"><TableExtractor /></ToolLandingPage>} />
{/* Other Tools */}
<Route path="/tools/qr-code" element={<ToolLandingPage slug="qr-code"><QrCodeGenerator /></ToolLandingPage>} />
{/* Video Tools */}
<Route path="/tools/video-to-gif" element={<ToolLandingPage slug="video-to-gif"><VideoToGif /></ToolLandingPage>} />
{/* Text Tools */}
<Route path="/tools/word-counter" element={<ToolLandingPage slug="word-counter"><WordCounter /></ToolLandingPage>} />
<Route path="/tools/text-cleaner" element={<ToolLandingPage slug="text-cleaner"><TextCleaner /></ToolLandingPage>} />
{/* Phase 2 PDF Conversion */}
<Route path="/tools/pdf-to-pptx" element={<ToolLandingPage slug="pdf-to-pptx"><PdfToPptx /></ToolLandingPage>} />
<Route path="/tools/excel-to-pdf" element={<ToolLandingPage slug="excel-to-pdf"><ExcelToPdf /></ToolLandingPage>} />
<Route path="/tools/pptx-to-pdf" element={<ToolLandingPage slug="pptx-to-pdf"><PptxToPdf /></ToolLandingPage>} />
<Route path="/tools/sign-pdf" element={<ToolLandingPage slug="sign-pdf"><SignPdf /></ToolLandingPage>} />
{/* Phase 2 PDF Extra */}
<Route path="/tools/crop-pdf" element={<ToolLandingPage slug="crop-pdf"><CropPdf /></ToolLandingPage>} />
<Route path="/tools/flatten-pdf" element={<ToolLandingPage slug="flatten-pdf"><FlattenPdf /></ToolLandingPage>} />
<Route path="/tools/repair-pdf" element={<ToolLandingPage slug="repair-pdf"><RepairPdf /></ToolLandingPage>} />
<Route path="/tools/pdf-metadata" element={<ToolLandingPage slug="pdf-metadata"><PdfMetadata /></ToolLandingPage>} />
{/* Phase 2 Image & Utility */}
<Route path="/tools/image-crop" element={<ToolLandingPage slug="image-crop"><ImageCrop /></ToolLandingPage>} />
<Route path="/tools/image-rotate-flip" element={<ToolLandingPage slug="image-rotate-flip"><ImageRotateFlip /></ToolLandingPage>} />
<Route path="/tools/barcode-generator" element={<ToolLandingPage slug="barcode-generator"><BarcodeGenerator /></ToolLandingPage>} />
{/* Tool Routes — driven by the unified manifest */}
{TOOL_MANIFEST.map((tool) => {
const Component = ToolComponents[tool.slug];
return (
<Route
key={tool.slug}
path={`/tools/${tool.slug}`}
element={<ToolLandingPage slug={tool.slug}><Component /></ToolLandingPage>}
/>
);
})}
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />

View File

@@ -1,12 +1,15 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { Download, RotateCcw, Clock } from 'lucide-react';
import { Download, RotateCcw, Clock, Lock } from 'lucide-react';
import type { TaskResult } from '@/services/api';
import { formatFileSize } from '@/utils/textTools';
import { trackEvent } from '@/services/analytics';
import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt';
import SharePanel from '@/components/shared/SharePanel';
import SuggestedTools from '@/components/seo/SuggestedTools';
import SignUpToDownloadModal from '@/components/shared/SignUpToDownloadModal';
import { useAuthStore } from '@/stores/authStore';
interface DownloadButtonProps {
/** Task result containing download URL */
@@ -18,10 +21,21 @@ interface DownloadButtonProps {
export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) {
const { t } = useTranslation();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const [showGateModal, setShowGateModal] = useState(false);
const currentToolSlug = location.pathname.startsWith('/tools/')
? location.pathname.replace('/tools/', '')
: null;
// Extract the download task ID from the download URL path
// URL format: /api/download/<task_id>/<filename>
const downloadTaskId = (() => {
if (!result.download_url) return undefined;
const parts = result.download_url.split('/');
const idx = parts.indexOf('download');
return idx >= 0 && parts.length > idx + 1 ? parts[idx + 1] : undefined;
})();
const handleDownloadClick = () => {
trackEvent('download_clicked', { filename: result.filename || 'unknown' });
dispatchCurrentToolRatingPrompt();
@@ -72,17 +86,35 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
)}
{/* Download button */}
<a
href={result.download_url}
download={result.filename}
onClick={handleDownloadClick}
className="btn-success w-full"
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-5 w-5" />
{t('common.download')} {result.filename}
</a>
{user ? (
<a
href={result.download_url}
download={result.filename}
onClick={handleDownloadClick}
className="btn-success w-full"
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-5 w-5" />
{t('common.download')} {result.filename}
</a>
) : (
<button
onClick={() => setShowGateModal(true)}
className="btn-primary w-full"
>
<Lock className="h-5 w-5" />
{t('downloadGate.downloadCta')}
</button>
)}
{showGateModal && (
<SignUpToDownloadModal
onClose={() => setShowGateModal(false)}
taskId={downloadTaskId}
toolSlug={currentToolSlug ?? undefined}
/>
)}
<div className="mt-3 flex justify-center">
<SharePanel

View File

@@ -0,0 +1,164 @@
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { UserPlus, LogIn, X, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/authStore';
import { claimTask } from '@/services/api';
interface SignUpToDownloadModalProps {
onClose: () => void;
/** Download task ID extracted from the download URL. */
taskId?: string;
/** Tool slug for credit accounting. */
toolSlug?: string;
}
export default function SignUpToDownloadModal({
onClose,
taskId,
toolSlug,
}: SignUpToDownloadModalProps) {
const { t } = useTranslation();
const { login, register } = useAuthStore();
const [mode, setMode] = useState<'register' | 'login'>('register');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (mode === 'register' && password !== confirmPassword) {
setError(t('account.passwordMismatch'));
return;
}
setLoading(true);
try {
if (mode === 'login') {
await login(email, password);
} else {
await register(email, password);
}
// Claim the anonymous task into the new account's history
if (taskId && toolSlug) {
try {
await claimTask(taskId, toolSlug);
} catch {
// Non-blocking — file is still downloadable via session
}
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t('account.loadFailed'));
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="relative w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-slate-800">
{/* Close button */}
<button
onClick={onClose}
className="absolute end-3 top-3 rounded-full p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
aria-label={t('common.close')}
>
<X className="h-5 w-5" />
</button>
{/* Header */}
<div className="mb-4 text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<UserPlus className="h-7 w-7 text-primary-600 dark:text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t('downloadGate.title')}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t('downloadGate.subtitle')}
</p>
</div>
{/* Benefits — compact */}
<ul className="mb-4 space-y-1.5 text-sm text-slate-600 dark:text-slate-300">
{[
t('downloadGate.benefit1'),
t('downloadGate.benefit2'),
t('downloadGate.benefit3'),
].map((b, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-0.5 text-emerald-500"></span>
{b}
</li>
))}
</ul>
{/* Inline auth form */}
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('account.emailPlaceholder')}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:focus:ring-primary-900/30"
/>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('account.passwordPlaceholder')}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:focus:ring-primary-900/30"
/>
{mode === 'register' && (
<input
type="password"
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('account.confirmPasswordPlaceholder')}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:focus:ring-primary-900/30"
/>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full disabled:opacity-60"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : mode === 'register' ? (
<><UserPlus className="h-4 w-4" /> {t('account.submitRegister')}</>
) : (
<><LogIn className="h-4 w-4" /> {t('account.submitLogin')}</>
)}
</button>
</form>
{/* Toggle login / register */}
<button
type="button"
onClick={() => { setMode(mode === 'register' ? 'login' : 'register'); setError(null); }}
className="mt-3 w-full text-center text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{mode === 'register' ? t('downloadGate.signIn') : t('downloadGate.switchToRegister')}
</button>
</div>
</div>
);
}

View File

@@ -86,7 +86,7 @@ export default function ToolSelectorModal({
aria-modal="true"
aria-labelledby="tool-selector-title"
>
<div className="modal-content w-full max-w-lg 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-[90vh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */}
<div className="mb-5 flex items-start justify-between">
<div>
@@ -123,26 +123,28 @@ export default function ToolSelectorModal({
</div>
{/* Tools Grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{tools.map((tool) => {
const Icon = tool.icon;
return (
<button
key={tool.key}
onClick={() => handleToolSelect(tool)}
className="group flex flex-col items-center gap-2 rounded-xl p-4 ring-1 ring-slate-200 transition-all hover:ring-primary-300 hover:shadow-md dark:ring-slate-700 dark:hover:ring-primary-600"
>
<div
className={`flex h-10 w-10 items-center justify-center rounded-xl ${tool.bgColor}`}
<div className="max-h-[50vh] overflow-y-auto overscroll-contain">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{tools.map((tool) => {
const Icon = tool.icon;
return (
<button
key={tool.key}
onClick={() => handleToolSelect(tool)}
className="group flex flex-col items-center gap-2 rounded-xl p-4 ring-1 ring-slate-200 transition-all hover:ring-primary-300 hover:shadow-md dark:ring-slate-700 dark:hover:ring-primary-600"
>
<Icon className={`h-5 w-5 ${tool.iconColor}`} />
</div>
<span className="text-center text-xs font-medium text-slate-700 group-hover:text-primary-600 dark:text-slate-300 dark:group-hover:text-primary-400">
{t(`tools.${tool.key}.shortDesc`)}
</span>
</button>
);
})}
<div
className={`flex h-10 w-10 items-center justify-center rounded-xl ${tool.bgColor}`}
>
<Icon className={`h-5 w-5 ${tool.iconColor}`} />
</div>
<span className="text-center text-xs font-medium text-slate-700 group-hover:text-primary-600 dark:text-slate-300 dark:group-hover:text-primary-400">
{t(`tools.${tool.key}.shortDesc`)}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { ALL_ROUTES } from '@/config/routes';
import { ALL_ROUTES, TOOL_ROUTES } from '@/config/routes';
import { getAllSeoLandingPaths } from '@/config/seoPages';
const __filename = fileURLToPath(import.meta.url);
@@ -12,7 +12,8 @@ const __dirname = dirname(__filename);
* SAFETY TEST — Route Integrity
*
* Ensures that every route in the canonical registry (routes.ts)
* has a matching <Route path="..."> in App.tsx.
* has a matching <Route path="..."> in App.tsx — either as a static
* path="..." attribute or via the TOOL_MANIFEST dynamic loop.
*
* If this test fails it means either:
* 1. A route was removed from App.tsx (NEVER do this)
@@ -25,7 +26,7 @@ describe('Route safety', () => {
);
const seoLandingPaths = new Set(getAllSeoLandingPaths());
// Extract all path="..." values from <Route> elements
// Extract all static path="..." values from <Route> elements
const routePathRegex = /path="([^"]+)"/g;
const appPaths = new Set<string>();
let match: RegExpExecArray | null;
@@ -33,6 +34,11 @@ describe('Route safety', () => {
if (match[1] !== '*') appPaths.add(match[1]);
}
// Detect manifest-driven routing: if App.tsx renders tool routes via
// TOOL_MANIFEST.map, every TOOL_ROUTES entry is covered dynamically.
const hasManifestLoop = appSource.includes('TOOL_MANIFEST.map');
const toolRouteSet = new Set(TOOL_ROUTES as readonly string[]);
it('App.tsx contains routes for every entry in the route registry', () => {
const hasDynamicSeoRoute = appPaths.has('/:slug');
const missing = ALL_ROUTES.filter((route) => {
@@ -40,6 +46,11 @@ describe('Route safety', () => {
return false;
}
// Tool routes covered by the manifest loop
if (hasManifestLoop && toolRouteSet.has(route)) {
return false;
}
if (hasDynamicSeoRoute && seoLandingPaths.has(route)) {
return false;
}

View File

@@ -4,9 +4,12 @@
* SAFETY RULE: Never remove a route from this list.
* New routes may only be appended. The route safety test
* (routes.test.ts) will fail if any existing route is deleted.
*
* Tool routes are now derived from the unified manifest (toolManifest.ts).
*/
import { getAllSeoLandingPaths } from '@/config/seoPages';
import { getManifestRoutePaths } from '@/config/toolManifest';
const STATIC_PAGE_ROUTES = [
'/',
@@ -35,68 +38,8 @@ export const PAGE_ROUTES = [
'/ar/:slug',
] as const;
// ─── Tool routes ─────────────────────────────────────────────────
export const TOOL_ROUTES = [
// PDF Tools
'/tools/pdf-to-word',
'/tools/word-to-pdf',
'/tools/compress-pdf',
'/tools/merge-pdf',
'/tools/split-pdf',
'/tools/rotate-pdf',
'/tools/pdf-to-images',
'/tools/images-to-pdf',
'/tools/watermark-pdf',
'/tools/protect-pdf',
'/tools/unlock-pdf',
'/tools/page-numbers',
'/tools/pdf-editor',
'/tools/pdf-flowchart',
'/tools/pdf-to-excel',
'/tools/remove-watermark-pdf',
'/tools/reorder-pdf',
'/tools/extract-pages',
// Image Tools
'/tools/image-converter',
'/tools/image-resize',
'/tools/compress-image',
'/tools/ocr',
'/tools/remove-background',
'/tools/image-to-svg',
// Convert Tools
'/tools/html-to-pdf',
// AI Tools
'/tools/chat-pdf',
'/tools/summarize-pdf',
'/tools/translate-pdf',
'/tools/extract-tables',
// Other Tools
'/tools/qr-code',
'/tools/video-to-gif',
'/tools/word-counter',
'/tools/text-cleaner',
// Phase 2 PDF Conversion
'/tools/pdf-to-pptx',
'/tools/excel-to-pdf',
'/tools/pptx-to-pdf',
'/tools/sign-pdf',
// Phase 2 PDF Extra Tools
'/tools/crop-pdf',
'/tools/flatten-pdf',
'/tools/repair-pdf',
'/tools/pdf-metadata',
// Phase 2 Image & Utility
'/tools/image-crop',
'/tools/image-rotate-flip',
'/tools/barcode-generator',
] as const;
// ─── Tool routes (derived from manifest) ─────────────────────────
export const TOOL_ROUTES = getManifestRoutePaths() as unknown as readonly string[];
// ─── All routes combined ─────────────────────────────────────────
export const ALL_ROUTES = [...PAGE_ROUTES, ...TOOL_ROUTES] as const;

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import { TOOL_MANIFEST, getManifestSlugs } from '@/config/toolManifest';
import { getAllToolSlugs, getToolSEO } from '@/config/seoData';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* DRIFT-DETECTION TESTS
*
* Ensures toolManifest.ts stays in sync with seoData.ts and HomePage.tsx.
* If any test fails it means someone added a tool in one place but forgot
* the other — fix by updating both files.
*/
describe('Tool Manifest ↔ SEO Data sync', () => {
const manifestSlugs = new Set(getManifestSlugs());
const seoSlugs = new Set(getAllToolSlugs());
it('every manifest tool has an seoData entry', () => {
const missing = [...manifestSlugs].filter((s) => !seoSlugs.has(s));
expect(missing, `Manifest tools missing seoData: ${missing.join(', ')}`).toEqual(
[]
);
});
it('every seoData tool has a manifest entry', () => {
const missing = [...seoSlugs].filter((s) => !manifestSlugs.has(s));
expect(missing, `seoData tools missing manifest: ${missing.join(', ')}`).toEqual(
[]
);
});
it('no duplicate slugs in the manifest', () => {
const seen = new Set<string>();
const dupes: string[] = [];
for (const tool of TOOL_MANIFEST) {
if (seen.has(tool.slug)) dupes.push(tool.slug);
seen.add(tool.slug);
}
expect(dupes, `Duplicate manifest slugs: ${dupes.join(', ')}`).toEqual([]);
});
it('no duplicate slugs in seoData', () => {
const all = getAllToolSlugs();
expect(new Set(all).size).toBe(all.length);
});
it('each seoData entry has required fields populated', () => {
for (const slug of seoSlugs) {
const seo = getToolSEO(slug);
expect(seo, `seoData missing entry for slug: ${slug}`).toBeDefined();
expect(seo!.titleSuffix?.length).toBeGreaterThan(0);
expect(seo!.metaDescription?.length).toBeGreaterThan(0);
}
});
});
describe('Tool Manifest ↔ HomePage ICON_MAP sync', () => {
const homePageSource = readFileSync(
resolve(__dirname, '../pages/HomePage.tsx'),
'utf-8'
);
// Extract icon names from the ICON_MAP object literal
// Match from "= {" to "};" to skip the type annotation that also contains braces
const iconMapMatch = homePageSource.match(/ICON_MAP[^=]+=\s*\{([\s\S]+?)\};/);
const iconMapKeys = new Set(
iconMapMatch
? iconMapMatch[1]
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
: []
);
it('every homepage-visible manifest tool has its icon in ICON_MAP', () => {
const missing: string[] = [];
for (const tool of TOOL_MANIFEST) {
if (tool.homepage && !iconMapKeys.has(tool.iconName)) {
missing.push(`${tool.slug} (icon: ${tool.iconName})`);
}
}
expect(
missing,
`Homepage tools with missing ICON_MAP entries: ${missing.join(', ')}`
).toEqual([]);
});
});
describe('Tool Manifest internal consistency', () => {
it('all manifest entries have non-empty slugs and i18nKeys', () => {
for (const tool of TOOL_MANIFEST) {
expect(tool.slug.length).toBeGreaterThan(0);
expect(tool.i18nKey.length).toBeGreaterThan(0);
}
});
it('all manifest slugs follow kebab-case pattern', () => {
const kebab = /^[a-z0-9]+(-[a-z0-9]+)*$/;
for (const tool of TOOL_MANIFEST) {
expect(
kebab.test(tool.slug),
`Slug "${tool.slug}" is not kebab-case`
).toBe(true);
}
});
it('manifest has at least 40 tools', () => {
expect(TOOL_MANIFEST.length).toBeGreaterThanOrEqual(40);
});
});

View File

@@ -0,0 +1,601 @@
/**
* Unified Tool Manifest — the single source of truth for every tool.
*
* Every consumer (App.tsx routes, HomePage grid, seoData, routes.ts, sitemap)
* should derive its list from this manifest instead of maintaining a separate
* hard-coded array. This eliminates drift between route definitions, SEO
* metadata, and homepage visibility.
*
* SAFETY RULE: Never remove an entry. New tools may only be appended.
*/
// ── Types ──────────────────────────────────────────────────────────
export type ToolCategory = 'pdf-core' | 'pdf-extended' | 'image' | 'conversion' | 'ai' | 'utility';
export interface ToolEntry {
/** URL slug under /tools/ — also used as the unique key */
slug: string;
/** i18n key used in `tools.<key>.title` / `tools.<key>.shortDesc` */
i18nKey: string;
/** Lazy-import factory — returns the React component */
component: () => Promise<{ default: React.ComponentType }>;
/** Portfolio category */
category: ToolCategory;
/** Visible on homepage grid */
homepage: boolean;
/** Homepage section: 'pdf' tools section or 'other' tools section */
homepageSection?: 'pdf' | 'other';
/** Lucide icon name to render (used by HomePage) */
iconName: string;
/** Tailwind text-color class for the icon */
iconColor: string;
/** Tailwind bg-color class for the card */
bgColor: string;
/** Demand tier from portfolio analysis */
demandTier: 'A' | 'B' | 'C';
}
// ── Manifest ───────────────────────────────────────────────────────
export const TOOL_MANIFEST: readonly ToolEntry[] = [
// ─── PDF Core ──────────────────────────────────────────────────
{
slug: 'pdf-editor',
i18nKey: 'pdfEditor',
component: () => import('@/components/tools/PdfEditor'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'PenLine',
iconColor: 'text-rose-600',
bgColor: 'bg-rose-50',
demandTier: 'A',
},
{
slug: 'pdf-to-word',
i18nKey: 'pdfToWord',
component: () => import('@/components/tools/PdfToWord'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileText',
iconColor: 'text-red-600',
bgColor: 'bg-red-50',
demandTier: 'A',
},
{
slug: 'word-to-pdf',
i18nKey: 'wordToPdf',
component: () => import('@/components/tools/WordToPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileOutput',
iconColor: 'text-blue-600',
bgColor: 'bg-blue-50',
demandTier: 'A',
},
{
slug: 'compress-pdf',
i18nKey: 'compressPdf',
component: () => import('@/components/tools/PdfCompressor'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Minimize2',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'A',
},
{
slug: 'merge-pdf',
i18nKey: 'mergePdf',
component: () => import('@/components/tools/MergePdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Layers',
iconColor: 'text-violet-600',
bgColor: 'bg-violet-50',
demandTier: 'A',
},
{
slug: 'split-pdf',
i18nKey: 'splitPdf',
component: () => import('@/components/tools/SplitPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Scissors',
iconColor: 'text-pink-600',
bgColor: 'bg-pink-50',
demandTier: 'A',
},
{
slug: 'rotate-pdf',
i18nKey: 'rotatePdf',
component: () => import('@/components/tools/RotatePdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'RotateCw',
iconColor: 'text-teal-600',
bgColor: 'bg-teal-50',
demandTier: 'A',
},
{
slug: 'pdf-to-images',
i18nKey: 'pdfToImages',
component: () => import('@/components/tools/PdfToImages'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Image',
iconColor: 'text-amber-600',
bgColor: 'bg-amber-50',
demandTier: 'A',
},
{
slug: 'images-to-pdf',
i18nKey: 'imagesToPdf',
component: () => import('@/components/tools/ImagesToPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileImage',
iconColor: 'text-lime-600',
bgColor: 'bg-lime-50',
demandTier: 'A',
},
{
slug: 'watermark-pdf',
i18nKey: 'watermarkPdf',
component: () => import('@/components/tools/WatermarkPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Droplets',
iconColor: 'text-cyan-600',
bgColor: 'bg-cyan-50',
demandTier: 'B',
},
{
slug: 'protect-pdf',
i18nKey: 'protectPdf',
component: () => import('@/components/tools/ProtectPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Lock',
iconColor: 'text-red-600',
bgColor: 'bg-red-50',
demandTier: 'A',
},
{
slug: 'unlock-pdf',
i18nKey: 'unlockPdf',
component: () => import('@/components/tools/UnlockPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Unlock',
iconColor: 'text-green-600',
bgColor: 'bg-green-50',
demandTier: 'A',
},
{
slug: 'page-numbers',
i18nKey: 'pageNumbers',
component: () => import('@/components/tools/AddPageNumbers'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'ListOrdered',
iconColor: 'text-sky-600',
bgColor: 'bg-sky-50',
demandTier: 'B',
},
// ─── PDF Extended ──────────────────────────────────────────────
{
slug: 'pdf-flowchart',
i18nKey: 'pdfFlowchart',
component: () => import('@/components/tools/PdfFlowchart'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'GitBranch',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'C',
},
{
slug: 'remove-watermark-pdf',
i18nKey: 'removeWatermark',
component: () => import('@/components/tools/RemoveWatermark'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'Droplets',
iconColor: 'text-rose-600',
bgColor: 'bg-rose-50',
demandTier: 'B',
},
{
slug: 'reorder-pdf',
i18nKey: 'reorderPdf',
component: () => import('@/components/tools/ReorderPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'ArrowUpDown',
iconColor: 'text-violet-600',
bgColor: 'bg-violet-50',
demandTier: 'B',
},
{
slug: 'extract-pages',
i18nKey: 'extractPages',
component: () => import('@/components/tools/ExtractPages'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileOutput',
iconColor: 'text-amber-600',
bgColor: 'bg-amber-50',
demandTier: 'B',
},
{
slug: 'sign-pdf',
i18nKey: 'signPdf',
component: () => import('@/components/tools/SignPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'PenLine',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'A',
},
{
slug: 'crop-pdf',
i18nKey: 'cropPdf',
component: () => import('@/components/tools/CropPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'Crop',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'B',
},
{
slug: 'flatten-pdf',
i18nKey: 'flattenPdf',
component: () => import('@/components/tools/FlattenPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileDown',
iconColor: 'text-slate-600',
bgColor: 'bg-slate-50',
demandTier: 'B',
},
{
slug: 'repair-pdf',
i18nKey: 'repairPdf',
component: () => import('@/components/tools/RepairPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'Wrench',
iconColor: 'text-yellow-600',
bgColor: 'bg-yellow-50',
demandTier: 'B',
},
{
slug: 'pdf-metadata',
i18nKey: 'pdfMetadata',
component: () => import('@/components/tools/PdfMetadata'),
category: 'pdf-extended',
homepage: false,
homepageSection: 'pdf',
iconName: 'FileText',
iconColor: 'text-gray-600',
bgColor: 'bg-gray-50',
demandTier: 'C',
},
// ─── Image ─────────────────────────────────────────────────────
{
slug: 'image-converter',
i18nKey: 'imageConvert',
component: () => import('@/components/tools/ImageConverter'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'ImageIcon',
iconColor: 'text-purple-600',
bgColor: 'bg-purple-50',
demandTier: 'B',
},
{
slug: 'image-resize',
i18nKey: 'imageResize',
component: () => import('@/components/tools/ImageResize'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Scaling',
iconColor: 'text-teal-600',
bgColor: 'bg-teal-50',
demandTier: 'B',
},
{
slug: 'compress-image',
i18nKey: 'compressImage',
component: () => import('@/components/tools/CompressImage'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Minimize2',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'A',
},
{
slug: 'ocr',
i18nKey: 'ocr',
component: () => import('@/components/tools/OcrTool'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'ScanText',
iconColor: 'text-amber-600',
bgColor: 'bg-amber-50',
demandTier: 'A',
},
{
slug: 'remove-background',
i18nKey: 'removeBg',
component: () => import('@/components/tools/RemoveBackground'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Eraser',
iconColor: 'text-fuchsia-600',
bgColor: 'bg-fuchsia-50',
demandTier: 'A',
},
{
slug: 'image-to-svg',
i18nKey: 'imageToSvg',
component: () => import('@/components/tools/ImageToSvg'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'ImageIcon',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'B',
},
{
slug: 'image-crop',
i18nKey: 'imageCrop',
component: () => import('@/components/tools/ImageCrop'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Crop',
iconColor: 'text-pink-600',
bgColor: 'bg-pink-50',
demandTier: 'C',
},
{
slug: 'image-rotate-flip',
i18nKey: 'imageRotateFlip',
component: () => import('@/components/tools/ImageRotateFlip'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'RotateCw',
iconColor: 'text-cyan-600',
bgColor: 'bg-cyan-50',
demandTier: 'C',
},
// ─── Conversion ────────────────────────────────────────────────
{
slug: 'pdf-to-excel',
i18nKey: 'pdfToExcel',
component: () => import('@/components/tools/PdfToExcel'),
category: 'conversion',
homepage: true,
homepageSection: 'pdf',
iconName: 'Sheet',
iconColor: 'text-green-600',
bgColor: 'bg-green-50',
demandTier: 'A',
},
{
slug: 'html-to-pdf',
i18nKey: 'htmlToPdf',
component: () => import('@/components/tools/HtmlToPdf'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Code',
iconColor: 'text-sky-600',
bgColor: 'bg-sky-50',
demandTier: 'B',
},
{
slug: 'pdf-to-pptx',
i18nKey: 'pdfToPptx',
component: () => import('@/components/tools/PdfToPptx'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Presentation',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'B',
},
{
slug: 'excel-to-pdf',
i18nKey: 'excelToPdf',
component: () => import('@/components/tools/ExcelToPdf'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Sheet',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'B',
},
{
slug: 'pptx-to-pdf',
i18nKey: 'pptxToPdf',
component: () => import('@/components/tools/PptxToPdf'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Presentation',
iconColor: 'text-red-600',
bgColor: 'bg-red-50',
demandTier: 'B',
},
// ─── AI ────────────────────────────────────────────────────────
{
slug: 'chat-pdf',
i18nKey: 'chatPdf',
component: () => import('@/components/tools/ChatPdf'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'MessageSquare',
iconColor: 'text-blue-600',
bgColor: 'bg-blue-50',
demandTier: 'B',
},
{
slug: 'summarize-pdf',
i18nKey: 'summarizePdf',
component: () => import('@/components/tools/SummarizePdf'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileText',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'A',
},
{
slug: 'translate-pdf',
i18nKey: 'translatePdf',
component: () => import('@/components/tools/TranslatePdf'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'Languages',
iconColor: 'text-purple-600',
bgColor: 'bg-purple-50',
demandTier: 'A',
},
{
slug: 'extract-tables',
i18nKey: 'tableExtractor',
component: () => import('@/components/tools/TableExtractor'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'Table',
iconColor: 'text-teal-600',
bgColor: 'bg-teal-50',
demandTier: 'B',
},
// ─── Utility ───────────────────────────────────────────────────
{
slug: 'qr-code',
i18nKey: 'qrCode',
component: () => import('@/components/tools/QrCodeGenerator'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'QrCode',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'B',
},
{
slug: 'barcode-generator',
i18nKey: 'barcode',
component: () => import('@/components/tools/BarcodeGenerator'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Barcode',
iconColor: 'text-gray-600',
bgColor: 'bg-gray-50',
demandTier: 'B',
},
{
slug: 'video-to-gif',
i18nKey: 'videoToGif',
component: () => import('@/components/tools/VideoToGif'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Film',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'B',
},
{
slug: 'word-counter',
i18nKey: 'wordCounter',
component: () => import('@/components/tools/WordCounter'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Hash',
iconColor: 'text-blue-600',
bgColor: 'bg-blue-50',
demandTier: 'C',
},
{
slug: 'text-cleaner',
i18nKey: 'textCleaner',
component: () => import('@/components/tools/TextCleaner'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Eraser',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'C',
},
] as const;
// ── Derived helpers ────────────────────────────────────────────────
/** All tool slugs — usable by routes.ts, sitemap, etc. */
export function getManifestSlugs(): string[] {
return TOOL_MANIFEST.map((t) => t.slug);
}
/** Tools visible on the homepage, split by section */
export function getHomepageTools(section: 'pdf' | 'other'): readonly ToolEntry[] {
return TOOL_MANIFEST.filter((t) => t.homepage && t.homepageSection === section);
}
/** Lookup a single tool by slug */
export function getToolEntry(slug: string): ToolEntry | undefined {
return TOOL_MANIFEST.find((t) => t.slug === slug);
}
/** All tool route paths — for the route registry */
export function getManifestRoutePaths(): string[] {
return TOOL_MANIFEST.map((t) => `/tools/${t.slug}`);
}

View File

@@ -326,6 +326,18 @@
"وصول API"
],
"featureCompare": "مقارنة الميزات",
"features": {
"credits": "الرصيد لكل نافذة",
"apiAccess": "الوصول عبر API",
"apiRequests": "طلبات API",
"maxFileSize": "الحد الأقصى لحجم الملف",
"historyRetention": "حفظ السجل",
"allTools": "جميع الأدوات (44)",
"aiTools": "أدوات الذكاء الاصطناعي",
"priorityProcessing": "المعالجة ذات الأولوية",
"noAds": "بدون إعلانات",
"emailSupport": "دعم عبر البريد الإلكتروني"
},
"faqTitle": "الأسئلة الشائعة",
"faq": [
{
@@ -971,6 +983,8 @@
"webQuotaTitle": "مهام الويب هذا الشهر",
"apiQuotaTitle": "مهام API هذا الشهر",
"quotaPeriod": "الفترة",
"creditBalanceTitle": "رصيد الاستخدام",
"creditWindowResets": "يتجدد في",
"apiKeysTitle": "مفاتيح API",
"apiKeysSubtitle": "أدر مفاتيح B2B API. كل مفتاح يمنحك وصولاً متزامناً بمستوى برو لجميع الأدوات.",
"apiKeyNamePlaceholder": "اسم المفتاح (مثال: إنتاج)",
@@ -1017,6 +1031,17 @@
"downloadReady": "ملفك جاهز للتحميل.",
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة."
},
"downloadGate": {
"title": "سجّل لتحميل ملفك",
"subtitle": "ملفك جاهز. أنشئ حسابًا مجانيًا لتحميله.",
"benefit1": "حمّل ملفاتك المعالجة فورًا",
"benefit2": "50 رصيدًا مجانيًا كل 30 يومًا",
"benefit3": "الوصول إلى جميع الأدوات بدون قيود",
"createAccount": "إنشاء حساب مجاني",
"signIn": "لديك حساب بالفعل؟ سجّل الدخول",
"switchToRegister": "ليس لديك حساب؟ أنشئ واحدًا",
"downloadCta": "سجّل لتحميل الملف"
},
"seo": {
"headings": {
"whatItDoes": "ما تفعله هذه الأداة",

View File

@@ -326,6 +326,18 @@
"API access"
],
"featureCompare": "Feature Comparison",
"features": {
"credits": "Credits per window",
"apiAccess": "API access",
"apiRequests": "API requests",
"maxFileSize": "Max file size",
"historyRetention": "History retention",
"allTools": "All 44 tools",
"aiTools": "AI tools included",
"priorityProcessing": "Priority processing",
"noAds": "No ads",
"emailSupport": "Email support"
},
"faqTitle": "Frequently Asked Questions",
"faq": [
{
@@ -971,6 +983,8 @@
"webQuotaTitle": "Web Tasks This Month",
"apiQuotaTitle": "API Tasks This Month",
"quotaPeriod": "Period",
"creditBalanceTitle": "Credit Balance",
"creditWindowResets": "Resets on",
"apiKeysTitle": "API Keys",
"apiKeysSubtitle": "Manage your B2B API keys. Each key gives Pro-level async access to all tools.",
"apiKeyNamePlaceholder": "Key name (e.g. Production)",
@@ -1017,6 +1031,17 @@
"downloadReady": "Your file is ready for download.",
"linkExpiry": "Download link expires in 30 minutes."
},
"downloadGate": {
"title": "Sign up to download your file",
"subtitle": "Your file is processed and ready. Create a free account to download it.",
"benefit1": "Download your processed files instantly",
"benefit2": "50 free credits every 30 days",
"benefit3": "Access to all tools with no restrictions",
"createAccount": "Create Free Account",
"signIn": "Already have an account? Sign in",
"switchToRegister": "Don't have an account? Create one",
"downloadCta": "Sign up to download"
},
"seo": {
"headings": {
"whatItDoes": "What This Tool Does",

View File

@@ -326,6 +326,18 @@
"Accès API"
],
"featureCompare": "Comparaison des fonctionnalités",
"features": {
"credits": "Crédits par fenêtre",
"apiAccess": "Accès API",
"apiRequests": "Requêtes API",
"maxFileSize": "Taille max. de fichier",
"historyRetention": "Conservation de l'historique",
"allTools": "Tous les 44 outils",
"aiTools": "Outils IA inclus",
"priorityProcessing": "Traitement prioritaire",
"noAds": "Sans publicité",
"emailSupport": "Support par e-mail"
},
"faqTitle": "Questions fréquentes",
"faq": [
{
@@ -971,6 +983,8 @@
"webQuotaTitle": "Tâches web ce mois-ci",
"apiQuotaTitle": "Tâches API ce mois-ci",
"quotaPeriod": "Période",
"creditBalanceTitle": "Solde de crédits",
"creditWindowResets": "Se renouvelle le",
"apiKeysTitle": "Clés API",
"apiKeysSubtitle": "Gérez vos clés API B2B. Chaque clé donne un accès asynchrone Pro à tous les outils.",
"apiKeyNamePlaceholder": "Nom de la clé (ex. Production)",
@@ -1017,6 +1031,17 @@
"downloadReady": "Votre fichier est prêt à être téléchargé.",
"linkExpiry": "Le lien de téléchargement expire dans 30 minutes."
},
"downloadGate": {
"title": "Inscrivez-vous pour télécharger votre fichier",
"subtitle": "Votre fichier est traité et prêt. Créez un compte gratuit pour le télécharger.",
"benefit1": "Téléchargez vos fichiers traités instantanément",
"benefit2": "50 crédits gratuits tous les 30 jours",
"benefit3": "Accès à tous les outils sans restrictions",
"createAccount": "Créer un compte gratuit",
"signIn": "Vous avez déjà un compte ? Connectez-vous",
"switchToRegister": "Pas de compte ? Créez-en un",
"downloadCta": "Inscrivez-vous pour télécharger"
},
"seo": {
"headings": {
"whatItDoes": "Ce que fait cet outil",

View File

@@ -356,28 +356,30 @@ export default function AccountPage() {
</div>
</section>
{/* Usage / Quota Cards */}
{usage && (
{/* Credit Balance Cards */}
{usage && usage.credits && (
<section className="grid gap-4 sm:grid-cols-2">
<div className="card rounded-[1.5rem] p-5">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
{t('account.webQuotaTitle')}
{t('account.creditBalanceTitle')}
</p>
<p className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
{usage.web_quota.used}
<span className="text-base font-normal text-slate-400"> / {usage.web_quota.limit ?? '∞'}</span>
{usage.credits.credits_remaining}
<span className="text-base font-normal text-slate-400"> / {usage.credits.credits_allocated}</span>
</p>
{usage.web_quota.limit != null && (
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${Math.min(100, (usage.web_quota.used / usage.web_quota.limit) * 100)}%` }}
/>
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${Math.min(100, (usage.credits.credits_used / usage.credits.credits_allocated) * 100)}%` }}
/>
</div>
{usage.credits.window_end && (
<p className="mt-2 text-xs text-slate-400">
{t('account.creditWindowResets')}: {new Date(usage.credits.window_end).toLocaleDateString()}
</p>
)}
<p className="mt-2 text-xs text-slate-400">{t('account.quotaPeriod')}: {usage.period_month}</p>
</div>
{usage.api_quota.limit != null && (
{usage.api_quota?.limit != null && (
<div className="card rounded-[1.5rem] p-5">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
{t('account.apiQuotaTitle')}
@@ -392,7 +394,6 @@ export default function AccountPage() {
style={{ width: `${Math.min(100, (usage.api_quota.used / usage.api_quota.limit) * 100)}%` }}
/>
</div>
<p className="mt-2 text-xs text-slate-400">{t('account.quotaPeriod')}: {usage.period_month}</p>
</div>
)}
</section>

View File

@@ -33,11 +33,31 @@ import {
Table,
Search,
X,
Crop,
FileDown,
Wrench,
Presentation,
Barcode,
} from 'lucide-react';
import ToolCard from '@/components/shared/ToolCard';
import HeroUploadZone from '@/components/shared/HeroUploadZone';
import AdSlot from '@/components/layout/AdSlot';
import SocialProofStrip from '@/components/shared/SocialProofStrip';
import { getHomepageTools, type ToolEntry } from '@/config/toolManifest';
// Map icon names from manifest to lucide components
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
FileText, FileOutput, Minimize2, ImageIcon, Film, Hash, Eraser, Layers,
Scissors, RotateCw, Image, FileImage, Droplets, Lock, Unlock, ListOrdered,
PenLine, GitBranch, Scaling, ScanText, Sheet, ArrowUpDown, QrCode, Code,
MessageSquare, Languages, Table, Crop, FileDown, Wrench, Presentation, Barcode,
};
function renderToolIcon(tool: ToolEntry) {
const IconComponent = ICON_MAP[tool.iconName];
if (!IconComponent) return null;
return <IconComponent className={`h-6 w-6 ${tool.iconColor}`} />;
}
interface ToolInfo {
key: string;
@@ -46,44 +66,17 @@ interface ToolInfo {
bgColor: string;
}
const pdfTools: ToolInfo[] = [
{ key: 'pdfEditor', path: '/tools/pdf-editor', icon: <PenLine className="h-6 w-6 text-rose-600" />, bgColor: 'bg-rose-50' },
{ key: 'pdfToWord', path: '/tools/pdf-to-word', icon: <FileText className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
{ key: 'wordToPdf', path: '/tools/word-to-pdf', icon: <FileOutput className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
{ key: 'compressPdf', path: '/tools/compress-pdf', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' },
{ key: 'mergePdf', path: '/tools/merge-pdf', icon: <Layers className="h-6 w-6 text-violet-600" />, bgColor: 'bg-violet-50' },
{ key: 'splitPdf', path: '/tools/split-pdf', icon: <Scissors className="h-6 w-6 text-pink-600" />, bgColor: 'bg-pink-50' },
{ key: 'rotatePdf', path: '/tools/rotate-pdf', icon: <RotateCw className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' },
{ key: 'pdfToImages', path: '/tools/pdf-to-images', icon: <Image className="h-6 w-6 text-amber-600" />, bgColor: 'bg-amber-50' },
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: <FileImage className="h-6 w-6 text-lime-600" />, bgColor: 'bg-lime-50' },
{ key: 'watermarkPdf', path: '/tools/watermark-pdf', icon: <Droplets className="h-6 w-6 text-cyan-600" />, bgColor: 'bg-cyan-50' },
{ key: 'protectPdf', path: '/tools/protect-pdf', icon: <Lock className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
{ key: 'unlockPdf', path: '/tools/unlock-pdf', icon: <Unlock className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' },
{ key: 'pageNumbers', path: '/tools/page-numbers', icon: <ListOrdered className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' },
{ key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: <GitBranch className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
{ key: 'pdfToExcel', path: '/tools/pdf-to-excel', icon: <Sheet className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' },
{ key: 'removeWatermark', path: '/tools/remove-watermark-pdf', icon: <Droplets className="h-6 w-6 text-rose-600" />, bgColor: 'bg-rose-50' },
{ key: 'reorderPdf', path: '/tools/reorder-pdf', icon: <ArrowUpDown className="h-6 w-6 text-violet-600" />, bgColor: 'bg-violet-50' },
{ key: 'extractPages', path: '/tools/extract-pages', icon: <FileOutput className="h-6 w-6 text-amber-600" />, bgColor: 'bg-amber-50' },
{ key: 'chatPdf', path: '/tools/chat-pdf', icon: <MessageSquare className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
{ key: 'summarizePdf', path: '/tools/summarize-pdf', icon: <FileText className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
{ key: 'translatePdf', path: '/tools/translate-pdf', icon: <Languages className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
{ key: 'tableExtractor', path: '/tools/extract-tables', icon: <Table className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' },
];
function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] {
return tools.map((t) => ({
key: t.i18nKey,
path: `/tools/${t.slug}`,
icon: renderToolIcon(t),
bgColor: t.bgColor,
}));
}
const otherTools: ToolInfo[] = [
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
{ key: 'imageResize', path: '/tools/image-resize', icon: <Scaling className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' },
{ key: 'compressImage', path: '/tools/compress-image', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' },
{ key: 'ocr', path: '/tools/ocr', icon: <ScanText className="h-6 w-6 text-amber-600" />, bgColor: 'bg-amber-50' },
{ key: 'removeBg', path: '/tools/remove-background', icon: <Eraser className="h-6 w-6 text-fuchsia-600" />, bgColor: 'bg-fuchsia-50' },
{ key: 'imageToSvg', path: '/tools/image-to-svg', icon: <ImageIcon className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
{ key: 'qrCode', path: '/tools/qr-code', icon: <QrCode className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
{ key: 'htmlToPdf', path: '/tools/html-to-pdf', icon: <Code className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' },
{ key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
{ key: 'textCleaner', path: '/tools/text-cleaner', icon: <Eraser className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
];
const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf'));
const otherTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('other'));
export default function HomePage() {
const { t } = useTranslation();

View File

@@ -18,7 +18,7 @@ interface PlanFeature {
}
const FEATURES: PlanFeature[] = [
{ key: 'webRequests', free: '50/month', pro: '500/month' },
{ key: 'credits', free: '50 credits/30 days', pro: '500 credits/30 days' },
{ key: 'apiAccess', free: false, pro: true },
{ key: 'apiRequests', free: '—', pro: '1,000/month' },
{ key: 'maxFileSize', free: '50 MB', pro: '100 MB' },

View File

@@ -479,6 +479,17 @@ export async function logoutUser(): Promise<void> {
await ensureCsrfToken(true);
}
/**
* Claim an anonymous task into the authenticated user's history.
*/
export async function claimTask(taskId: string, tool: string): Promise<{ claimed: boolean }> {
const response = await api.post<{ claimed: boolean }>('/account/claim-task', {
task_id: taskId,
tool,
});
return response.data;
}
/**
* Return the current authenticated user, if any.
*/
@@ -965,9 +976,18 @@ export async function updateAdminUserRole(userId: number, role: string): Promise
// --- Account / Usage / API Keys ---
export interface CreditInfo {
credits_allocated: number;
credits_used: number;
credits_remaining: number;
window_start: string | null;
window_end: string | null;
plan: string;
}
export interface UsageSummary {
plan: string;
period_month: string;
period_month?: string;
ads_enabled: boolean;
history_limit: number;
file_limits_mb: {
@@ -977,8 +997,10 @@ export interface UsageSummary {
video: number;
homepageSmartUpload: number;
};
credits: CreditInfo;
tool_costs: Record<string, number>;
web_quota: { used: number; limit: number | null };
api_quota: { used: number; limit: number | null };
api_quota?: { used: number; limit: number | null };
}
export interface ApiKey {

View File

@@ -10,6 +10,8 @@ export interface ToolSeoData {
ratingValue?: number;
ratingCount?: number;
features?: string[];
/** Optional HowTo steps for inline HowTo within the tool schema */
howToSteps?: string[];
}
export interface LanguageAlternate {
@@ -81,12 +83,23 @@ export function generateToolSchema(tool: ToolSeoData): object {
operatingSystem: 'Any',
browserRequirements: 'Requires JavaScript. Works in modern browsers.',
isAccessibleForFree: true,
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
offers: [
{
'@type': 'Offer',
name: 'Free',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Pro',
price: '9.99',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
description: 'Pro plan — higher limits, no ads, API access',
},
],
description: tool.description,
inLanguage: ['en', 'ar', 'fr'],
provider: {
@@ -94,6 +107,12 @@ export function generateToolSchema(tool: ToolSeoData): object {
name: DEFAULT_SITE_NAME,
url: getSiteOrigin(),
},
potentialAction: {
'@type': 'UseAction',
target: tool.url,
name: `Use ${tool.name}`,
},
screenshot: `${getSiteOrigin()}/social-preview.svg`,
};
if (tool.features && tool.features.length > 0) {