feat: Implement rating prompt feature across tools

- Added a rating prompt dispatch mechanism to various tools (ChatPdf, PdfFlowchart, QrCodeGenerator, SummarizePdf, TranslatePdf, TableExtractor) to encourage user feedback after tool usage.
- Introduced a new utility for handling rating prompts, including event dispatching and current tool identification.
- Updated the ToolRating component to manage user feedback submission, including UI enhancements and state management.
- Enhanced the sitemap generation script to include new routes for pricing and blog pages.
- Removed hardcoded API key in pdf_ai_service.py for improved security.
- Added a project status report documenting current implementation against the roadmap.
- Updated translations for rating prompts in Arabic, English, and French.
- Ensured consistency in frontend route registry and backend task processing.
This commit is contained in:
Your Name
2026-03-10 23:52:56 +02:00
parent a14c31c594
commit d4c7195eeb
19 changed files with 628 additions and 119 deletions

View File

@@ -8,7 +8,7 @@ import requests
logger = logging.getLogger(__name__)
# Configuration
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "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")
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "stepfun/step-3.5-flash:free")
OPENROUTER_BASE_URL = os.getenv(
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"

Binary file not shown.

View File

@@ -0,0 +1,225 @@
# SaaS-PDF Project Status Report
Generated on: 2026-03-10
Branch reviewed: feature/seo-content
## Executive Summary
This report compares the previously requested roadmap against the current implementation in the SaaS-PDF codebase.
The project has progressed well beyond the earlier inventory documents. The current codebase already includes a broad set of PDF, image, AI, video, and utility tools, multilingual SEO landing pages, core business pages, analytics hooks, and backend tests for most recently added features.
The strongest completed areas are:
- Phase 2 tool expansion
- Phase 3 SEO landing page architecture
- Phase 4 multilingual content support
- Phase 5 core website pages
The main remaining gaps are consistency and production hardening:
- The existing tool inventory document is outdated compared to the live codebase.
- The frontend route registry was not fully synchronized with the actual app routes.
- The sitemap generator lagged behind the committed sitemap structure.
- AI configuration included an insecure fallback API key in code and needed hardening.
## Current Platform Snapshot
### Backend
- Flask application factory with 24 registered blueprints
- Celery async task processing
- Redis-backed task flow
- Service-oriented architecture under backend/app/services
- Route modules under backend/app/routes
- Task modules under backend/app/tasks
### Frontend
- React + Vite + TypeScript
- Lazy-loaded route structure
- SEO landing page wrapper for tool pages
- Translation support for English, Arabic, and French
- Homepage tool cards for major feature groups
### Public SEO Files
- frontend/public/sitemap.xml
- frontend/public/robots.txt
- frontend/public/llms.txt
- frontend/public/humans.txt
## Requested Roadmap vs Current Status
## Phase 1 — Full Project Analysis
Status: completed previously, but documentation drift exists.
Findings:
- docs/tool_inventory.md exists but is no longer fully accurate.
- The current app exposes more tools and routes than the inventory document reports.
- The codebase should be treated as the source of truth until the inventory document is refreshed.
## Phase 2 — Build Missing High-Value Tools
Status: largely completed.
Implemented priority tools confirmed in code:
- Compress Image
- PDF to Excel
- Add Watermark to PDF
- Remove Watermark
- Reorder PDF Pages
- Extract Pages
- QR Code Generator
- HTML to PDF
- Protect PDF
- Unlock PDF
Implemented advanced tools confirmed in code:
- AI Chat with PDF
- PDF Summarizer
- PDF Translator
- Table Extractor
These features are backed by route modules, service modules, task modules, frontend pages, and backend tests.
## Phase 3 — Complete SEO System
Status: substantially completed.
Implemented:
- Dedicated tool landing pages under /tools/*
- Canonical tags
- OpenGraph tags
- Twitter card tags
- JSON-LD structured data
- FAQ sections and FAQ schema support
- Related tool internal linking
- Public SEO support files
Remaining work:
- Replace placeholder production domain values
- Add hreflang link tags if multilingual indexing strategy requires them
- Keep the sitemap generator aligned with the committed sitemap output
## Phase 4 — Content Generation
Status: completed at the application content layer.
Implemented:
- Tool content in English, Arabic, and French
- SEO section content used by the landing page wrapper
- Tool copy for new tools already present in translation files
## Phase 5 — Core Website Pages
Status: completed.
Implemented pages:
- /about
- /contact
- /privacy
- /terms
- /pricing
- /blog
Notes:
- Contact currently uses a mailto flow rather than a backend contact form endpoint.
- About, Privacy, and Terms are SEO-enabled pages with structured metadata.
## Phase 6 — Technical SEO Optimization
Status: mostly completed.
Implemented:
- Reusable SEO head component
- Structured data helpers
- Lazy route loading
- Analytics hooks
- Search Console verification support
- Sitemap generation script
Remaining work:
- Reduce duplicated SEO metadata between some tool pages and the shared tool landing wrapper
- Add final production-domain configuration
## Phase 7 — Analytics and Growth
Status: partially completed.
Implemented:
- Google Analytics integration hooks
- Plausible integration hooks
- Search Console verification injection
- docs/seo_strategy.md
- Pricing and Blog pages as growth support pages
Remaining work:
- Connect production env vars
- Expand blog content into a real publishing workflow
- Validate analytics in deployed environment
## Phase 8 — Safety Rules
Status: generally respected.
Observed:
- Existing routes were preserved.
- New functionality was added in isolated modules.
- Route safety tests exist.
- The work follows the established backend and frontend structure.
## Key Risks and Gaps
1. Documentation drift
The existing tool inventory document no longer matches the current implementation. This can mislead future planning if not updated.
2. Route registry drift
The canonical frontend route registry had fallen behind the actual app routes. This report batch includes a fix for that inconsistency.
3. Sitemap generation drift
The sitemap generator was missing pages already represented in the committed sitemap. This report batch includes a synchronization fix.
4. AI secret handling
The PDF AI service used a hardcoded fallback API key. This report batch removes that fallback so configuration must come from environment variables.
## Implementation Work Started In This Batch
The following improvements were started as part of this implementation step:
- Added this status report file
- Synchronized the frontend route registry with live routes
- Updated the sitemap generator to include the current page inventory
- Hardened AI configuration by removing the hardcoded API key fallback
## Recommended Next Implementation Steps
1. Refresh docs/tool_inventory.md so it becomes the current source of truth again.
2. Remove duplicate Helmet metadata from tool components that are already wrapped by ToolLandingPage.
3. Replace placeholder domain values in public SEO files with the production domain.
4. Decide whether contact should remain mailto-based or move to a backend endpoint.
5. Run full backend and frontend test/build validation in the target environment.
## Final Assessment
SaaS-PDF is no longer just a basic MVP. It is already a broad multi-tool document-processing platform with strong progress across product scope, frontend SEO architecture, and backend task-based processing.
The current priority is not missing core features. The current priority is tightening consistency, production configuration, and documentation so the implemented work is easier to maintain and safer to ship.

View File

@@ -7,6 +7,7 @@ import FAQSection from './FAQSection';
import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating';
import { useToolRating } from '@/hooks/useToolRating';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
interface SEOFAQ {
q: string;
@@ -84,6 +85,20 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{/* Tool Interface */}
{children}
<div className="mx-auto mt-6 flex max-w-3xl items-center justify-center px-4">
<button
type="button"
onClick={() => dispatchRatingPrompt(slug, { forceOpen: true })}
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-primary-600 dark:hover:text-primary-300"
>
<span>{t('pages.rating.cta', 'Rate this tool')}</span>
<span className="text-slate-400 dark:text-slate-500"></span>
<span className="text-slate-500 dark:text-slate-400">
{t('pages.rating.ctaHint', 'Help us improve it faster')}
</span>
</button>
</div>
{/* SEO Content Below Tool */}
<div className="mx-auto mt-16 max-w-3xl">
{/* What this tool does */}

View File

@@ -3,6 +3,7 @@ import { Download, RotateCcw, Clock } from 'lucide-react';
import type { TaskResult } from '@/services/api';
import { formatFileSize } from '@/utils/textTools';
import { trackEvent } from '@/services/analytics';
import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt';
interface DownloadButtonProps {
/** Task result containing download URL */
@@ -14,6 +15,11 @@ interface DownloadButtonProps {
export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) {
const { t } = useTranslation();
const handleDownloadClick = () => {
trackEvent('download_clicked', { filename: result.filename || 'unknown' });
dispatchCurrentToolRatingPrompt();
};
if (!result.download_url) return null;
return (
@@ -62,9 +68,7 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
<a
href={result.download_url}
download={result.filename}
onClick={() => {
trackEvent('download_clicked', { filename: result.filename || 'unknown' });
}}
onClick={handleDownloadClick}
className="btn-success w-full"
target="_blank"
rel="noopener noreferrer"

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Star, ThumbsUp, AlertTriangle, Zap, Send } from 'lucide-react';
import { Star, ThumbsUp, AlertTriangle, Zap, Send, X } from 'lucide-react';
import api from '@/services/api';
import { RATING_PROMPT_EVENT } from '@/utils/ratingPrompt';
interface ToolRatingProps {
/** Tool slug e.g. "compress-pdf" */
@@ -16,6 +17,7 @@ const TAGS = [
export default function ToolRating({ toolSlug }: ToolRatingProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [rating, setRating] = useState(0);
const [hoveredStar, setHoveredStar] = useState(0);
const [selectedTag, setSelectedTag] = useState('');
@@ -24,6 +26,98 @@ export default function ToolRating({ toolSlug }: ToolRatingProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const submittedStorageKey = useMemo(() => `tool-rating:submitted:${toolSlug}`, [toolSlug]);
const dismissedStorageKey = useMemo(() => `tool-rating:dismissed:${toolSlug}`, [toolSlug]);
const readStorage = useCallback((storage: 'localStorage' | 'sessionStorage', key: string) => {
if (typeof window === 'undefined') return null;
try {
return window[storage].getItem(key);
} catch {
return null;
}
}, []);
const writeStorage = useCallback(
(storage: 'localStorage' | 'sessionStorage', key: string, value: string) => {
if (typeof window === 'undefined') return;
try {
window[storage].setItem(key, value);
} catch {
// Ignore storage failures and keep the modal functional.
}
},
[]
);
const resetForm = useCallback(() => {
setRating(0);
setHoveredStar(0);
setSelectedTag('');
setFeedback('');
setSubmitted(false);
setSubmitting(false);
setError('');
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
if (!submitted) {
writeStorage('sessionStorage', dismissedStorageKey, '1');
}
}, [dismissedStorageKey, submitted, writeStorage]);
useEffect(() => {
function handleRatingPrompt(event: Event) {
const detail = (event as CustomEvent<{ toolSlug?: string; forceOpen?: boolean }>).detail;
if (!detail?.toolSlug || detail.toolSlug !== toolSlug) return;
if (readStorage('localStorage', submittedStorageKey)) return;
if (!detail.forceOpen && readStorage('sessionStorage', dismissedStorageKey)) return;
resetForm();
setIsOpen(true);
}
window.addEventListener(RATING_PROMPT_EVENT, handleRatingPrompt as EventListener);
return () => {
window.removeEventListener(RATING_PROMPT_EVENT, handleRatingPrompt as EventListener);
};
}, [dismissedStorageKey, readStorage, resetForm, submittedStorageKey, toolSlug]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeModal();
}
}
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [closeModal, isOpen]);
useEffect(() => {
if (!submitted || !isOpen) return;
const timer = window.setTimeout(() => {
setIsOpen(false);
resetForm();
}, 1800);
return () => window.clearTimeout(timer);
}, [isOpen, resetForm, submitted]);
async function handleSubmit() {
if (rating === 0) return;
setSubmitting(true);
@@ -36,108 +130,181 @@ export default function ToolRating({ toolSlug }: ToolRatingProps) {
feedback: feedback.trim(),
tag: selectedTag,
});
writeStorage('localStorage', submittedStorageKey, '1');
writeStorage('sessionStorage', dismissedStorageKey, '1');
setSubmitted(true);
} catch {
setError(t('rating.error', 'Failed to submit rating. Please try again.'));
setError(
t('pages.rating.error', 'Failed to submit rating. Please try again.')
);
} finally {
setSubmitting(false);
}
}
if (!isOpen) {
return null;
}
if (submitted) {
return (
<div className="mt-8 rounded-2xl border border-green-200 bg-green-50 p-6 text-center dark:border-green-800 dark:bg-green-900/20">
<ThumbsUp className="mx-auto mb-3 h-8 w-8 text-green-600 dark:text-green-400" />
<p className="font-semibold text-green-800 dark:text-green-300">
{t('rating.thankYou', 'Thank you for your feedback!')}
</p>
<p className="mt-1 text-sm text-green-600 dark:text-green-400">
{t('rating.helpImprove', 'Your rating helps us improve our tools.')}
</p>
<div
className="modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="tool-rating-title"
>
<div className="modal-content w-full max-w-md rounded-3xl bg-white p-6 text-center shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<ThumbsUp className="mx-auto mb-3 h-10 w-10 text-emerald-600 dark:text-emerald-400" />
<p
id="tool-rating-title"
className="font-semibold text-emerald-800 dark:text-emerald-300"
>
{t('pages.rating.successTitle', 'Thank you for your feedback!')}
</p>
<p className="mt-2 text-sm text-emerald-700 dark:text-emerald-400">
{t(
'pages.rating.successBody',
'Your rating helps us improve the tools and catch issues faster.'
)}
</p>
</div>
</div>
);
}
return (
<div className="mt-8 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">
<h3 className="mb-4 text-center text-lg font-semibold text-slate-900 dark:text-white">
{t('rating.title', 'How was your experience?')}
</h3>
{/* Star Rating */}
<div className="mb-5 flex items-center justify-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setRating(star)}
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
className="rounded-lg p-1 transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-amber-400"
aria-label={`${star} ${t('rating.stars', 'stars')}`}
>
<Star
className={`h-8 w-8 transition-colors ${
star <= (hoveredStar || rating)
? 'fill-amber-400 text-amber-400'
: 'text-slate-300 dark:text-slate-600'
}`}
/>
</button>
))}
</div>
{/* Quick Tags */}
{rating > 0 && (
<>
<div className="mb-4 flex flex-wrap items-center justify-center gap-2">
{TAGS.map(({ key, icon: Icon }) => (
<button
key={key}
onClick={() => setSelectedTag(selectedTag === key ? '' : key)}
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
selectedTag === key
? 'bg-primary-100 text-primary-700 ring-1 ring-primary-300 dark:bg-primary-900/40 dark:text-primary-300 dark:ring-primary-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
}`}
>
<Icon className="h-3.5 w-3.5" />
{t(`rating.tag.${key}`, key)}
</button>
))}
</div>
{/* Optional Feedback */}
<div className="mb-4">
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder={t('rating.feedbackPlaceholder', 'Any additional feedback? (optional)')}
rows={2}
maxLength={500}
className="w-full resize-none rounded-xl border border-slate-300 bg-slate-50 px-4 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
{error && (
<p className="mb-3 text-center text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<div className="text-center">
<button
onClick={handleSubmit}
disabled={submitting}
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-slate-800"
<div
className="modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 p-4 backdrop-blur-sm"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeModal();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="tool-rating-title"
aria-describedby="tool-rating-description"
>
<div className="modal-content w-full max-w-xl rounded-3xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700 sm:p-7">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<span className="inline-flex rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:ring-emerald-800">
{t('pages.rating.completedBadge', 'Quick feedback')}
</span>
<h3
id="tool-rating-title"
className="mt-3 text-xl font-bold text-slate-900 dark:text-white"
>
<Send className="h-4 w-4" />
{submitting
? t('common.processing', 'Processing...')
: t('rating.submit', 'Submit Rating')}
</button>
{t('pages.rating.title', 'Rate this tool')}
</h3>
<p
id="tool-rating-description"
className="mt-2 max-w-lg text-sm leading-6 text-slate-600 dark:text-slate-300"
>
{t(
'pages.rating.promptBody',
'A quick rating after download helps us improve this tool and catch issues sooner.'
)}
</p>
</div>
</>
)}
<button
type="button"
onClick={closeModal}
className="rounded-xl p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:hover:bg-slate-700 dark:hover:text-slate-200"
aria-label={t('pages.rating.close', 'Close rating dialog')}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="mb-5 flex items-center justify-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
className="rounded-xl p-1.5 transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-amber-400"
aria-label={`${star} ${t('pages.rating.stars', 'stars')}`}
>
<Star
className={`h-9 w-9 transition-colors ${
star <= (hoveredStar || rating)
? 'fill-amber-400 text-amber-400'
: 'text-slate-300 dark:text-slate-600'
}`}
/>
</button>
))}
</div>
{rating > 0 && (
<>
<div className="mb-4 flex flex-wrap items-center justify-center gap-2">
{TAGS.map(({ key, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setSelectedTag(selectedTag === key ? '' : key)}
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
selectedTag === key
? 'bg-primary-100 text-primary-700 ring-1 ring-primary-300 dark:bg-primary-900/40 dark:text-primary-300 dark:ring-primary-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
}`}
>
<Icon className="h-3.5 w-3.5" />
{t(`pages.rating.${key}`, key)}
</button>
))}
</div>
<div className="mb-4">
<textarea
value={feedback}
onChange={(event) => setFeedback(event.target.value)}
placeholder={t(
'pages.rating.feedbackPlaceholder',
'Share your experience (optional)'
)}
rows={3}
maxLength={500}
className="w-full resize-none rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
</>
)}
{error && (
<p className="mb-4 text-center text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={closeModal}
className="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-300 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700"
>
{t('pages.rating.later', 'Maybe later')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={rating === 0 || submitting}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-slate-800"
>
<Send className="h-4 w-4" />
{submitting
? t('common.processing', 'Processing...')
: t('pages.rating.submit', 'Submit Rating')}
</button>
</div>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { useFileUpload } from '@/hooks/useFileUpload';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
export default function ChatPdf() {
const { t } = useTranslation();
@@ -30,7 +31,8 @@ export default function ChatPdf() {
taskId,
onComplete: (r) => {
setPhase('done');
setReply((r as Record<string, unknown>).reply as string || '');
setReply(r.reply || '');
dispatchRatingPrompt('chat-pdf');
},
onError: () => setPhase('done'),
});

View File

@@ -17,6 +17,7 @@ import ManualProcedure from './pdf-flowchart/ManualProcedure';
import FlowGeneration from './pdf-flowchart/FlowGeneration';
import FlowChart from './pdf-flowchart/FlowChart';
import FlowChat from './pdf-flowchart/FlowChat';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
// ---------------------------------------------------------------------------
// Component
@@ -150,6 +151,7 @@ export default function PdfFlowchart() {
const handleGenerationDone = () => {
setStep(3);
dispatchRatingPrompt('pdf-flowchart');
};
const handleFlowUpdate = (updated: Flowchart) => {

View File

@@ -7,6 +7,7 @@ import AdSlot from '@/components/layout/AdSlot';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import api, { type TaskResponse, type TaskResult } from '@/services/api';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
export default function QrCodeGenerator() {
const { t } = useTranslation();
@@ -113,6 +114,7 @@ export default function QrCodeGenerator() {
</div>
<div className="flex gap-3">
<a href={downloadUrl} download={result.filename || 'qrcode.png'}
onClick={() => dispatchRatingPrompt('qr-code')}
className="btn-primary flex-1">{t('common.download')}</a>
<button onClick={handleReset} className="btn-secondary flex-1">{t('common.startOver')}</button>
</div>

View File

@@ -9,6 +9,7 @@ import { useFileUpload } from '@/hooks/useFileUpload';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
export default function SummarizePdf() {
const { t } = useTranslation();
@@ -30,7 +31,8 @@ export default function SummarizePdf() {
taskId,
onComplete: (r) => {
setPhase('done');
setSummary((r as Record<string, unknown>).summary as string || '');
setSummary(r.summary || '');
dispatchRatingPrompt('summarize-pdf');
},
onError: () => setPhase('done'),
});

View File

@@ -9,6 +9,7 @@ import { useFileUpload } from '@/hooks/useFileUpload';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
interface ExtractedTable {
page: number;
@@ -35,8 +36,9 @@ export default function TableExtractor() {
taskId,
onComplete: (r) => {
setPhase('done');
const raw = (r as Record<string, unknown>).tables;
const raw = r.tables;
if (Array.isArray(raw)) setTables(raw as ExtractedTable[]);
dispatchRatingPrompt('extract-tables');
},
onError: () => setPhase('done'),
});

View File

@@ -9,6 +9,7 @@ import { useFileUpload } from '@/hooks/useFileUpload';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
const LANGUAGES = [
{ value: 'en', label: 'English' },
@@ -45,7 +46,8 @@ export default function TranslatePdf() {
taskId,
onComplete: (r) => {
setPhase('done');
setTranslation((r as Record<string, unknown>).translation as string || '');
setTranslation(r.translation || '');
dispatchRatingPrompt('translate-pdf');
},
onError: () => setPhase('done'),
});

View File

@@ -16,6 +16,8 @@ export const PAGE_ROUTES = [
'/privacy',
'/terms',
'/contact',
'/pricing',
'/blog',
] as const;
// ─── Tool routes ─────────────────────────────────────────────────

View File

@@ -86,7 +86,7 @@ export function useTaskPolling({
return () => {
stopPolling();
};
}, [taskId, intervalMs]); // eslint-disable-line react-hooks/exhaustive-deps
}, [taskId, intervalMs]);
return { status, isPolling, result, error, stopPolling };
}

View File

@@ -206,6 +206,16 @@
"title": "قيّم هذه الأداة",
"submit": "إرسال التقييم",
"thanks": "شكراً لملاحظاتك!",
"completedBadge": "رأيك السريع يهمنا",
"promptBody": "بعد التحميل، قيّم الأداة في ثوانٍ. رأيك يساعدنا على تطويرها واكتشاف المشاكل مبكراً.",
"cta": "قيّم هذه الأداة",
"ctaHint": "ساعدنا على تحسينها بسرعة",
"later": "لاحقاً",
"close": "إغلاق نافذة التقييم",
"successTitle": "شكراً، رأيك وصل",
"successBody": "كل تقييم يساعدنا على تحسين التجربة ومعالجة المشاكل بسرعة.",
"error": "تعذر إرسال التقييم. يرجى المحاولة مرة أخرى.",
"stars": "نجوم",
"fast": "سريع",
"accurate": "دقيق",
"issue": "واجهت مشكلة",

View File

@@ -206,6 +206,16 @@
"title": "Rate this tool",
"submit": "Submit Rating",
"thanks": "Thank you for your feedback!",
"completedBadge": "Quick feedback",
"promptBody": "A quick rating after download helps us improve this tool and catch issues sooner.",
"cta": "Rate this tool",
"ctaHint": "Help us improve it faster",
"later": "Maybe later",
"close": "Close rating dialog",
"successTitle": "Thank you for your feedback!",
"successBody": "Your rating helps us improve the tools and catch issues faster.",
"error": "Failed to submit rating. Please try again.",
"stars": "stars",
"fast": "Fast",
"accurate": "Accurate",
"issue": "Had Issues",

View File

@@ -206,6 +206,16 @@
"title": "Évaluez cet outil",
"submit": "Envoyer l'évaluation",
"thanks": "Merci pour votre retour !",
"completedBadge": "Retour rapide",
"promptBody": "Une note rapide après le téléchargement nous aide à améliorer cet outil et à repérer les problèmes plus tôt.",
"cta": "Évaluer cet outil",
"ctaHint": "Aidez-nous à l'améliorer plus vite",
"later": "Plus tard",
"close": "Fermer la fenêtre d'évaluation",
"successTitle": "Merci pour votre retour !",
"successBody": "Votre évaluation nous aide à améliorer les outils et à corriger les problèmes plus vite.",
"error": "Impossible d'envoyer l'évaluation. Veuillez réessayer.",
"stars": "étoiles",
"fast": "Rapide",
"accurate": "Précis",
"issue": "Problème",

View File

@@ -0,0 +1,28 @@
export const RATING_PROMPT_EVENT = 'saaspdf:rating-prompt';
interface RatingPromptOptions {
forceOpen?: boolean;
}
export function dispatchRatingPrompt(toolSlug: string, options: RatingPromptOptions = {}) {
if (typeof window === 'undefined' || !toolSlug) return;
window.dispatchEvent(
new CustomEvent(RATING_PROMPT_EVENT, {
detail: {
toolSlug,
forceOpen: options.forceOpen === true,
},
})
);
}
export function dispatchCurrentToolRatingPrompt(options: RatingPromptOptions = {}) {
if (typeof window === 'undefined') return;
const path = window.location.pathname.replace(/\/$/, '');
if (!path.startsWith('/tools/')) return;
const toolSlug = path.replace('/tools/', '');
dispatchRatingPrompt(toolSlug, options);
}

View File

@@ -14,42 +14,68 @@ from datetime import datetime
# ─── Route definitions with priority and changefreq ──────────────────────────
PAGES = [
{'path': '/', 'changefreq': 'daily', 'priority': '1.0'},
{'path': '/about', 'changefreq': 'monthly', 'priority': '0.4'},
{'path': '/contact', 'changefreq': 'monthly', 'priority': '0.4'},
{'path': '/privacy', 'changefreq': 'yearly', 'priority': '0.3'},
{'path': '/terms', 'changefreq': 'yearly', 'priority': '0.3'},
{'path': '/', 'changefreq': 'daily', 'priority': '1.0'},
{'path': '/about', 'changefreq': 'monthly', 'priority': '0.4'},
{'path': '/contact', 'changefreq': 'monthly', 'priority': '0.4'},
{'path': '/privacy', 'changefreq': 'yearly', 'priority': '0.3'},
{'path': '/terms', 'changefreq': 'yearly', 'priority': '0.3'},
{'path': '/pricing', 'changefreq': 'monthly', 'priority': '0.7'},
{'path': '/blog', 'changefreq': 'weekly', 'priority': '0.6'},
]
# PDF Tools
PDF_TOOLS = [
'pdf-to-word', 'word-to-pdf', 'compress-pdf', 'merge-pdf',
'split-pdf', 'rotate-pdf', 'pdf-to-images', 'images-to-pdf',
'watermark-pdf', 'remove-watermark-pdf', 'protect-pdf', 'unlock-pdf',
'page-numbers', 'reorder-pdf', 'extract-pages', 'pdf-editor',
'pdf-flowchart', 'pdf-to-excel',
{'slug': 'pdf-to-word', 'priority': '0.9'},
{'slug': 'word-to-pdf', 'priority': '0.9'},
{'slug': 'compress-pdf', 'priority': '0.9'},
{'slug': 'merge-pdf', 'priority': '0.9'},
{'slug': 'split-pdf', 'priority': '0.8'},
{'slug': 'rotate-pdf', 'priority': '0.7'},
{'slug': 'pdf-to-images', 'priority': '0.8'},
{'slug': 'images-to-pdf', 'priority': '0.8'},
{'slug': 'watermark-pdf', 'priority': '0.7'},
{'slug': 'remove-watermark-pdf','priority': '0.7'},
{'slug': 'protect-pdf', 'priority': '0.8'},
{'slug': 'unlock-pdf', 'priority': '0.8'},
{'slug': 'page-numbers', 'priority': '0.7'},
{'slug': 'reorder-pdf', 'priority': '0.7'},
{'slug': 'extract-pages', 'priority': '0.7'},
{'slug': 'pdf-editor', 'priority': '0.8'},
{'slug': 'pdf-flowchart', 'priority': '0.7'},
{'slug': 'pdf-to-excel', 'priority': '0.8'},
]
# Image Tools
IMAGE_TOOLS = [
'image-converter', 'image-resize', 'compress-image', 'remove-background',
{'slug': 'image-converter', 'priority': '0.8'},
{'slug': 'image-resize', 'priority': '0.8'},
{'slug': 'compress-image', 'priority': '0.8'},
{'slug': 'remove-background', 'priority': '0.8'},
]
# AI Tools
AI_TOOLS = [
'ocr', 'chat-pdf', 'summarize-pdf', 'translate-pdf', 'extract-tables',
{'slug': 'ocr', 'priority': '0.8'},
{'slug': 'chat-pdf', 'priority': '0.8'},
{'slug': 'summarize-pdf', 'priority': '0.8'},
{'slug': 'translate-pdf', 'priority': '0.8'},
{'slug': 'extract-tables', 'priority': '0.8'},
]
# Convert / Utility Tools
UTILITY_TOOLS = [
'html-to-pdf', 'qr-code', 'video-to-gif', 'word-counter', 'text-cleaner',
{'slug': 'html-to-pdf', 'priority': '0.7'},
{'slug': 'qr-code', 'priority': '0.7'},
{'slug': 'video-to-gif', 'priority': '0.7'},
{'slug': 'word-counter', 'priority': '0.6'},
{'slug': 'text-cleaner', 'priority': '0.6'},
]
TOOL_GROUPS = [
('PDF Tools', PDF_TOOLS, '0.9'),
('Image Tools', IMAGE_TOOLS, '0.8'),
('AI Tools', AI_TOOLS, '0.8'),
('Utility Tools', UTILITY_TOOLS, '0.7'),
('PDF Tools', PDF_TOOLS),
('Image Tools', IMAGE_TOOLS),
('AI Tools', AI_TOOLS),
('Utility Tools', UTILITY_TOOLS),
]
@@ -67,14 +93,14 @@ def generate_sitemap(domain: str) -> str:
</url>''')
# Tool pages by category
for label, slugs, priority in TOOL_GROUPS:
for label, routes in TOOL_GROUPS:
urls.append(f'\n <!-- {label} -->')
for slug in slugs:
for route in routes:
urls.append(f''' <url>
<loc>{domain}/tools/{slug}</loc>
<loc>{domain}/tools/{route["slug"]}</loc>
<lastmod>{today}</lastmod>
<changefreq>weekly</changefreq>
<priority>{priority}</priority>
<priority>{route["priority"]}</priority>
</url>''')
sitemap = f'''<?xml version="1.0" encoding="UTF-8"?>
@@ -97,7 +123,7 @@ def main():
with open(args.output, 'w', encoding='utf-8') as f:
f.write(sitemap)
total = len(PAGES) + sum(len(slugs) for _, slugs, _ in TOOL_GROUPS)
total = len(PAGES) + sum(len(routes) for _, routes in TOOL_GROUPS)
print(f"Sitemap generated: {args.output}")
print(f"Total URLs: {total}")