الميزات: إضافة صفحات الأسعار والمدونة، وتفعيل ميزة تقييم الأدوات
- إضافة روابط جديدة في تذييل صفحات الأسعار والمدونة. - إنشاء مكون صفحة الأسعار لعرض تفاصيل الخطط ومقارنة الميزات. - تطوير مكون صفحة المدونة لعرض منشورات المدونة مع روابط للمقالات الفردية. - تقديم مكون تقييم الأدوات لتلقي ملاحظات المستخدمين حول الأدوات، بما في ذلك التقييم بالنجوم والتعليقات الاختيارية. - تفعيل وظيفة useToolRating لجلب وعرض تقييمات الأدوات. - تحديث أدوات تحسين محركات البحث لتضمين بيانات التقييم في البيانات المنظمة للأدوات. - تحسين ملفات i18n بترجمات للميزات والصفحات الجديدة. - دمج إدارة الموافقة على ملفات تعريف الارتباط لتتبع التحليلات.
This commit is contained in:
123
frontend/src/components/layout/CookieConsent.tsx
Normal file
123
frontend/src/components/layout/CookieConsent.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Cookie, X } from 'lucide-react';
|
||||
|
||||
const CONSENT_KEY = 'cookie_consent';
|
||||
const CONSENT_VERSION = '1';
|
||||
|
||||
type ConsentState = 'pending' | 'accepted' | 'rejected';
|
||||
|
||||
function getStoredConsent(): ConsentState {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONSENT_KEY);
|
||||
if (!raw) return 'pending';
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.version === CONSENT_VERSION) return parsed.state as ConsentState;
|
||||
return 'pending';
|
||||
} catch {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
function storeConsent(state: ConsentState) {
|
||||
localStorage.setItem(
|
||||
CONSENT_KEY,
|
||||
JSON.stringify({ state, version: CONSENT_VERSION, timestamp: Date.now() }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a custom event so analytics.ts can listen for consent changes.
|
||||
*/
|
||||
function dispatchConsentEvent(accepted: boolean) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('cookie-consent', { detail: { accepted } }),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnalyticsConsent(): boolean {
|
||||
return getStoredConsent() === 'accepted';
|
||||
}
|
||||
|
||||
export default function CookieConsent() {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (getStoredConsent() === 'pending') {
|
||||
// Small delay so it doesn't block LCP
|
||||
const timer = setTimeout(() => setVisible(true), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleAccept() {
|
||||
storeConsent('accepted');
|
||||
dispatchConsentEvent(true);
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
storeConsent('rejected');
|
||||
dispatchConsentEvent(false);
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('cookie.title', 'Cookie Consent')}
|
||||
className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:flex sm:items-start sm:gap-4">
|
||||
<div className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400 sm:mb-0">
|
||||
<Cookie className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{t('cookie.title', 'We use cookies')}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{t(
|
||||
'cookie.message',
|
||||
'We use essential cookies for site functionality and optional analytics cookies (Google Analytics) to improve your experience. You can accept or reject non-essential cookies.',
|
||||
)}{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{t('cookie.learnMore', 'Learn more')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-slate-800"
|
||||
>
|
||||
{t('cookie.accept', 'Accept All')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className="rounded-lg border border-slate-300 bg-white px-5 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800"
|
||||
>
|
||||
{t('cookie.reject', 'Reject Non-Essential')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className="absolute right-3 top-3 rounded-lg p-1 text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-300 sm:static"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,18 @@ export default function Footer() {
|
||||
>
|
||||
{t('common.contact')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.blog')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getToolSEO } from '@/config/seoData';
|
||||
import { generateToolSchema, generateBreadcrumbs, generateFAQ } from '@/utils/seo';
|
||||
import FAQSection from './FAQSection';
|
||||
import RelatedTools from './RelatedTools';
|
||||
import ToolRating from '@/components/shared/ToolRating';
|
||||
import { useToolRating } from '@/hooks/useToolRating';
|
||||
|
||||
interface SEOFAQ {
|
||||
q: string;
|
||||
@@ -25,6 +27,7 @@ interface ToolLandingPageProps {
|
||||
export default function ToolLandingPage({ slug, children }: ToolLandingPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const seo = getToolSEO(slug);
|
||||
const ratingData = useToolRating(slug);
|
||||
|
||||
// Fallback: just render tool without SEO wrapper
|
||||
if (!seo) return <>{children}</>;
|
||||
@@ -39,6 +42,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
description: seo.metaDescription,
|
||||
url: canonicalUrl,
|
||||
category: seo.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication',
|
||||
ratingValue: ratingData.average,
|
||||
ratingCount: ratingData.count,
|
||||
});
|
||||
|
||||
const breadcrumbSchema = generateBreadcrumbs([
|
||||
@@ -156,6 +161,9 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
|
||||
{/* Related Tools */}
|
||||
<RelatedTools currentSlug={slug} />
|
||||
|
||||
{/* User Rating */}
|
||||
<ToolRating toolSlug={slug} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
143
frontend/src/components/shared/ToolRating.tsx
Normal file
143
frontend/src/components/shared/ToolRating.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Star, ThumbsUp, AlertTriangle, Zap, Send } from 'lucide-react';
|
||||
import api from '@/services/api';
|
||||
|
||||
interface ToolRatingProps {
|
||||
/** Tool slug e.g. "compress-pdf" */
|
||||
toolSlug: string;
|
||||
}
|
||||
|
||||
const TAGS = [
|
||||
{ key: 'fast', icon: Zap },
|
||||
{ key: 'accurate', icon: ThumbsUp },
|
||||
{ key: 'issue', icon: AlertTriangle },
|
||||
] as const;
|
||||
|
||||
export default function ToolRating({ toolSlug }: ToolRatingProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredStar, setHoveredStar] = useState(0);
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit() {
|
||||
if (rating === 0) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.post('/ratings/submit', {
|
||||
tool: toolSlug,
|
||||
rating,
|
||||
feedback: feedback.trim(),
|
||||
tag: selectedTag,
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError(t('rating.error', 'Failed to submit rating. Please try again.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting
|
||||
? t('common.processing', 'Processing...')
|
||||
: t('rating.submit', 'Submit Rating')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user