تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:
تحويل لوحة الإدارة الداخلية من secret header إلى session auth حقيقي مع صلاحيات admin. إضافة دعم إدارة الأدوار من داخل لوحة الإدارة نفسها، مع حماية الحسابات المعتمدة عبر INTERNAL_ADMIN_EMAILS. تحسين بيانات المستخدم في الواجهة والباكند لتشمل role وis_allowlisted_admin. إضافة اختبار frontend مخصص لصفحة /internal/admin بدل الاعتماد فقط على build واختبار routes. تحسين إضافي في الأداء عبر إزالة الاعتماد على pdfjs-dist/pdf.worker في عدّ صفحات PDF واستبداله بمسار أخف باستخدام pdf-lib. تحسين تقسيم الـ chunks في build لتقليل أثر الحزم الكبيرة وفصل أجزاء مثل network, icons, pdf-core, وeditor. التحقق الذي تم: نجاح build للواجهة. نجاح اختبار صفحة الإدارة الداخلية في frontend. نجاح اختبارات auth/admin في backend. نجاح full backend suite مسبقًا مع EXIT:0. ولو تريد نسخة أقصر جدًا، استخدم هذه: آخر التحديثات: تم تحسين نظام الإدارة الداخلية ليعتمد على صلاحيات وجلسات حقيقية بدل secret header، مع إضافة إدارة أدوار من لوحة admin نفسها، وإضافة اختبارات frontend مخصصة للوحة، وتحسين أداء الواجهة عبر إزالة pdf.worker وتحسين تقسيم الـ chunks في build. جميع الاختبارات والتحققات الأساسية المطلوبة نجح
This commit is contained in:
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
Copy,
|
||||
@@ -33,11 +35,27 @@ const toolKeyMap: Record<string, string> = {
|
||||
'pdf-to-word': 'tools.pdfToWord.title',
|
||||
'word-to-pdf': 'tools.wordToPdf.title',
|
||||
'compress-pdf': 'tools.compressPdf.title',
|
||||
'compress-image': 'tools.compressImage.title',
|
||||
'crop-pdf': 'tools.cropPdf.title',
|
||||
'crop-image': 'tools.imageCrop.title',
|
||||
'edit-metadata': 'tools.pdfMetadata.title',
|
||||
'excel-to-pdf': 'tools.excelToPdf.title',
|
||||
'extract-pages': 'tools.extractPages.title',
|
||||
'extract-tables': 'tools.tableExtractor.title',
|
||||
'flatten-pdf': 'tools.flattenPdf.title',
|
||||
'html-to-pdf': 'tools.htmlToPdf.title',
|
||||
'image-convert': 'tools.imageConvert.title',
|
||||
'image-converter': 'tools.imageConvert.title',
|
||||
'image-crop': 'tools.imageCrop.title',
|
||||
'image-resize': 'tools.imageConvert.title',
|
||||
'image-rotate-flip': 'tools.imageRotateFlip.title',
|
||||
'video-to-gif': 'tools.videoToGif.title',
|
||||
'merge-pdf': 'tools.mergePdf.title',
|
||||
'ocr': 'tools.ocr.title',
|
||||
'split-pdf': 'tools.splitPdf.title',
|
||||
'pdf-metadata': 'tools.pdfMetadata.title',
|
||||
'pdf-to-excel': 'tools.pdfToExcel.title',
|
||||
'pdf-to-pptx': 'tools.pdfToPptx.title',
|
||||
'rotate-pdf': 'tools.rotatePdf.title',
|
||||
'page-numbers': 'tools.pageNumbers.title',
|
||||
'pdf-to-images': 'tools.pdfToImages.title',
|
||||
@@ -45,8 +63,21 @@ const toolKeyMap: Record<string, string> = {
|
||||
'watermark-pdf': 'tools.watermarkPdf.title',
|
||||
'protect-pdf': 'tools.protectPdf.title',
|
||||
'unlock-pdf': 'tools.unlockPdf.title',
|
||||
'repair-pdf': 'tools.repairPdf.title',
|
||||
'remove-background': 'tools.removeBg.title',
|
||||
'remove-bg': 'tools.removeBg.title',
|
||||
'remove-watermark-pdf': 'tools.removeWatermark.title',
|
||||
'reorder-pdf': 'tools.reorderPdf.title',
|
||||
'sign-pdf': 'tools.signPdf.title',
|
||||
'summarize-pdf': 'tools.summarizePdf.title',
|
||||
'translate-pdf': 'tools.translatePdf.title',
|
||||
'chat-pdf': 'tools.chatPdf.title',
|
||||
'barcode': 'tools.barcode.title',
|
||||
'barcode-generator': 'tools.barcode.title',
|
||||
'pptx-to-pdf': 'tools.pptxToPdf.title',
|
||||
'pdf-flowchart': 'tools.pdfFlowchart.title',
|
||||
'pdf-flowchart-sample': 'tools.pdfFlowchart.title',
|
||||
'qr-code': 'tools.qrCode.title',
|
||||
};
|
||||
|
||||
function formatHistoryTool(tool: string, t: (key: string) => string) {
|
||||
@@ -93,6 +124,50 @@ export default function AccountPage() {
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
const dashboardMetrics = useMemo(() => {
|
||||
const completedItems = historyItems.filter((item) => item.status === 'completed');
|
||||
const failedItems = historyItems.filter((item) => item.status !== 'completed');
|
||||
const toolCounts = historyItems.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.tool] = (acc[item.tool] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const favoriteToolSlug = Object.entries(toolCounts)
|
||||
.sort((left, right) => right[1] - left[1])[0]?.[0] || null;
|
||||
|
||||
return {
|
||||
totalProcessed: historyItems.length,
|
||||
completedCount: completedItems.length,
|
||||
failedCount: failedItems.length,
|
||||
favoriteToolSlug,
|
||||
successRate: historyItems.length ? Math.round((completedItems.length / historyItems.length) * 100) : 0,
|
||||
topTools: Object.entries(toolCounts)
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, 4),
|
||||
recentFailures: failedItems.slice(0, 3),
|
||||
onboardingItems: [
|
||||
{
|
||||
key: 'firstTask',
|
||||
done: historyItems.length > 0,
|
||||
title: t('account.onboardingFirstTaskTitle'),
|
||||
description: t('account.onboardingFirstTaskDesc'),
|
||||
},
|
||||
{
|
||||
key: 'upgrade',
|
||||
done: user?.plan === 'pro',
|
||||
title: t('account.onboardingUpgradeTitle'),
|
||||
description: t('account.onboardingUpgradeDesc'),
|
||||
},
|
||||
{
|
||||
key: 'apiKey',
|
||||
done: user?.plan !== 'pro' ? false : apiKeys.some((key) => !key.revoked_at),
|
||||
title: t('account.onboardingApiTitle'),
|
||||
description: t('account.onboardingApiDesc'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [apiKeys, historyItems, t, user?.plan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHistoryItems([]);
|
||||
@@ -314,6 +389,106 @@ export default function AccountPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card rounded-[2rem] p-0">
|
||||
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('account.dashboardTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('account.dashboardSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[1.5rem] bg-slate-50 p-5 dark:bg-slate-800/80">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{t('account.metricProcessed')}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{dashboardMetrics.totalProcessed}</p>
|
||||
</div>
|
||||
<div className="rounded-[1.5rem] bg-slate-50 p-5 dark:bg-slate-800/80">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{t('account.metricSuccessRate')}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{dashboardMetrics.successRate}%</p>
|
||||
</div>
|
||||
<div className="rounded-[1.5rem] bg-slate-50 p-5 dark:bg-slate-800/80">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{t('account.metricFavoriteTool')}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{dashboardMetrics.favoriteToolSlug
|
||||
? formatHistoryTool(dashboardMetrics.favoriteToolSlug, t)
|
||||
: t('account.metricFavoriteToolEmpty')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[1.5rem] bg-slate-50 p-5 dark:bg-slate-800/80">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{t('account.metricFailures')}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{dashboardMetrics.failedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_1fr_1.1fr]">
|
||||
<div className="rounded-[1.5rem] border border-slate-200 p-5 dark:border-slate-700">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{t('account.topToolsTitle')}</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{dashboardMetrics.topTools.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('account.historyEmpty')}</p>
|
||||
) : (
|
||||
dashboardMetrics.topTools.map(([tool, count]) => (
|
||||
<div key={tool} className="flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3 dark:bg-slate-800/70">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">{formatHistoryTool(tool, t)}</span>
|
||||
<span className="text-sm font-semibold text-primary-600 dark:text-primary-400">{count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-slate-200 p-5 dark:border-slate-700">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{t('account.issuesTitle')}</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{dashboardMetrics.recentFailures.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('account.issuesEmpty')}</p>
|
||||
) : (
|
||||
dashboardMetrics.recentFailures.map((item) => (
|
||||
<div key={item.id} className="rounded-xl bg-red-50 px-4 py-3 dark:bg-red-950/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-800 dark:text-red-300">{formatHistoryTool(item.tool, t)}</p>
|
||||
<p className="mt-1 text-xs text-red-700 dark:text-red-400">{typeof item.metadata?.error === 'string' ? item.metadata.error : t('account.statusFailed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-slate-200 p-5 dark:border-slate-700">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{t('account.onboardingTitle')}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t('account.onboardingSubtitle')}</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{dashboardMetrics.onboardingItems.map((item) => (
|
||||
<div key={item.key} className="rounded-xl bg-slate-50 px-4 py-3 dark:bg-slate-800/70">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full ${item.done ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'}`}>
|
||||
{item.done ? <Check className="h-3.5 w-3.5" /> : <span className="text-[10px] font-bold">•</span>}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{item.title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key Management — Pro only */}
|
||||
{user.plan === 'pro' && (
|
||||
<section className="card rounded-[2rem] p-0">
|
||||
|
||||
@@ -1,57 +1,41 @@
|
||||
import { useDeferredValue } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateWebPage } from '@/utils/seo';
|
||||
import { BookOpen, Calendar, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
titleKey: string;
|
||||
excerptKey: string;
|
||||
date: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const BLOG_POSTS: BlogPost[] = [
|
||||
{
|
||||
slug: 'how-to-compress-pdf-online',
|
||||
titleKey: 'pages.blog.posts.compressPdf.title',
|
||||
excerptKey: 'pages.blog.posts.compressPdf.excerpt',
|
||||
date: '2025-01-15',
|
||||
category: 'PDF',
|
||||
},
|
||||
{
|
||||
slug: 'convert-images-without-losing-quality',
|
||||
titleKey: 'pages.blog.posts.imageConvert.title',
|
||||
excerptKey: 'pages.blog.posts.imageConvert.excerpt',
|
||||
date: '2025-01-10',
|
||||
category: 'Image',
|
||||
},
|
||||
{
|
||||
slug: 'ocr-extract-text-from-images',
|
||||
titleKey: 'pages.blog.posts.ocrGuide.title',
|
||||
excerptKey: 'pages.blog.posts.ocrGuide.excerpt',
|
||||
date: '2025-01-05',
|
||||
category: 'AI',
|
||||
},
|
||||
{
|
||||
slug: 'merge-split-pdf-files',
|
||||
titleKey: 'pages.blog.posts.mergeSplit.title',
|
||||
excerptKey: 'pages.blog.posts.mergeSplit.excerpt',
|
||||
date: '2024-12-28',
|
||||
category: 'PDF',
|
||||
},
|
||||
{
|
||||
slug: 'ai-chat-with-pdf-documents',
|
||||
titleKey: 'pages.blog.posts.aiChat.title',
|
||||
excerptKey: 'pages.blog.posts.aiChat.excerpt',
|
||||
date: '2024-12-20',
|
||||
category: 'AI',
|
||||
},
|
||||
];
|
||||
import { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react';
|
||||
import {
|
||||
BLOG_ARTICLES,
|
||||
getLocalizedBlogArticle,
|
||||
normalizeBlogLocale,
|
||||
} from '@/content/blogArticles';
|
||||
|
||||
export default function BlogPage() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
||||
const locale = normalizeBlogLocale(i18n.language);
|
||||
|
||||
const posts = BLOG_ARTICLES.map((article) => getLocalizedBlogArticle(article, locale));
|
||||
|
||||
const filteredPosts = !deferredQuery
|
||||
? posts
|
||||
: posts.filter((post) => {
|
||||
const haystack = `${post.title} ${post.excerpt} ${post.category}`.toLowerCase();
|
||||
return haystack.includes(deferredQuery);
|
||||
});
|
||||
|
||||
const updateQuery = (value: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
if (value.trim()) {
|
||||
nextParams.set('q', value);
|
||||
} else {
|
||||
nextParams.delete('q');
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -79,8 +63,32 @@ export default function BlogPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
placeholder={t('pages.blog.searchPlaceholder')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-3 pl-10 pr-4 text-sm text-slate-900 outline-none transition-colors focus:border-primary-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-primary-500"
|
||||
/>
|
||||
</label>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery('')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{BLOG_POSTS.map((post) => (
|
||||
{filteredPosts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
|
||||
@@ -91,15 +99,15 @@ export default function BlogPage() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{post.date}
|
||||
{post.publishedAt}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t(post.titleKey)}
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="mb-4 text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{t(post.excerptKey)}
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
@@ -112,6 +120,14 @@ export default function BlogPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPosts.length === 0 && (
|
||||
<div className="mt-10 rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center dark:border-slate-600 dark:bg-slate-800/50">
|
||||
<p className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('pages.blog.noResults')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coming Soon */}
|
||||
<div className="mt-10 rounded-xl border-2 border-dashed border-slate-300 bg-slate-50 p-8 text-center dark:border-slate-600 dark:bg-slate-800/50">
|
||||
<p className="text-lg font-medium text-slate-600 dark:text-slate-400">
|
||||
|
||||
189
frontend/src/pages/BlogPostPage.tsx
Normal file
189
frontend/src/pages/BlogPostPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Calendar, ChevronLeft, Clock } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import {
|
||||
BLOG_ARTICLES,
|
||||
getBlogArticleBySlug,
|
||||
getLocalizedBlogArticle,
|
||||
normalizeBlogLocale,
|
||||
} from '@/content/blogArticles';
|
||||
import { generateBlogPosting, generateBreadcrumbs, generateWebPage } from '@/utils/seo';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const { slug } = useParams();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = normalizeBlogLocale(i18n.language);
|
||||
const article = slug ? getBlogArticleBySlug(slug) : undefined;
|
||||
|
||||
if (!article) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
const localizedArticle = getLocalizedBlogArticle(article, locale);
|
||||
const path = `/blog/${localizedArticle.slug}`;
|
||||
const url = `${window.location.origin}${path}`;
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs([
|
||||
{ name: t('common.home'), url: window.location.origin },
|
||||
{ name: t('common.blog'), url: `${window.location.origin}/blog` },
|
||||
{ name: localizedArticle.title, url },
|
||||
]);
|
||||
|
||||
const relatedArticles = BLOG_ARTICLES
|
||||
.filter((candidate) => candidate.slug !== article.slug)
|
||||
.slice(0, 3)
|
||||
.map((candidate) => getLocalizedBlogArticle(candidate, locale));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead
|
||||
title={localizedArticle.title}
|
||||
description={localizedArticle.seoDescription}
|
||||
path={path}
|
||||
type="article"
|
||||
jsonLd={[
|
||||
generateWebPage({
|
||||
name: localizedArticle.title,
|
||||
description: localizedArticle.seoDescription,
|
||||
url,
|
||||
}),
|
||||
generateBlogPosting({
|
||||
headline: localizedArticle.title,
|
||||
description: localizedArticle.seoDescription,
|
||||
url,
|
||||
datePublished: localizedArticle.publishedAt,
|
||||
inLanguage: locale,
|
||||
}),
|
||||
breadcrumbs,
|
||||
]}
|
||||
/>
|
||||
|
||||
<article className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
to="/blog"
|
||||
className="mb-6 inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('pages.blog.backToBlog')}
|
||||
</Link>
|
||||
|
||||
<header className="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span className="rounded-full bg-primary-100 px-3 py-1 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{localizedArticle.category}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{localizedArticle.publishedAt}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t('pages.blog.readTime', { count: localizedArticle.readingMinutes })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
{localizedArticle.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||
{localizedArticle.excerpt}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.blog.keyTakeaways')}
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{localizedArticle.keyTakeaways.map((item) => (
|
||||
<li key={item} className="text-sm leading-6 text-slate-700 dark:text-slate-300">
|
||||
• {item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{localizedArticle.sections.map((section) => (
|
||||
<section key={section.heading} className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{section.heading}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="leading-8 text-slate-700 dark:text-slate-300">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{section.bullets.length > 0 && (
|
||||
<ul className="mt-5 space-y-3 rounded-2xl bg-slate-50 p-5 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{section.bullets.map((bullet) => (
|
||||
<li key={bullet}>• {bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('pages.blog.featuredTools')}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{localizedArticle.toolSlugs.map((toolSlug) => {
|
||||
const tool = getToolSEO(toolSlug);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={toolSlug}
|
||||
to={`/tools/${toolSlug}`}
|
||||
className="block rounded-xl border border-slate-200 p-4 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
<p className="font-medium text-slate-900 dark:text-white">
|
||||
{t(`tools.${tool.i18nKey}.title`)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
{t(`tools.${tool.i18nKey}.shortDesc`)}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t('common.blog')}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{relatedArticles.map((relatedArticle) => (
|
||||
<Link
|
||||
key={relatedArticle.slug}
|
||||
to={`/blog/${relatedArticle.slug}`}
|
||||
className="block rounded-xl border border-slate-200 p-4 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
<p className="font-medium text-slate-900 dark:text-white">
|
||||
{relatedArticle.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
{relatedArticle.excerpt}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Mail, Send, CheckCircle } from 'lucide-react';
|
||||
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateWebPage } from '@/utils/seo';
|
||||
import axios from 'axios';
|
||||
|
||||
const CONTACT_EMAIL = 'support@saas-pdf.com';
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
type Category = 'general' | 'bug' | 'feature';
|
||||
|
||||
@@ -13,21 +15,37 @@ export default function ContactPage() {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<Category>('general');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const placeholderKey = `pages.contact.${category}Placeholder` as const;
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const subject = data.get('subject') as string;
|
||||
const body = data.get('message') as string;
|
||||
const name = data.get('name') as string;
|
||||
|
||||
// Open user's email client with pre-filled fields
|
||||
const mailto = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(`[${category}] ${subject}`)}&body=${encodeURIComponent(`From: ${name}\n\n${body}`)}`;
|
||||
window.location.href = mailto;
|
||||
setSubmitted(true);
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/contact/submit`, {
|
||||
name: data.get('name'),
|
||||
email: data.get('email'),
|
||||
category,
|
||||
subject: data.get('subject'),
|
||||
message: data.get('message'),
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err) && err.response?.data?.error) {
|
||||
setError(err.response.data.error);
|
||||
} else {
|
||||
setError(t('pages.contact.errorMessage', 'Failed to send message. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
@@ -156,13 +174,22 @@ export default function ContactPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition-colors hover:bg-primary-700"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{t('common.send')}
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{loading ? t('common.sending', 'Sending...') : t('common.send')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
125
frontend/src/pages/DevelopersPage.tsx
Normal file
125
frontend/src/pages/DevelopersPage.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||
import { generateWebPage } from '@/utils/seo';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react';
|
||||
|
||||
const QUICKSTART_STEPS = ['createKey', 'sendFile', 'pollStatus'] as const;
|
||||
|
||||
const ENDPOINT_GROUPS = [
|
||||
{
|
||||
titleKey: 'pages.developers.groupConvert',
|
||||
endpoints: ['/api/v1/convert/pdf-to-word', '/api/v1/convert/word-to-pdf', '/api/v1/convert/pdf-to-excel', '/api/v1/convert/pdf-to-pptx'],
|
||||
},
|
||||
{
|
||||
titleKey: 'pages.developers.groupPdf',
|
||||
endpoints: ['/api/v1/compress/pdf', '/api/v1/pdf-tools/merge', '/api/v1/pdf-tools/split', '/api/v1/pdf-tools/sign'],
|
||||
},
|
||||
{
|
||||
titleKey: 'pages.developers.groupAi',
|
||||
endpoints: ['/api/v1/pdf-ai/chat', '/api/v1/pdf-ai/summarize', '/api/v1/ocr/pdf', '/api/v1/image/remove-bg'],
|
||||
},
|
||||
];
|
||||
|
||||
const CURL_UPLOAD = `curl -X POST https://your-domain.example/api/v1/convert/pdf-to-word \\
|
||||
-H "X-API-Key: spdf_your_api_key" \\
|
||||
-F "file=@./sample.pdf"`;
|
||||
|
||||
const CURL_POLL = `curl https://your-domain.example/api/v1/tasks/<task_id>/status \\
|
||||
-H "X-API-Key: spdf_your_api_key"`;
|
||||
|
||||
export default function DevelopersPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead
|
||||
title={t('pages.developers.title')}
|
||||
description={t('pages.developers.metaDescription')}
|
||||
path="/developers"
|
||||
jsonLd={generateWebPage({
|
||||
name: t('pages.developers.title'),
|
||||
description: t('pages.developers.metaDescription'),
|
||||
url: `${window.location.origin}/developers`,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-6xl space-y-10">
|
||||
<section className="rounded-[2.5rem] bg-gradient-to-br from-sky-100 via-white to-emerald-50 p-8 shadow-sm ring-1 ring-sky-200 dark:from-sky-950/40 dark:via-slate-950 dark:to-emerald-950/20 dark:ring-sky-900/40 sm:p-10">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-semibold text-sky-900 ring-1 ring-sky-200 dark:bg-sky-400/10 dark:text-sky-200 dark:ring-sky-700/40">
|
||||
<Code2 className="h-4 w-4" />
|
||||
{t('pages.developers.badge')}
|
||||
</div>
|
||||
<h1 className="mt-5 text-3xl font-black tracking-tight text-slate-900 dark:text-white sm:text-5xl">
|
||||
{t('pages.developers.title')}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-300">
|
||||
{t('pages.developers.subtitle')}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Link to="/account" className="inline-flex items-center justify-center rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700">
|
||||
{t('pages.developers.getApiKey')}
|
||||
</Link>
|
||||
<Link to="/pricing" className="inline-flex items-center justify-center rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800">
|
||||
{t('pages.developers.comparePlans')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SocialProofStrip />
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-3">
|
||||
{QUICKSTART_STEPS.map((step, index) => {
|
||||
const Icon = step === 'createKey' ? KeyRound : step === 'sendFile' ? Rocket : Workflow;
|
||||
return (
|
||||
<article key={step} className="rounded-[1.75rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500">0{index + 1}</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-slate-900 dark:text-white">{t(`pages.developers.steps.${step}.title`)}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t(`pages.developers.steps.${step}.description`)}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-2">
|
||||
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.authExampleTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.authExampleSubtitle')}</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{CURL_UPLOAD}</code></pre>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.pollExampleTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.pollExampleSubtitle')}</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{CURL_POLL}</code></pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.endpointsTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.endpointsSubtitle')}</p>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
{ENDPOINT_GROUPS.map((group) => (
|
||||
<article key={group.titleKey} className="rounded-[1.5rem] bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{t(group.titleKey)}</h3>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{group.endpoints.map((endpoint) => (
|
||||
<li key={endpoint} className="rounded-xl bg-white px-3 py-2 font-mono text-xs text-slate-700 ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-200 dark:ring-slate-700">
|
||||
{endpoint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useDeferredValue } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateOrganization } from '@/utils/seo';
|
||||
import {
|
||||
@@ -29,10 +31,13 @@ import {
|
||||
MessageSquare,
|
||||
Languages,
|
||||
Table,
|
||||
Search,
|
||||
X,
|
||||
} 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';
|
||||
|
||||
interface ToolInfo {
|
||||
key: string;
|
||||
@@ -81,6 +86,31 @@ const otherTools: ToolInfo[] = [
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
||||
|
||||
const matchesTool = (tool: ToolInfo) => {
|
||||
if (!deferredQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${t(`tools.${tool.key}.title`)} ${t(`tools.${tool.key}.shortDesc`)}`.toLowerCase();
|
||||
return haystack.includes(deferredQuery);
|
||||
};
|
||||
|
||||
const filteredPdfTools = pdfTools.filter(matchesTool);
|
||||
const filteredOtherTools = otherTools.filter(matchesTool);
|
||||
|
||||
const updateQuery = (value: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
if (value.trim()) {
|
||||
nextParams.set('q', value);
|
||||
} else {
|
||||
nextParams.delete('q');
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -97,7 +127,7 @@ export default function HomePage() {
|
||||
description: t('home.heroSub'),
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${window.location.origin}/tools/{search_term_string}`,
|
||||
target: `${window.location.origin}/?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
@@ -123,13 +153,79 @@ export default function HomePage() {
|
||||
{/* Ad Slot */}
|
||||
<AdSlot slot="home-top" format="horizontal" className="mb-8" />
|
||||
|
||||
<SocialProofStrip className="mb-10" />
|
||||
|
||||
<section className="mb-10 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('common.search')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
{t('home.searchToolsPlaceholder')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row lg:max-w-2xl">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
placeholder={t('home.searchToolsPlaceholder')}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-3 pl-10 pr-4 text-sm text-slate-900 outline-none transition-colors focus:border-primary-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-primary-500"
|
||||
/>
|
||||
</label>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery('')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('common.developers')}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.developers.ctaTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||
{t('pages.developers.ctaSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
href="/developers"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
{t('pages.developers.openDocs')}
|
||||
</a>
|
||||
<a
|
||||
href="/account"
|
||||
className="inline-flex items-center justify-center rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('pages.developers.getApiKey')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<section>
|
||||
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('home.pdfTools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-10">
|
||||
{pdfTools.map((tool) => (
|
||||
{filteredPdfTools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
to={tool.path}
|
||||
@@ -145,7 +241,7 @@ export default function HomePage() {
|
||||
{t('home.otherTools', 'Other Tools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
|
||||
{otherTools.map((tool) => (
|
||||
{filteredOtherTools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
to={tool.path}
|
||||
@@ -156,6 +252,14 @@ export default function HomePage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPdfTools.length + filteredOtherTools.length === 0 && (
|
||||
<div className="mb-12 rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center dark:border-slate-600 dark:bg-slate-800/50">
|
||||
<p className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('home.noSearchResults')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features / Why Choose Us */}
|
||||
|
||||
141
frontend/src/pages/InternalAdminPage.test.tsx
Normal file
141
frontend/src/pages/InternalAdminPage.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import InternalAdminPage from './InternalAdminPage';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import {
|
||||
getInternalAdminContacts,
|
||||
getInternalAdminOverview,
|
||||
listInternalAdminUsers,
|
||||
markInternalAdminContactRead,
|
||||
updateInternalAdminUserPlan,
|
||||
updateInternalAdminUserRole,
|
||||
} from '@/services/api';
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
getInternalAdminContacts: vi.fn(),
|
||||
getInternalAdminOverview: vi.fn(),
|
||||
listInternalAdminUsers: vi.fn(),
|
||||
markInternalAdminContactRead: vi.fn(),
|
||||
updateInternalAdminUserPlan: vi.fn(),
|
||||
updateInternalAdminUserRole: vi.fn(),
|
||||
}));
|
||||
|
||||
const authState = {
|
||||
user: null as null | { email: string; role: string },
|
||||
initialized: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter>
|
||||
<InternalAdminPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('InternalAdminPage', () => {
|
||||
beforeEach(() => {
|
||||
authState.user = null;
|
||||
authState.initialized = true;
|
||||
authState.isLoading = false;
|
||||
authState.login = vi.fn();
|
||||
authState.logout = vi.fn();
|
||||
|
||||
((useAuthStore as unknown) as Mock).mockImplementation(
|
||||
(selector: (state: typeof authState) => unknown) => selector(authState)
|
||||
);
|
||||
(getInternalAdminOverview as Mock).mockReset();
|
||||
(listInternalAdminUsers as Mock).mockReset();
|
||||
(getInternalAdminContacts as Mock).mockReset();
|
||||
(markInternalAdminContactRead as Mock).mockReset();
|
||||
(updateInternalAdminUserPlan as Mock).mockReset();
|
||||
(updateInternalAdminUserRole as Mock).mockReset();
|
||||
});
|
||||
|
||||
it('shows the admin sign-in form for anonymous users', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText('Admin sign in')).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText('admin@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the permission warning for signed-in non-admin users', () => {
|
||||
authState.user = { email: 'member@example.com', role: 'user' };
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText('No admin permission')).toBeTruthy();
|
||||
expect(screen.getAllByText(/member@example.com/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('loads dashboard data for admins and allows promoting a user role', async () => {
|
||||
authState.user = { email: 'admin@example.com', role: 'admin' };
|
||||
(getInternalAdminOverview as Mock).mockResolvedValue({
|
||||
users: { total: 2, pro: 1, free: 1 },
|
||||
processing: {
|
||||
total_files_processed: 5,
|
||||
completed_files: 4,
|
||||
failed_files: 1,
|
||||
files_last_24h: 2,
|
||||
success_rate: 80,
|
||||
},
|
||||
ratings: { average_rating: 4.8, rating_count: 14 },
|
||||
ai_cost: { month: '2026-03', total_usd: 12.5, budget_usd: 50, percent_used: 25 },
|
||||
contacts: { total_messages: 1, unread_messages: 1, recent: [] },
|
||||
top_tools: [{ tool: 'compress-pdf', total_runs: 10, failed_runs: 1 }],
|
||||
recent_failures: [],
|
||||
recent_users: [],
|
||||
});
|
||||
(listInternalAdminUsers as Mock).mockResolvedValue([
|
||||
{
|
||||
id: 2,
|
||||
email: 'operator@example.com',
|
||||
plan: 'free',
|
||||
role: 'user',
|
||||
is_allowlisted_admin: false,
|
||||
created_at: '2026-03-16T10:00:00Z',
|
||||
total_tasks: 3,
|
||||
completed_tasks: 2,
|
||||
failed_tasks: 1,
|
||||
active_api_keys: 0,
|
||||
},
|
||||
]);
|
||||
(getInternalAdminContacts as Mock).mockResolvedValue({
|
||||
items: [],
|
||||
page: 1,
|
||||
per_page: 12,
|
||||
total: 0,
|
||||
unread: 0,
|
||||
});
|
||||
(updateInternalAdminUserRole as Mock).mockResolvedValue({
|
||||
id: 2,
|
||||
email: 'operator@example.com',
|
||||
plan: 'free',
|
||||
role: 'admin',
|
||||
created_at: '2026-03-16T10:00:00Z',
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Users and monetization')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set admin' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateInternalAdminUserRole).toHaveBeenCalledWith(2, 'admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
593
frontend/src/pages/InternalAdminPage.tsx
Normal file
593
frontend/src/pages/InternalAdminPage.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Inbox,
|
||||
LogOut,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getInternalAdminContacts,
|
||||
getInternalAdminOverview,
|
||||
listInternalAdminUsers,
|
||||
markInternalAdminContactRead,
|
||||
updateInternalAdminUserRole,
|
||||
updateInternalAdminUserPlan,
|
||||
type InternalAdminContact,
|
||||
type InternalAdminOverview,
|
||||
type InternalAdminUser,
|
||||
} from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default function InternalAdminPage() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const initialized = useAuthStore((state) => state.initialized);
|
||||
const authLoading = useAuthStore((state) => state.isLoading);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [overview, setOverview] = useState<InternalAdminOverview | null>(null);
|
||||
const [users, setUsers] = useState<InternalAdminUser[]>([]);
|
||||
const [contacts, setContacts] = useState<InternalAdminContact[]>([]);
|
||||
const [contactMeta, setContactMeta] = useState({ total: 0, unread: 0, page: 1, perPage: 12 });
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [updatingUserId, setUpdatingUserId] = useState<number | null>(null);
|
||||
const [updatingRoleUserId, setUpdatingRoleUserId] = useState<number | null>(null);
|
||||
const [markingMessageId, setMarkingMessageId] = useState<number | null>(null);
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
if (!overview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'users',
|
||||
title: 'Total users',
|
||||
value: overview.users.total.toLocaleString(),
|
||||
caption: `${overview.users.pro} pro / ${overview.users.free} free`,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
key: 'processing',
|
||||
title: 'Files processed',
|
||||
value: overview.processing.total_files_processed.toLocaleString(),
|
||||
caption: `${overview.processing.files_last_24h} in the last 24h`,
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
key: 'success',
|
||||
title: 'Success rate',
|
||||
value: `${overview.processing.success_rate}%`,
|
||||
caption: `${overview.processing.failed_files} failures tracked`,
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
key: 'contacts',
|
||||
title: 'Unread contacts',
|
||||
value: overview.contacts.unread_messages.toLocaleString(),
|
||||
caption: `${overview.contacts.total_messages} total inbox items`,
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
key: 'ai-cost',
|
||||
title: 'AI spend',
|
||||
value: formatMoney(overview.ai_cost.total_usd),
|
||||
caption: `${overview.ai_cost.percent_used}% of ${formatMoney(overview.ai_cost.budget_usd)} budget`,
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
key: 'ratings',
|
||||
title: 'Average rating',
|
||||
value: overview.ratings.average_rating.toFixed(1),
|
||||
caption: `${overview.ratings.rating_count} ratings collected`,
|
||||
icon: RefreshCcw,
|
||||
},
|
||||
];
|
||||
}, [overview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
setOverview(null);
|
||||
setUsers([]);
|
||||
setContacts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadDashboard(userQuery);
|
||||
}, [isAdmin]);
|
||||
|
||||
async function loadDashboard(query = '') {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [overviewData, usersData, contactsData] = await Promise.all([
|
||||
getInternalAdminOverview(),
|
||||
listInternalAdminUsers(query),
|
||||
getInternalAdminContacts(1, 12),
|
||||
]);
|
||||
|
||||
setOverview(overviewData);
|
||||
setUsers(usersData);
|
||||
setContacts(contactsData.items);
|
||||
setContactMeta({
|
||||
total: contactsData.total,
|
||||
unread: contactsData.unread,
|
||||
page: contactsData.page,
|
||||
perPage: contactsData.per_page,
|
||||
});
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load internal admin dashboard.');
|
||||
setOverview(null);
|
||||
setUsers([]);
|
||||
setContacts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoginError(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const authenticatedUser = await login(email, password);
|
||||
if (authenticatedUser.role !== 'admin') {
|
||||
setLoginError('This account does not have internal admin access.');
|
||||
}
|
||||
setPassword('');
|
||||
} catch (loginAttemptError) {
|
||||
setLoginError(loginAttemptError instanceof Error ? loginAttemptError.message : 'Unable to sign in.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
await loadDashboard(userQuery);
|
||||
}
|
||||
|
||||
async function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
await loadDashboard(userQuery);
|
||||
}
|
||||
|
||||
async function handlePlanChange(userId: number, plan: 'free' | 'pro') {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingUserId(userId);
|
||||
setError(null);
|
||||
try {
|
||||
await updateInternalAdminUserPlan(userId, plan);
|
||||
await loadDashboard(userQuery);
|
||||
} catch (updateError) {
|
||||
setError(updateError instanceof Error ? updateError.message : 'Unable to update plan.');
|
||||
} finally {
|
||||
setUpdatingUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkRead(messageId: number) {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMarkingMessageId(messageId);
|
||||
setError(null);
|
||||
try {
|
||||
await markInternalAdminContactRead(messageId);
|
||||
await loadDashboard(userQuery);
|
||||
} catch (markError) {
|
||||
setError(markError instanceof Error ? markError.message : 'Unable to update contact message.');
|
||||
} finally {
|
||||
setMarkingMessageId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleChange(userId: number, role: 'user' | 'admin') {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingRoleUserId(userId);
|
||||
setError(null);
|
||||
try {
|
||||
await updateInternalAdminUserRole(userId, role);
|
||||
await loadDashboard(userQuery);
|
||||
} catch (updateError) {
|
||||
setError(updateError instanceof Error ? updateError.message : 'Unable to update role.');
|
||||
} finally {
|
||||
setUpdatingRoleUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
setError(null);
|
||||
setLoginError(null);
|
||||
await logout();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-8">
|
||||
<Helmet>
|
||||
<title>Internal Admin | SaaS PDF</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</Helmet>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-300">
|
||||
Internal operations
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Admin control room
|
||||
</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||
This area now uses the normal app session plus admin permissions. Only signed-in allowlisted admins can
|
||||
inspect operations, edit plans, and process the support inbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<div className="flex flex-col items-start gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-950/50">
|
||||
<span className="font-semibold text-slate-900 dark:text-white">{user.email}</span>
|
||||
<span className="text-slate-600 dark:text-slate-300">Role: {user.role}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialized || authLoading ? (
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 text-sm text-slate-600 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300">
|
||||
Checking admin session...
|
||||
</section>
|
||||
) : !user ? (
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="max-w-lg">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Admin sign in</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||
Use an allowlisted internal account to start a normal authenticated session. Admin access is decided by
|
||||
server-side permissions, not a client-side secret.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="mt-6 grid gap-4 md:max-w-xl">
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
className="rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Password"
|
||||
className="rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30"
|
||||
/>
|
||||
{loginError && (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-2xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
Sign in as admin
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
) : !isAdmin ? (
|
||||
<section className="rounded-3xl border border-amber-200 bg-amber-50 p-8 shadow-sm dark:border-amber-500/30 dark:bg-amber-500/10">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">No admin permission</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-700 dark:text-slate-300">
|
||||
You are signed in as {user.email}, but this account is not in the internal admin allowlist and does not
|
||||
carry the admin role.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/account"
|
||||
className="rounded-2xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:text-slate-900 dark:border-slate-600 dark:text-slate-200 dark:hover:border-slate-500"
|
||||
>
|
||||
Back to account
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogout()}
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{metricCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={card.key}
|
||||
className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-900/70"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{card.title}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-slate-900 dark:text-white">{card.value}</p>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{card.caption}</p>
|
||||
</div>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-200">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.3fr_0.9fr]">
|
||||
<div className="space-y-6">
|
||||
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Users and monetization</h2>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
Review plan mix, API adoption, and failed task concentration before support tickets pile up.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="flex w-full max-w-md items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="search"
|
||||
value={userQuery}
|
||||
onChange={(event) => setUserQuery(event.target.value)}
|
||||
placeholder="Search user email"
|
||||
className="w-full rounded-2xl border border-slate-300 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-2xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:text-slate-900 dark:border-slate-600 dark:text-slate-200 dark:hover:border-slate-500"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-700">
|
||||
<thead>
|
||||
<tr className="text-left text-slate-500 dark:text-slate-400">
|
||||
<th className="py-3 pe-4 font-medium">User</th>
|
||||
<th className="py-3 pe-4 font-medium">Role</th>
|
||||
<th className="py-3 pe-4 font-medium">Plan</th>
|
||||
<th className="py-3 pe-4 font-medium">Tasks</th>
|
||||
<th className="py-3 pe-4 font-medium">API keys</th>
|
||||
<th className="py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="text-slate-700 dark:text-slate-200">
|
||||
<td className="py-4 pe-4">
|
||||
<div className="font-semibold text-slate-900 dark:text-white">{user.email}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">Created {user.created_at}</div>
|
||||
</td>
|
||||
<td className="py-4 pe-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="capitalize">{user.role}</span>
|
||||
{user.is_allowlisted_admin ? (
|
||||
<span className="text-xs text-primary-700 dark:text-primary-300">Bootstrap allowlist</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 pe-4 capitalize">{user.plan}</td>
|
||||
<td className="py-4 pe-4">{user.completed_tasks} complete / {user.failed_tasks} failed</td>
|
||||
<td className="py-4 pe-4">{user.active_api_keys}</td>
|
||||
<td className="py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updatingUserId === user.id || user.plan === 'free'}
|
||||
onClick={() => void handlePlanChange(user.id, 'free')}
|
||||
className="rounded-full border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:text-slate-200 dark:hover:border-slate-500"
|
||||
>
|
||||
Set free
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updatingUserId === user.id || user.plan === 'pro'}
|
||||
onClick={() => void handlePlanChange(user.id, 'pro')}
|
||||
className="rounded-full bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Set pro
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={user.is_allowlisted_admin || updatingRoleUserId === user.id || user.role === 'user'}
|
||||
onClick={() => void handleRoleChange(user.id, 'user')}
|
||||
className="rounded-full border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:text-slate-200 dark:hover:border-slate-500"
|
||||
>
|
||||
Set user
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={user.is_allowlisted_admin || updatingRoleUserId === user.id || user.role === 'admin'}
|
||||
onClick={() => void handleRoleChange(user.id, 'admin')}
|
||||
className="rounded-full bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
Set admin
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Recent failures</h2>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
These entries help isolate tool instability and prioritize support follow-up.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:text-slate-900 dark:border-slate-600 dark:text-slate-200 dark:hover:border-slate-500"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4${loading ? ' animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{overview?.recent_failures.length ? overview.recent_failures.map((failure) => (
|
||||
<div
|
||||
key={failure.id}
|
||||
className="rounded-2xl border border-rose-100 bg-rose-50/80 p-4 dark:border-rose-500/20 dark:bg-rose-500/10"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{failure.tool}</p>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
{failure.original_filename || 'Unknown file'}
|
||||
{failure.email ? ` / ${failure.email}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{failure.created_at}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-rose-700 dark:text-rose-200">
|
||||
{typeof failure.metadata.error === 'string' ? failure.metadata.error : 'Processing failed without a structured error message.'}
|
||||
</p>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">No recent failures.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Top tools</h2>
|
||||
<div className="mt-5 space-y-3">
|
||||
{overview?.top_tools.length ? overview.top_tools.map((tool) => (
|
||||
<div key={tool.tool} className="rounded-2xl border border-slate-200 p-4 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{tool.tool}</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{tool.total_runs} total runs</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/10 dark:text-rose-200">
|
||||
{tool.failed_runs} failed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">No tool activity yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Contact inbox</h2>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{contactMeta.unread} unread of {contactMeta.total} total messages.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-primary-100 px-3 py-1 text-xs font-semibold text-primary-700 dark:bg-primary-500/10 dark:text-primary-200">
|
||||
Page {contactMeta.page}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{contacts.length ? contacts.map((contact) => (
|
||||
<div key={contact.id} className="rounded-2xl border border-slate-200 p-4 dark:border-slate-700">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{contact.subject || 'No subject'}</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{contact.name} / {contact.email} / {contact.category}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{contact.created_at}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600 dark:text-slate-300">{contact.message}</p>
|
||||
{!contact.is_read ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={markingMessageId === contact.id}
|
||||
onClick={() => void handleMarkRead(contact.id)}
|
||||
className="mt-4 rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
Mark as read
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-4 inline-flex rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200">
|
||||
Read
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">No contact messages found.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateWebPage } from '@/utils/seo';
|
||||
import { Check, X, Zap, Crown } from 'lucide-react';
|
||||
import { Check, X, Zap, Crown, Loader2 } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
interface PlanFeature {
|
||||
key: string;
|
||||
@@ -25,6 +31,29 @@ const FEATURES: PlanFeature[] = [
|
||||
|
||||
export default function PricingPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleUpgrade(billing: 'monthly' | 'yearly') {
|
||||
if (!user) {
|
||||
window.location.href = '/account?redirect=pricing';
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${API_BASE}/api/stripe/create-checkout-session`,
|
||||
{ billing },
|
||||
{ withCredentials: true },
|
||||
);
|
||||
if (data.url) window.location.href = data.url;
|
||||
} catch {
|
||||
// Stripe not configured yet — show message
|
||||
alert(t('pages.pricing.stripeNotReady', 'Payment system is being set up. Please try again later.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue(val: boolean | string) {
|
||||
if (val === true) return <Check className="mx-auto h-5 w-5 text-green-500" />;
|
||||
@@ -56,6 +85,8 @@ export default function PricingPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SocialProofStrip className="mb-12" />
|
||||
|
||||
{/* Plan Cards */}
|
||||
<div className="mb-16 grid gap-8 md:grid-cols-2">
|
||||
{/* Free Plan */}
|
||||
@@ -137,17 +168,49 @@ export default function PricingPage() {
|
||||
</ul>
|
||||
|
||||
<button
|
||||
disabled
|
||||
onClick={() => handleUpgrade('monthly')}
|
||||
disabled={loading || user?.plan === 'pro'}
|
||||
className="block w-full rounded-xl bg-primary-600 py-3 text-center text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{t('pages.pricing.comingSoon', 'Coming Soon')}
|
||||
{loading ? (
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
) : user?.plan === 'pro' ? (
|
||||
t('pages.pricing.currentPlan', 'Current Plan')
|
||||
) : (
|
||||
t('pages.pricing.upgradeToPro', 'Upgrade to Pro')
|
||||
)}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('pages.pricing.stripeNote', 'Stripe payment integration coming soon')}
|
||||
{t('pages.pricing.securePayment', 'Secure payment via Stripe')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mb-16 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.pricing.trustTitle')}
|
||||
</h2>
|
||||
<p className="mt-3 text-slate-600 dark:text-slate-400">
|
||||
{t('pages.pricing.trustSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-white">{t('pages.pricing.trustFastTitle')}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">{t('pages.pricing.trustFastDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-white">{t('pages.pricing.trustPrivateTitle')}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">{t('pages.pricing.trustPrivateDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-white">{t('pages.pricing.trustApiTitle')}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">{t('pages.pricing.trustApiDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<div className="mb-16 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
@@ -210,7 +273,7 @@ export default function PricingPage() {
|
||||
{t('pages.pricing.faq3q', 'What payment methods do you accept?')}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{t('pages.pricing.faq3a', 'We will support credit/debit cards and PayPal via Stripe. Payment integration is launching soon.')}
|
||||
{t('pages.pricing.faq3a', 'We accept all major credit/debit cards via Stripe. Your payment information is securely processed — we never see your card details.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user