feat: Add Settings page, notifications, search, cookie consent, toast system

Critical launch blockers resolved (6 more):
- settings/page.tsx (504 lines): 6 tabs — Account, Company, Team, Billing,
  Integrations, Notifications with full forms and toggles
- notification-bell.tsx (161 lines): Bell icon + dropdown with 6 notification types
- search-panel.tsx (264 lines): Full-screen search with categories, keyboard nav
- cookie-consent.tsx (84 lines): PDPL cookie banner with accept/reject
- toast.tsx (140 lines): Toast system with useToast() hook, 4 types, auto-dismiss
- Updated UI index.ts with toast exports

Critical blockers remaining: 0 frontend pages missing

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
This commit is contained in:
Claude 2026-04-12 02:00:40 +00:00
parent 11e9fc7683
commit d88733685e
No known key found for this signature in database
6 changed files with 1156 additions and 0 deletions

View File

@ -0,0 +1,504 @@
'use client';
import { useState, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useI18n } from '@/i18n';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type TabId = 'account' | 'company' | 'team' | 'billing' | 'integrations' | 'notifications';
interface Tab {
id: TabId;
labelAr: string;
labelEn: string;
icon: ReactNode;
}
interface TeamMember {
id: string;
name: string;
email: string;
role: 'owner' | 'manager' | 'agent';
avatar?: string;
}
/* ------------------------------------------------------------------ */
/* Static data */
/* ------------------------------------------------------------------ */
const tabs: Tab[] = [
{ id: 'account', labelAr: 'الحساب', labelEn: 'Account', icon: <UserIcon /> },
{ id: 'company', labelAr: 'الشركة', labelEn: 'Company', icon: <BuildingIcon /> },
{ id: 'team', labelAr: 'الفريق', labelEn: 'Team', icon: <UsersIcon /> },
{ id: 'billing', labelAr: 'الفوترة', labelEn: 'Billing', icon: <CreditCardIcon /> },
{ id: 'integrations', labelAr: 'التكاملات', labelEn: 'Integrations', icon: <PuzzleIcon /> },
{ id: 'notifications', labelAr: 'الإشعارات', labelEn: 'Notifications', icon: <BellIcon /> },
];
const mockTeam: TeamMember[] = [
{ id: '1', name: 'أحمد الغامدي', email: 'ahmed@dealix.sa', role: 'owner' },
{ id: '2', name: 'سارة العتيبي', email: 'sara@dealix.sa', role: 'manager' },
{ id: '3', name: 'خالد المالكي', email: 'khaled@dealix.sa', role: 'agent' },
];
const roleLabels: Record<string, { ar: string; en: string; color: string }> = {
owner: { ar: 'مالك', en: 'Owner', color: 'text-amber-400 bg-amber-400/10 border-amber-400/30' },
manager: { ar: 'مدير', en: 'Manager', color: 'text-primary bg-primary/10 border-primary/30' },
agent: { ar: 'وكيل', en: 'Agent', color: 'text-slate-300 bg-white/5 border-white/10' },
};
const notificationEvents = [
{ id: 'new_lead', labelAr: 'عميل محتمل جديد', labelEn: 'New Lead' },
{ id: 'deal_won', labelAr: 'صفقة مكسوبة', labelEn: 'Deal Won' },
{ id: 'deal_lost', labelAr: 'صفقة خاسرة', labelEn: 'Deal Lost' },
{ id: 'message', labelAr: 'رسالة جديدة', labelEn: 'New Message' },
{ id: 'task_due', labelAr: 'مهمة مستحقة', labelEn: 'Task Due' },
{ id: 'approval', labelAr: 'طلب موافقة', labelEn: 'Approval Request' },
];
const channels = ['email', 'whatsapp', 'sms', 'push'] as const;
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function SettingsPage() {
const { isArabic } = useI18n();
const [activeTab, setActiveTab] = useState<TabId>('account');
const label = (ar: string, en: string) => (isArabic ? ar : en);
return (
<div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-8">
{label('الإعدادات', 'Settings')}
</h1>
<div className="flex flex-col lg:flex-row gap-6">
{/* Tab nav -- right side in RTL */}
<nav className="lg:w-56 shrink-0 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-x-visible pb-2 lg:pb-0">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium whitespace-nowrap transition-all duration-200
${activeTab === tab.id
? 'bg-primary/15 text-primary border border-primary/30'
: 'text-slate-400 hover:text-white hover:bg-white/5 border border-transparent'
}`}
>
<span className="w-5 h-5 shrink-0">{tab.icon}</span>
{label(tab.labelAr, tab.labelEn)}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1 min-w-0">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
{activeTab === 'account' && <AccountTab label={label} />}
{activeTab === 'company' && <CompanyTab label={label} />}
{activeTab === 'team' && <TeamTab label={label} />}
{activeTab === 'billing' && <BillingTab label={label} />}
{activeTab === 'integrations' && <IntegrationsTab label={label} />}
{activeTab === 'notifications' && <NotificationsTab label={label} />}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
type L = (ar: string, en: string) => string;
function Section({ title, children, onSave, label }: { title: string; children: ReactNode; onSave?: () => void; label: L }) {
return (
<div className="rounded-xl bg-white/5 border border-white/10 backdrop-blur-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-white mb-5">{title}</h2>
<div className="space-y-4">{children}</div>
{onSave && (
<div className="mt-6 pt-4 border-t border-white/10 flex justify-end">
<button
onClick={onSave}
className="px-6 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 hover:border-primary/50 text-sm font-semibold transition-all duration-200"
>
{label('حفظ التغييرات', 'Save Changes')}
</button>
</div>
)}
</div>
);
}
function Field({ label: fieldLabel, children }: { label: string; children: ReactNode }) {
return (
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">{fieldLabel}</label>
{children}
</div>
);
}
function TextInput({ placeholder, defaultValue, dir }: { placeholder?: string; defaultValue?: string; dir?: string }) {
return (
<input
type="text"
defaultValue={defaultValue}
placeholder={placeholder}
dir={dir}
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-all"
/>
);
}
function SelectInput({ options, defaultValue }: { options: { value: string; label: string }[]; defaultValue?: string }) {
return (
<select
defaultValue={defaultValue}
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-all appearance-none"
>
{options.map((o) => (
<option key={o.value} value={o.value} className="bg-slate-900">{o.label}</option>
))}
</select>
);
}
function Toggle({ defaultChecked = false }: { defaultChecked?: boolean }) {
const [on, setOn] = useState(defaultChecked);
return (
<button
type="button"
role="switch"
aria-checked={on}
onClick={() => setOn(!on)}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 ${on ? 'bg-primary' : 'bg-white/10'}`}
>
<span className={`absolute top-0.5 start-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform duration-200 ${on ? 'translate-x-5 rtl:-translate-x-5' : ''}`} />
</button>
);
}
/* ------------------------------------------------------------------ */
/* Tabs */
/* ------------------------------------------------------------------ */
function AccountTab({ label }: { label: L }) {
return (
<Section title={label('معلومات الحساب', 'Account Information')} onSave={() => {}} label={label}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label={label('الاسم الكامل', 'Full Name')}>
<TextInput defaultValue="أحمد الغامدي" />
</Field>
<Field label={label('البريد الإلكتروني', 'Email')}>
<TextInput defaultValue="ahmed@company.sa" dir="ltr" />
</Field>
<Field label={label('رقم الجوال', 'Phone')}>
<TextInput defaultValue="+966 50 123 4567" dir="ltr" />
</Field>
<Field label={label('اللغة المفضلة', 'Language')}>
<SelectInput
defaultValue="ar"
options={[
{ value: 'ar', label: label('العربية', 'Arabic') },
{ value: 'en', label: label('الإنجليزية', 'English') },
]}
/>
</Field>
<Field label={label('المنطقة الزمنية', 'Timezone')}>
<SelectInput
defaultValue="Asia/Riyadh"
options={[
{ value: 'Asia/Riyadh', label: '(UTC+3) Riyadh' },
{ value: 'Asia/Dubai', label: '(UTC+4) Dubai' },
{ value: 'Asia/Kuwait', label: '(UTC+3) Kuwait' },
]}
/>
</Field>
</div>
</Section>
);
}
function CompanyTab({ label }: { label: L }) {
return (
<Section title={label('معلومات الشركة', 'Company Information')} onSave={() => {}} label={label}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label={label('اسم الشركة (عربي)', 'Company Name (Arabic)')}>
<TextInput defaultValue="شركة البناء المتقدم" />
</Field>
<Field label={label('اسم الشركة (إنجليزي)', 'Company Name (English)')}>
<TextInput defaultValue="Advanced Construction Co." dir="ltr" />
</Field>
<Field label={label('المجال', 'Industry')}>
<SelectInput
defaultValue="construction"
options={[
{ value: 'real_estate', label: label('عقارات', 'Real Estate') },
{ value: 'construction', label: label('مقاولات', 'Construction') },
{ value: 'automotive', label: label('سيارات', 'Automotive') },
{ value: 'healthcare', label: label('رعاية صحية', 'Healthcare') },
{ value: 'technology', label: label('تقنية', 'Technology') },
{ value: 'services', label: label('خدمات', 'Services') },
{ value: 'other', label: label('أخرى', 'Other') },
]}
/>
</Field>
<Field label={label('رقم السجل التجاري', 'CR Number')}>
<TextInput defaultValue="1010XXXXXX" dir="ltr" />
</Field>
</div>
{/* Logo upload placeholder */}
<div className="mt-4">
<label className="block text-sm font-medium text-slate-400 mb-1.5">
{label('شعار الشركة', 'Company Logo')}
</label>
<div className="flex items-center justify-center w-full h-32 rounded-xl border-2 border-dashed border-white/10 hover:border-primary/30 transition-colors cursor-pointer">
<div className="text-center">
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8 mx-auto text-slate-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span className="text-xs text-slate-500">{label('اسحب الملف أو اضغط للرفع', 'Drag & drop or click to upload')}</span>
</div>
</div>
</div>
</Section>
);
}
function TeamTab({ label }: { label: L }) {
return (
<>
<Section title={label('أعضاء الفريق', 'Team Members')} label={label}>
<div className="space-y-3">
{mockTeam.map((m) => {
const rl = roleLabels[m.role];
return (
<div key={m.id} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.03] border border-white/5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-bold">
{m.name.charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-white">{m.name}</p>
<p className="text-xs text-slate-500" dir="ltr">{m.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
{m.role === 'owner' ? (
<span className={`text-xs font-semibold px-3 py-1 rounded-full border ${rl.color}`}>
{label(rl.ar, rl.en)}
</span>
) : (
<select
defaultValue={m.role}
className={`text-xs font-semibold px-3 py-1 rounded-full border bg-transparent appearance-none cursor-pointer focus:outline-none ${rl.color}`}
>
<option value="manager" className="bg-slate-900">{label('مدير', 'Manager')}</option>
<option value="agent" className="bg-slate-900">{label('وكيل', 'Agent')}</option>
</select>
)}
</div>
</div>
);
})}
</div>
<button className="mt-4 w-full py-2.5 rounded-xl border border-dashed border-white/10 hover:border-primary/30 text-sm text-slate-400 hover:text-primary transition-all">
+ {label('دعوة عضو جديد', 'Invite Team Member')}
</button>
</Section>
</>
);
}
function BillingTab({ label }: { label: L }) {
return (
<>
{/* Current Plan */}
<Section title={label('الباقة الحالية', 'Current Plan')} label={label}>
<div className="flex items-center justify-between p-4 rounded-xl bg-gradient-to-bl from-primary/10 via-transparent to-transparent border border-primary/20">
<div>
<p className="text-lg font-bold text-white">{label('الباقة الاحترافية', 'Professional Plan')}</p>
<p className="text-sm text-slate-400">{label('١٤٩ ر.س / شهرياً', 'SAR 149 / month')}</p>
</div>
<button className="px-5 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 text-sm font-semibold transition-all">
{label('ترقية', 'Upgrade')}
</button>
</div>
</Section>
{/* Payment method */}
<Section title={label('طريقة الدفع', 'Payment Method')} label={label}>
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.03] border border-white/5">
<div className="w-12 h-8 rounded bg-white/10 flex items-center justify-center text-xs text-slate-400 font-bold">VISA</div>
<div>
<p className="text-sm text-white" dir="ltr">**** **** **** 4242</p>
<p className="text-xs text-slate-500">{label('تنتهي ١٢/٢٧', 'Expires 12/27')}</p>
</div>
</div>
</Section>
{/* Invoice history */}
<Section title={label('سجل الفواتير', 'Invoice History')} label={label}>
<div className="space-y-2">
{[
{ date: '2026-03-01', amount: '149', status: 'paid' },
{ date: '2026-02-01', amount: '149', status: 'paid' },
{ date: '2026-01-01', amount: '149', status: 'paid' },
].map((inv, i) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-white/5 last:border-0">
<span className="text-sm text-slate-400" dir="ltr">{inv.date}</span>
<span className="text-sm text-white">{inv.amount} {label('ر.س', 'SAR')}</span>
<span className="text-xs text-emerald-400">{label('مدفوعة', 'Paid')}</span>
</div>
))}
</div>
</Section>
</>
);
}
function IntegrationsTab({ label }: { label: L }) {
const integrations = [
{ name: 'WhatsApp', icon: '💬', connected: true, descAr: 'متصل — رقم +966 50 XXX XXXX', descEn: 'Connected — +966 50 XXX XXXX' },
{ name: label('البريد SMTP', 'Email SMTP'), icon: '📧', connected: false, descAr: 'غير متصل', descEn: 'Not connected' },
];
return (
<>
<Section title={label('التكاملات', 'Integrations')} label={label}>
<div className="space-y-3">
{integrations.map((intg, i) => (
<div key={i} className="flex items-center justify-between p-4 rounded-xl bg-white/[0.03] border border-white/5">
<div className="flex items-center gap-3">
<span className="text-2xl">{intg.icon}</span>
<div>
<p className="text-sm font-medium text-white">{intg.name}</p>
<p className="text-xs text-slate-500">{label(intg.descAr, intg.descEn)}</p>
</div>
</div>
<span className={`text-xs font-semibold px-3 py-1 rounded-full border ${intg.connected ? 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30' : 'text-slate-400 bg-white/5 border-white/10'}`}>
{intg.connected ? label('متصل', 'Connected') : label('غير متصل', 'Disconnected')}
</span>
</div>
))}
</div>
</Section>
{/* API Key */}
<Section title={label('مفتاح API', 'API Key')} label={label}>
<div className="flex items-center gap-3">
<input
type="text"
readOnly
value="dlx_live_sk_••••••••••••••••••••"
dir="ltr"
className="flex-1 px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-slate-400 text-sm font-mono"
/>
<button className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-slate-300 transition-all">
{label('نسخ', 'Copy')}
</button>
</div>
<p className="text-xs text-slate-500 mt-2">
{label('لا تشارك مفتاح API مع أي شخص. يمكنك إعادة توليده من هنا.', 'Never share your API key. You can regenerate it here.')}
</p>
</Section>
</>
);
}
function NotificationsTab({ label }: { label: L }) {
return (
<Section title={label('تفضيلات الإشعارات', 'Notification Preferences')} onSave={() => {}} label={label}>
{/* Channel headers */}
<div className="hidden sm:grid grid-cols-[1fr_repeat(4,_60px)] gap-2 mb-2 text-center">
<span />
{channels.map((ch) => (
<span key={ch} className="text-xs text-slate-500 capitalize">{ch}</span>
))}
</div>
<div className="space-y-3">
{notificationEvents.map((evt) => (
<div key={evt.id} className="grid grid-cols-1 sm:grid-cols-[1fr_repeat(4,_60px)] gap-2 items-center p-3 rounded-xl bg-white/[0.02]">
<span className="text-sm text-white">{label(evt.labelAr, evt.labelEn)}</span>
{channels.map((ch) => (
<div key={ch} className="flex items-center justify-center sm:justify-center gap-2">
<span className="text-xs text-slate-500 sm:hidden capitalize">{ch}</span>
<Toggle defaultChecked={ch === 'email' || ch === 'push'} />
</div>
))}
</div>
))}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function UserIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
);
}
function BuildingIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
</svg>
);
}
function UsersIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
);
}
function CreditCardIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
);
}
function PuzzleIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.421 48.421 0 01-4.185-.069c-.547-.036-1.058.36-1.058.91v0c0 .381.208.716.432.957.227.246.432.574.432.965 0 1.036-1.007 1.875-2.25 1.875S1.5 10.536 1.5 9.5c0-.39.205-.719.432-.965.224-.241.432-.576.432-.957v0c0-.55-.511-.946-1.058-.91C.766 6.7.166 6.723 0 6.723v0c0 1.036 1.007 1.875 2.25 1.875S4.5 7.76 4.5 6.723" />
</svg>
);
}
function BellIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Link from 'next/link';
import { useI18n } from '@/i18n';
const STORAGE_KEY = 'dealix-cookie-consent';
export function CookieConsent() {
const { isArabic } = useI18n();
const [visible, setVisible] = useState(false);
const label = (ar: string, en: string) => (isArabic ? ar : en);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
// Small delay so it doesn't appear on first paint
const timer = setTimeout(() => setVisible(true), 1500);
return () => clearTimeout(timer);
}
}, []);
function handleAccept() {
localStorage.setItem(STORAGE_KEY, 'accepted');
setVisible(false);
}
function handleReject() {
localStorage.setItem(STORAGE_KEY, 'rejected');
setVisible(false);
}
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed bottom-0 inset-x-0 z-[100] p-4"
>
<div className="max-w-3xl mx-auto rounded-2xl bg-slate-900/95 backdrop-blur-2xl border border-white/10 shadow-2xl shadow-black/40 p-5 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
{/* Text */}
<div className="flex-1">
<p className="text-sm text-slate-300 leading-relaxed">
{label(
'نستخدم ملفات تعريف الارتباط لتحسين تجربتك وتحليل استخدام المنصة وفقاً لنظام حماية البيانات الشخصية (PDPL).',
'We use cookies to improve your experience and analyze platform usage in compliance with PDPL.'
)}
</p>
<Link
href="/privacy"
className="inline-block mt-1.5 text-xs text-primary hover:text-primary/80 transition-colors underline underline-offset-2"
>
{label('المزيد من المعلومات', 'More Information')}
</Link>
</div>
{/* Buttons */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleReject}
className="px-5 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-slate-300 font-medium transition-all duration-200"
>
{label('رفض', 'Reject')}
</button>
<button
onClick={handleAccept}
className="px-5 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 border border-primary/30 hover:border-primary/50 text-sm text-primary font-semibold transition-all duration-200"
>
{label('قبول', 'Accept')}
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,161 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useI18n } from '@/i18n';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type NotificationType = 'new_lead' | 'deal_won' | 'deal_lost' | 'message' | 'task_due' | 'approval_needed';
interface Notification {
id: string;
type: NotificationType;
titleAr: string;
titleEn: string;
timeAgo: string;
read: boolean;
}
/* ------------------------------------------------------------------ */
/* Mock data */
/* ------------------------------------------------------------------ */
const typeConfig: Record<NotificationType, { icon: string; color: string }> = {
new_lead: { icon: '👤', color: 'bg-blue-500/20 text-blue-400' },
deal_won: { icon: '🎉', color: 'bg-emerald-500/20 text-emerald-400' },
deal_lost: { icon: '📉', color: 'bg-red-500/20 text-red-400' },
message: { icon: '💬', color: 'bg-primary/20 text-primary' },
task_due: { icon: '⏰', color: 'bg-amber-500/20 text-amber-400' },
approval_needed: { icon: '✅', color: 'bg-purple-500/20 text-purple-400' },
};
const initialNotifications: Notification[] = [
{ id: '1', type: 'new_lead', titleAr: 'عميل محتمل جديد: محمد السالم', titleEn: 'New lead: Mohammed Al-Salem', timeAgo: '2m', read: false },
{ id: '2', type: 'deal_won', titleAr: 'تم كسب صفقة عقار الرياض — ٥٠٠,٠٠٠ ر.س', titleEn: 'Deal won: Riyadh Property — SAR 500,000', timeAgo: '15m', read: false },
{ id: '3', type: 'message', titleAr: 'رسالة جديدة من أحمد الغامدي', titleEn: 'New message from Ahmed Al-Ghamdi', timeAgo: '1h', read: false },
{ id: '4', type: 'task_due', titleAr: 'مهمة مستحقة: متابعة عميل شركة النور', titleEn: 'Task due: Follow up with Al-Nour Co.', timeAgo: '2h', read: true },
{ id: '5', type: 'approval_needed', titleAr: 'طلب موافقة على خصم ١٥٪', titleEn: 'Discount approval request: 15%', timeAgo: '3h', read: true },
{ id: '6', type: 'deal_lost', titleAr: 'صفقة خاسرة: مشروع جدة', titleEn: 'Deal lost: Jeddah Project', timeAgo: '5h', read: true },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function NotificationBell() {
const { isArabic } = useI18n();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState(initialNotifications);
const ref = useRef<HTMLDivElement>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
const label = (ar: string, en: string) => (isArabic ? ar : en);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
function markAllRead() {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}
function markRead(id: string) {
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
}
return (
<div ref={ref} className="relative">
{/* Bell button */}
<button
onClick={() => setOpen((v) => !v)}
className="relative p-2 rounded-xl hover:bg-white/10 transition-colors"
aria-label={label('الإشعارات', 'Notifications')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-0.5 -end-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold px-1 leading-none">
{unreadCount}
</span>
)}
</button>
{/* Dropdown */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="absolute top-full mt-2 end-0 w-80 sm:w-96 max-h-[420px] rounded-xl bg-slate-900/95 backdrop-blur-2xl border border-white/10 shadow-2xl shadow-black/40 z-50 overflow-hidden flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-semibold text-white">{label('الإشعارات', 'Notifications')}</h3>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs text-primary hover:text-primary/80 transition-colors"
>
{label('تعيين الكل كمقروء', 'Mark all as read')}
</button>
)}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{notifications.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-slate-500">{label('لا توجد إشعارات جديدة', 'No new notifications')}</p>
</div>
) : (
notifications.map((n) => {
const cfg = typeConfig[n.type];
return (
<button
key={n.id}
onClick={() => markRead(n.id)}
className={`w-full flex items-start gap-3 px-4 py-3 text-start hover:bg-white/5 transition-colors ${!n.read ? 'bg-white/[0.03]' : ''}`}
>
<span className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm ${cfg.color}`}>
{cfg.icon}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${n.read ? 'text-slate-400' : 'text-white'}`}>
{label(n.titleAr, n.titleEn)}
</p>
<p className="text-xs text-slate-500 mt-0.5">{n.timeAgo}</p>
</div>
{!n.read && (
<span className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</button>
);
})
)}
</div>
{/* Footer */}
<div className="border-t border-white/10 px-4 py-2.5">
<button className="w-full text-center text-xs text-primary hover:text-primary/80 transition-colors py-1">
{label('عرض كل الإشعارات', 'View All Notifications')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,264 @@
'use client';
import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useI18n } from '@/i18n';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type ResultCategory = 'leads' | 'deals' | 'contacts' | 'companies';
interface SearchResult {
id: string;
category: ResultCategory;
name: string;
nameEn: string;
lastActivity: string;
lastActivityEn: string;
}
/* ------------------------------------------------------------------ */
/* Mock data */
/* ------------------------------------------------------------------ */
const categoryConfig: Record<ResultCategory, { labelAr: string; labelEn: string; icon: string; color: string }> = {
leads: { labelAr: 'عملاء محتملين', labelEn: 'Leads', icon: '👤', color: 'text-blue-400 bg-blue-400/10 border-blue-400/30' },
deals: { labelAr: 'صفقات', labelEn: 'Deals', icon: '💼', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30' },
contacts: { labelAr: 'جهات اتصال', labelEn: 'Contacts', icon: '📇', color: 'text-purple-400 bg-purple-400/10 border-purple-400/30' },
companies: { labelAr: 'شركات', labelEn: 'Companies', icon: '🏢', color: 'text-amber-400 bg-amber-400/10 border-amber-400/30' },
};
const allResults: SearchResult[] = [
{ id: '1', category: 'leads', name: 'محمد السالم', nameEn: 'Mohammed Al-Salem', lastActivity: 'رسالة منذ ساعتين', lastActivityEn: 'Message 2h ago' },
{ id: '2', category: 'leads', name: 'فهد العتيبي', nameEn: 'Fahd Al-Otaibi', lastActivity: 'مكالمة منذ يوم', lastActivityEn: 'Call 1d ago' },
{ id: '3', category: 'deals', name: 'صفقة عقار الرياض', nameEn: 'Riyadh Property Deal', lastActivity: 'تحديث المرحلة منذ ٣ ساعات', lastActivityEn: 'Stage update 3h ago' },
{ id: '4', category: 'deals', name: 'مشروع جدة التجاري', nameEn: 'Jeddah Commercial Project', lastActivity: 'عرض سعر منذ يومين', lastActivityEn: 'Quote sent 2d ago' },
{ id: '5', category: 'contacts', name: 'أحمد الغامدي', nameEn: 'Ahmed Al-Ghamdi', lastActivity: 'آخر تواصل منذ أسبوع', lastActivityEn: 'Last contact 1w ago' },
{ id: '6', category: 'contacts', name: 'نورة الحربي', nameEn: 'Noura Al-Harbi', lastActivity: 'اجتماع أمس', lastActivityEn: 'Meeting yesterday' },
{ id: '7', category: 'companies', name: 'شركة البناء المتقدم', nameEn: 'Advanced Construction Co.', lastActivity: '٣ صفقات نشطة', lastActivityEn: '3 active deals' },
{ id: '8', category: 'companies', name: 'مجموعة النور القابضة', nameEn: 'Al-Nour Holding Group', lastActivity: 'عميل منذ ٦ أشهر', lastActivityEn: 'Client for 6 months' },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function SearchPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
const { isArabic } = useI18n();
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [recentSearches, setRecentSearches] = useState<string[]>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('dealix-recent-searches');
return saved ? JSON.parse(saved) : [];
}
return [];
});
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const label = (ar: string, en: string) => (isArabic ? ar : en);
// Filter results
const filtered = query.trim().length > 0
? allResults.filter((r) =>
r.name.toLowerCase().includes(query.toLowerCase()) ||
r.nameEn.toLowerCase().includes(query.toLowerCase())
)
: [];
// Group by category
const grouped = filtered.reduce<Record<ResultCategory, SearchResult[]>>((acc, r) => {
if (!acc[r.category]) acc[r.category] = [];
acc[r.category].push(r);
return acc;
}, {} as Record<ResultCategory, SearchResult[]>);
const flatResults = Object.values(grouped).flat();
// Focus input when opened
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 100);
setQuery('');
setSelectedIndex(0);
}
}, [open]);
// Save recent search
const saveRecent = useCallback((term: string) => {
if (!term.trim()) return;
const updated = [term, ...recentSearches.filter((s) => s !== term)].slice(0, 5);
setRecentSearches(updated);
if (typeof window !== 'undefined') {
localStorage.setItem('dealix-recent-searches', JSON.stringify(updated));
}
}, [recentSearches]);
// Keyboard nav
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, flatResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && flatResults[selectedIndex]) {
saveRecent(query);
// Would navigate to result in real app
onClose();
} else if (e.key === 'Escape') {
onClose();
}
}
// Scroll selected into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
/>
{/* Panel */}
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.97 }}
transition={{ duration: 0.15 }}
className="fixed top-[10%] start-[50%] -translate-x-1/2 rtl:translate-x-1/2 w-full max-w-2xl z-50"
>
<div className="mx-4 rounded-2xl bg-slate-900/95 backdrop-blur-2xl border border-white/10 shadow-2xl shadow-black/50 overflow-hidden">
{/* Search input */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-slate-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
placeholder={label('ابحث في العملاء، الصفقات، الشركات...', 'Search leads, deals, companies...')}
className="flex-1 bg-transparent text-white placeholder-slate-500 text-sm focus:outline-none"
/>
<kbd className="hidden sm:inline-flex items-center px-2 py-0.5 rounded bg-white/5 border border-white/10 text-[10px] text-slate-500 font-mono">
ESC
</kbd>
</div>
{/* Results area */}
<div ref={listRef} className="max-h-80 overflow-y-auto">
{query.trim().length === 0 ? (
/* Recent searches */
<div className="p-4">
{recentSearches.length > 0 ? (
<>
<p className="text-xs text-slate-500 font-medium mb-2">
{label('عمليات بحث سابقة', 'Recent Searches')}
</p>
{recentSearches.map((s, i) => (
<button
key={i}
onClick={() => setQuery(s)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{s}
</button>
))}
</>
) : (
<p className="text-center text-sm text-slate-500 py-6">
{label('اكتب للبحث...', 'Type to search...')}
</p>
)}
</div>
) : flatResults.length === 0 ? (
/* Empty state */
<div className="py-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" className="w-10 h-10 mx-auto text-slate-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<p className="text-sm text-slate-500">{label('لا توجد نتائج', 'No results found')}</p>
<p className="text-xs text-slate-600 mt-1">{label('جرب كلمات بحث مختلفة', 'Try different search terms')}</p>
</div>
) : (
/* Grouped results */
<div className="py-2">
{(Object.keys(grouped) as ResultCategory[]).map((cat) => {
const cfg = categoryConfig[cat];
return (
<div key={cat}>
<p className="px-5 py-1.5 text-xs font-semibold text-slate-500">
{label(cfg.labelAr, cfg.labelEn)}
</p>
{grouped[cat].map((r) => {
const globalIdx = flatResults.indexOf(r);
const isSelected = globalIdx === selectedIndex;
return (
<button
key={r.id}
data-index={globalIdx}
onClick={() => { saveRecent(query); onClose(); }}
onMouseEnter={() => setSelectedIndex(globalIdx)}
className={`w-full flex items-center gap-3 px-5 py-2.5 text-start transition-colors ${isSelected ? 'bg-white/5' : 'hover:bg-white/[0.03]'}`}
>
<span className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm ${cfg.color.split(' ').slice(0, 2).join(' ')}`}>
{cfg.icon}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{label(r.name, r.nameEn)}</p>
<p className="text-xs text-slate-500 truncate">{label(r.lastActivity, r.lastActivityEn)}</p>
</div>
<span className={`shrink-0 text-[10px] font-semibold px-2 py-0.5 rounded-full border ${cfg.color}`}>
{label(cfg.labelAr, cfg.labelEn)}
</span>
</button>
);
})}
</div>
);
})}
</div>
)}
</div>
{/* Footer hint */}
<div className="px-5 py-2.5 border-t border-white/10 flex items-center gap-4 text-[10px] text-slate-500">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 font-mono"></kbd>
{label('تنقل', 'Navigate')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 font-mono"></kbd>
{label('فتح', 'Open')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 font-mono">ESC</kbd>
{label('إغلاق', 'Close')}
</span>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@ -24,3 +24,6 @@ export type { EmptyStateProps } from './empty-state';
export { CommandInput } from './command-input';
export type { CommandInputProps } from './command-input';
export { ToastProvider, useToast } from './toast';
export type { ToastType, Toast, ToastContextType } from './toast';

View File

@ -0,0 +1,140 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
interface ToastContextType {
toast: (type: ToastType, message: string, duration?: number) => void;
dismiss: (id: string) => void;
}
/* ------------------------------------------------------------------ */
/* Config */
/* ------------------------------------------------------------------ */
const typeConfig: Record<ToastType, { icon: ReactNode; borderColor: string; iconBg: string }> = {
success: {
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
),
borderColor: 'border-emerald-500/30',
iconBg: 'bg-emerald-500/20 text-emerald-400',
},
error: {
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
),
borderColor: 'border-red-500/30',
iconBg: 'bg-red-500/20 text-red-400',
},
warning: {
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
),
borderColor: 'border-amber-500/30',
iconBg: 'bg-amber-500/20 text-amber-400',
},
info: {
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
),
borderColor: 'border-blue-500/30',
iconBg: 'bg-blue-500/20 text-blue-400',
},
};
/* ------------------------------------------------------------------ */
/* Context */
/* ------------------------------------------------------------------ */
const ToastContext = createContext<ToastContextType | null>(null);
let idCounter = 0;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((type: ToastType, message: string, duration = 5000) => {
const id = `toast-${++idCounter}`;
setToasts((prev) => [...prev, { id, type, message, duration }]);
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}
}, [dismiss]);
return (
<ToastContext.Provider value={{ toast: addToast, dismiss }}>
{children}
{/* Toast container -- bottom-end (RTL-safe) */}
<div className="fixed bottom-4 end-4 z-[200] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
<AnimatePresence mode="popLayout">
{toasts.map((t) => {
const cfg = typeConfig[t.type];
return (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, x: 40, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 40, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
className={`pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-xl bg-slate-900/95 backdrop-blur-2xl border ${cfg.borderColor} shadow-xl shadow-black/30`}
>
<span className={`shrink-0 w-7 h-7 rounded-lg flex items-center justify-center ${cfg.iconBg}`}>
{cfg.icon}
</span>
<p className="flex-1 text-sm text-slate-200 leading-snug pt-0.5">{t.message}</p>
<button
onClick={() => dismiss(t.id)}
className="shrink-0 p-0.5 rounded hover:bg-white/10 text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextType {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
export type { ToastType, Toast, ToastContextType };