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:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
164
frontend/src/components/shared/SignUpToDownloadModal.tsx
Normal file
164
frontend/src/components/shared/SignUpToDownloadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
114
frontend/src/config/toolManifest.test.ts
Normal file
114
frontend/src/config/toolManifest.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
601
frontend/src/config/toolManifest.ts
Normal file
601
frontend/src/config/toolManifest.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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": "ما تفعله هذه الأداة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user