Browse Source

feat(support): add support channels UI with 5 languages, country dial codes, and whatsapp testing

master
parent
commit
64af6b3676
  1. 2
      .env.example
  2. 2
      src/components/layout/ServerStatusDialog.tsx
  3. 2
      src/config/navigation.ts
  4. 227
      src/features/services/ServicesPage.tsx
  5. 747
      src/features/services/components/SupportServiceView.tsx
  6. 2
      src/icons.tsx
  7. 2
      vite.config.ts

2
.env.example

@ -3,4 +3,4 @@
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
# مقصد پراکسی توسعه Vite برای هدایت درخواست‌های /api به سرور بک‌اند # مقصد پراکسی توسعه Vite برای هدایت درخواست‌های /api به سرور بک‌اند
VITE_API_TARGET=http://localhost:8000
VITE_API_TARGET=http://localhost:8001

2
src/components/layout/ServerStatusDialog.tsx

@ -127,7 +127,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<div className="space-y-1.5 font-mono text-[11px]" dir="ltr"> <div className="space-y-1.5 font-mono text-[11px]" dir="ltr">
<div className="flex justify-between items-center text-grey-2"> <div className="flex justify-between items-center text-grey-2">
<span className="text-grey-3 font-sans text-xs">{t('serverStatus.targetServer')}:</span> <span className="text-grey-3 font-sans text-xs">{t('serverStatus.targetServer')}:</span>
<span className="text-foreground">http://127.0.0.1:8000</span>
<span className="text-foreground">http://127.0.0.1:8001</span>
</div> </div>
<div className="flex justify-between items-center text-grey-2"> <div className="flex justify-between items-center text-grey-2">
<span className="text-grey-3 font-sans text-xs">{t('serverStatus.apiPrefix')}:</span> <span className="text-grey-3 font-sans text-xs">{t('serverStatus.apiPrefix')}:</span>

2
src/config/navigation.ts

@ -128,7 +128,7 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
], ],
}, },
{ title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' }, { title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' },
{ title: t('navigation.support'), titleKey: 'navigation.support', isExternalSupport: true, icon: 'headset' },
{ title: t('navigation.support'), titleKey: 'navigation.support', to: '/services/consultant', icon: 'headset' },
{ title: t('navigation.faq'), titleKey: 'navigation.faq', to: '/services/faq', icon: 'helpCircle' }, { title: t('navigation.faq'), titleKey: 'navigation.faq', to: '/services/faq', icon: 'helpCircle' },
{ title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' }, { title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' },
], ],

227
src/features/services/ServicesPage.tsx

@ -14,6 +14,7 @@ import { CategoriesServiceView } from './components/CategoriesServiceView'
import { CitiesServiceView } from './components/CitiesServiceView' import { CitiesServiceView } from './components/CitiesServiceView'
import { FaqServiceView } from './components/FaqServiceView' import { FaqServiceView } from './components/FaqServiceView'
import { StoreServiceView } from './components/StoreServiceView' import { StoreServiceView } from './components/StoreServiceView'
import { SupportServiceView } from './components/SupportServiceView'
// تعریف ساختار داده‌های سرویس‌ها // تعریف ساختار داده‌های سرویس‌ها
export interface ServiceMeta { export interface ServiceMeta {
@ -166,10 +167,10 @@ export const SERVICES_LIST: ServiceMeta[] = [
color: 'text-primary', color: 'text-primary',
bgColor: 'bg-primary/10', bgColor: 'bg-primary/10',
borderColor: 'border-primary/20', borderColor: 'border-primary/20',
description: 'شبکه کارشناسان تخصصی پزشکی سفر، احکام، امور حقوقی، گمشدگان و پشتیبانی 24 ساعته زائرین',
description: 'مدیریت کانال‌های تماس، راهنمای پشتیبانی زائرین، پاسخگویی ۵ زبانه و کنترل وضعیت فعال/غیرفعال‌سازی در اپلیکیشن',
endpoint: '/consultants/', endpoint: '/consultants/',
statsLabel: 'کارشناس فعال',
defaultCount: 12,
statsLabel: 'کانال فعال',
defaultCount: 5,
badge: 'پشتیبانی زنده', badge: 'پشتیبانی زنده',
}, },
] ]
@ -182,6 +183,11 @@ export default function ServicesPage() {
return <Navigate to="/services/store/products" replace /> return <Navigate to="/services/store/products" replace />
} }
// اگر مسیر /services/support فراخوانی شد، به صفحه پشتیبانی هدایت شود
if (serviceKey === 'support') {
return <Navigate to="/services/consultant" replace />
}
const currentService = useMemo(() => { const currentService = useMemo(() => {
if (!serviceKey) return null if (!serviceKey) return null
const base = SERVICES_LIST.find((s) => s.key === serviceKey) const base = SERVICES_LIST.find((s) => s.key === serviceKey)
@ -284,7 +290,7 @@ function ServiceDetailRenderer({
case 'trip-etiquette': case 'trip-etiquette':
return <DuasServiceView /> return <DuasServiceView />
case 'consultant': case 'consultant':
return <ConsultantServiceView service={service} />
return <SupportServiceView />
case 'faq': case 'faq':
return <FaqServiceView /> return <FaqServiceView />
case 'store': case 'store':
@ -629,219 +635,8 @@ function TripEtiquetteServiceView({ service }: { service: ServiceMeta }) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ۵. مشاوره و پشتیبانی (Consultant / Support)
// ۵. مشاوره و پشتیبانی (Support) در components/SupportServiceView پیاده‌سازی شده است
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function ConsultantServiceView({ service }: { service: ServiceMeta }) {
const [searchTerm, setSearchTerm] = useState('')
const [specialtyFilter, setSpecialtyFilter] = useState<string>('ALL')
const consultants = [
{ id: 1, name: 'دکتر علیرضا رضایی', specialty: 'پزشکی سفر، گرمازدگی و مراقبت‌های بهداشتی', category: 'medical', status: 'آنلاین و آماده پاسخ', isOnline: true, activeChats: 4, satisfaction: '99%', experience: '8 سال' },
{ id: 2, name: 'حجت‌الاسلام سید هادی موسوی', specialty: 'احکام و مناسک حج، عمره و عتبات عالیات', category: 'rituals', status: 'آنلاین و آماده پاسخ', isOnline: true, activeChats: 6, satisfaction: '98%', experience: '12 سال' },
{ id: 3, name: 'مهندس مهدی کمالی', specialty: 'لجستیک، حمل‌ونقل بین‌شهری، گذرنامه و امور گمشدگان', category: 'logistics', status: 'مشغول در تماس', isOnline: false, activeChats: 2, satisfaction: '97%', experience: '5 سال' },
{ id: 4, name: 'سرکار خانم فاطمه سعیدی', specialty: 'مشاوره خانواده، بانوان، اقامت و کودکان در سفر', category: 'family', status: 'آنلاین و آماده پاسخ', isOnline: true, activeChats: 3, satisfaction: '100%', experience: '6 سال' },
]
const filteredConsultants = useMemo(() => {
return consultants.filter((c) => {
if (specialtyFilter !== 'ALL' && c.category !== specialtyFilter) return false
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase().trim()
const matchName = c.name.toLowerCase().includes(q)
const matchSpec = c.specialty.toLowerCase().includes(q)
if (!matchName && !matchSpec) return false
}
return true
})
}, [consultants, specialtyFilter, searchTerm])
return (
<div className="space-y-6">
{/* ۱. کارت‌های شاخص عملکرد مرکز پشتیبانی و مشاوره */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">مشاوران شیفت جاری</span>
<span className="flex items-center gap-1.5 text-[10px] text-emerald-600 dark:text-emerald-400 font-bold">
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
آنلاین
</span>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(8)} <span className="text-xs text-grey-3 font-normal">نفر</span>
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
از 12 کارشناس پشتیبانی 24 ساعته
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">مشاوره‌های امروز</span>
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20 text-[10px] font-bold">
موفق
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">
{toFa(64)}
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
پاسخگویی به مسائل بهداشتی و شرعی
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">میانگین زمان انتظار</span>
<Ic name="clock" className="size-3.5 text-primary" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
45 <span className="text-xs text-grey-3 font-normal">ثانیه</span>
</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
استاندارد عالی (زیر 1 دقیقه)
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">رضایت‌مندی زائرین</span>
<Ic name="star" className="size-3.5 text-amber-500" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
98.7%
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
بر اساس 1,420 بازخورد ثبت‌شده
</div>
</div>
</div>
{/* ۲. فیلترها و نوار جستجوی کارشناسان */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setSpecialtyFilter('ALL')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'ALL'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
همه تخصص‌ها
</button>
<button
onClick={() => setSpecialtyFilter('medical')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'medical'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
پزشکی و بهداشت
</button>
<button
onClick={() => setSpecialtyFilter('rituals')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'rituals'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
احکام و مناسک
</button>
<button
onClick={() => setSpecialtyFilter('logistics')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'logistics'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
لجستیک و گمشدگان
</button>
</div>
<div className="relative min-w-[220px]">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی نام یا حوزه تخصص..."
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground p-0.5"
>
<Ic name="close" className="size-3" />
</button>
)}
</div>
</div>
{/* ۳. کارت‌های کارشناسان با استایل مدرن */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-black text-foreground">کادر کارشناسان پشتیبانی و مشاوره عقیله</h3>
<Badge variant="secondary" className="text-[11px] font-mono bg-primary/10 text-primary border-0">
{toFa(filteredConsultants.length)} کارشناس
</Badge>
</div>
<span className="text-xs text-grey-3">پشتیبانی زنده صوتی و متنی زائرین</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{filteredConsultants.map((c) => (
<div
key={c.id}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4 shadow-xs hover:border-primary/40 transition flex flex-col justify-between"
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="size-11 rounded-2xl bg-primary/10 text-primary flex items-center justify-center font-bold text-base shrink-0">
<Ic name="headset" className="size-5" />
</div>
<Badge
className={`text-[10px] rounded-full border ${
c.isOnline
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'
}`}
>
{c.status}
</Badge>
</div>
<div>
<h4 className="text-sm font-black text-foreground">{c.name}</h4>
<p className="text-xs text-grey-3 mt-1 leading-relaxed line-clamp-2">{c.specialty}</p>
</div>
</div>
<div className="space-y-3 pt-3 border-t border-border-soft text-xs">
<div className="flex items-center justify-between text-grey-3">
<span>گفتگوهای فعال:</span>
<span className="font-mono font-bold text-foreground tabular-nums">{toFa(c.activeChats)} جلسه</span>
</div>
<div className="flex items-center justify-between text-grey-3">
<span>رضایت مخاطبان:</span>
<span className="font-mono font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">{c.satisfaction}</span>
</div>
<div className="flex items-center justify-between text-grey-3">
<span>سابقه تخصصی:</span>
<span className="font-mono text-grey-2">{c.experience}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ۶. فروشگاه معصومه (Massoumah Store) در components/StoreServiceView پیاده‌سازی شده است // ۶. فروشگاه معصومه (Massoumah Store) در components/StoreServiceView پیاده‌سازی شده است

747
src/features/services/components/SupportServiceView.tsx

@ -0,0 +1,747 @@
import React, { useState, useMemo } from 'react'
import { Ic, type IconName } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { toFa, cn } from '@/lib/utils'
import { toast } from '@/stores/toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
ServiceTranslation,
type ServiceLanguageCode,
SERVICE_LANGUAGES,
} from '@/components/shared/ServiceTranslation'
export interface SupportChannel {
id: string
icon: string
phoneNumber: string
whatsappNumber: string
order: number
isActive: boolean
titles: Record<ServiceLanguageCode, string>
descriptions: Record<ServiceLanguageCode, string>
}
const INITIAL_SUPPORT_CHANNELS: SupportChannel[] = [
{
id: 'ch-1',
icon: 'ticket',
phoneNumber: '+442071234577',
whatsappNumber: '+442071234577',
order: 1,
isActive: true,
titles: {
ar: 'الحجز والتذاكر',
fa: 'رزرو و خرید بلیت',
en: 'Booking & Reservation',
ru: 'Бронирование и билеты',
id: 'Pemesanan & Reservasi',
},
descriptions: {
ar: 'حجز تذاكر الطيران، القطارات، تأكيد المواعيد وإلغاء أو تعديل الحجوزات',
fa: 'صدور و پیگیری بلیت پرواز، قطار، استرداد و تغییر زمان حرکت',
en: 'Flight & train ticket reservations, confirmation and scheduling support',
ru: 'Бронирование авиа и ж/д билетов, подтверждение и изменение рейсов',
id: 'Pemesanan tiket pesawat & kereta, konfirmasi dan perubahan jadwal',
},
},
{
id: 'ch-2',
icon: 'calendar',
phoneNumber: '+442071234547',
whatsappNumber: '+442071234547',
order: 2,
isActive: true,
titles: {
ar: 'الرحلة وجدول السفر',
fa: 'برنامه و هماهنگی سفر',
en: 'Trip & Itinerary',
ru: 'Поездка и маршруت',
id: 'Jadwal Perjalanan',
},
descriptions: {
ar: 'تنظيم مواعيد الرحلات، برامج الزيارات الميدانية ومرافقة القوافل',
fa: 'هماهنگی جدول زمانی کاروان‌ها، گشت‌های زیارتی و هماهنگی تورلیدر',
en: 'Travel schedules, group coordination, ziyarah guides and itineraries',
ru: 'Расписание поездок, координация групп и экскурсионных программ',
id: 'Jadwal perjalanan, koordinasi rombongan dan panduan ziarah',
},
},
{
id: 'ch-3',
icon: 'building',
phoneNumber: '+442071234560',
whatsappNumber: '+442071234560',
order: 3,
isActive: false,
titles: {
ar: 'الإقامة والمواصلات',
fa: 'اقامت و ترانسفر',
en: 'Accommodation & Transportation',
ru: 'Проживание и транспорт',
id: 'Akomodasi & Transportasi',
},
descriptions: {
ar: 'حجز الفنادق القريبة من الحرمين، الشقق المفروشة والتنقل بين المدن',
fa: 'هتل‌های نزدیک به حرم، ترانسفر فرودگاهی و تاکسی‌های بین‌شهری',
en: 'Hotel reservations near holy shrines, VIP transit and airport transfers',
ru: 'Отели рядом со святынями, трансфер из аэропорта и между городами',
id: 'Reservasi hotel dekat makam suci, transportasi bandara dan antar kota',
},
},
{
id: 'ch-4',
icon: 'plane',
phoneNumber: '+442071234590',
whatsappNumber: '+442071234590',
order: 4,
isActive: true,
titles: {
ar: 'التأشيرة وجواز السفر',
fa: 'ویزا و گذرنامه',
en: 'Visa and Passport',
ru: 'Виза и загранпаспорт',
id: 'Visa dan Paspor',
},
descriptions: {
ar: 'إجراءات استخراج وتجديد التأشيرات السياحية والدينية والاستشارات القنصلية',
fa: 'پیگیری روادید الکترونیک، تمدید اقامت، امور کنسولی و گذرنامه‌های مفقودی',
en: 'Visa processing, stay extensions, embassy affairs and lost passport aid',
ru: 'Оформление электронных виз, продление пребывания и консульская помощь',
id: 'Pengurusan visa ziarah, perpanjangan izin tinggal dan bantuan paspor',
},
},
{
id: 'ch-5',
icon: 'shrine',
phoneNumber: '+442071234555',
whatsappNumber: '+442071234555',
order: 5,
isActive: true,
titles: {
ar: 'المسائل والأحكام الشرعية',
fa: 'احکام و مسائل شرعی',
en: 'Religious Issues',
ru: 'Религиозные вопросы',
id: 'Masalah Keagamaan',
},
descriptions: {
ar: 'الاستفتاءات الشرعية، أحكام صلاة وقصر المسافر، ومناسك الحج والعمرة والزيارة',
fa: 'پاسخ به سوالات شرعی، احکام نماز و روزه مسافر، مناسک و آداب زیارت',
en: 'Religious decrees, prayer travel rulings, and pilgrim etiquette and rituals',
ru: 'Шариатские ответы, правила молитвы в пути и обряды паломничества',
id: 'Konsultasi hukum syariah, tata cara shalat musafir dan manasik ziarah',
},
},
]
const AVAILABLE_ICONS: { id: string; label: string; icon: IconName }[] = [
{ id: 'ticket', label: 'بلیت', icon: 'ticket' },
{ id: 'calendar', label: 'تقویم', icon: 'calendar' },
{ id: 'building', label: 'هتل', icon: 'building' },
{ id: 'plane', label: 'ویزا', icon: 'plane' },
{ id: 'shrine', label: 'احکام', icon: 'shrine' },
{ id: 'headset', label: 'پشتیبانی', icon: 'headset' },
{ id: 'phone', label: 'تماس', icon: 'phone' },
]
export const COUNTRY_DIAL_CODES = [
{ code: 'GB', name: 'انگلستان', flag: '🇬🇧', dialCode: '+44' },
{ code: 'IQ', name: 'عراق', flag: '🇮🇶', dialCode: '+964' },
{ code: 'IR', name: 'ایران', flag: '🇮🇷', dialCode: '+98' },
{ code: 'SA', name: 'عربستان', flag: '🇸🇦', dialCode: '+966' },
{ code: 'AE', name: 'امارات', flag: '🇦🇪', dialCode: '+971' },
{ code: 'KW', name: 'کویت', flag: '🇰🇼', dialCode: '+965' },
{ code: 'BH', name: 'بحرین', flag: '🇧🇭', dialCode: '+973' },
{ code: 'OM', name: 'عمان', flag: '🇴🇲', dialCode: '+968' },
{ code: 'QA', name: 'قطر', flag: '🇶🇦', dialCode: '+974' },
{ code: 'TR', name: 'ترکیه', flag: '🇹🇷', dialCode: '+90' },
{ code: 'LB', name: 'لبنان', flag: '🇱🇧', dialCode: '+961' },
{ code: 'SY', name: 'سوریه', flag: '🇸🇾', dialCode: '+963' },
{ code: 'RU', name: 'روسیه', flag: '🇷🇺', dialCode: '+7' },
{ code: 'ID', name: 'اندونزی', flag: '🇮🇩', dialCode: '+62' },
{ code: 'PK', name: 'پاکستان', flag: '🇵🇰', dialCode: '+92' },
{ code: 'IN', name: 'هند', flag: '🇮🇳', dialCode: '+91' },
{ code: 'AF', name: 'افغانستان', flag: '🇦🇫', dialCode: '+93' },
{ code: 'EG', name: 'مصر', flag: '🇪🇬', dialCode: '+20' },
{ code: 'DE', name: 'آلمان', flag: '🇩🇪', dialCode: '+49' },
{ code: 'FR', name: 'فرانسه', flag: '🇫🇷', dialCode: '+33' },
{ code: 'US', name: 'آمریکا / کانادا', flag: '🇺🇸', dialCode: '+1' },
]
function splitPhoneNumber(fullPhone: string): { dialCode: string; body: string } {
const clean = (fullPhone || '').trim().replace(/^00/, '+')
if (!clean) return { dialCode: '+44', body: '' }
const sorted = [...COUNTRY_DIAL_CODES].sort((a, b) => b.dialCode.length - a.dialCode.length)
for (const c of sorted) {
if (clean.startsWith(c.dialCode)) {
return { dialCode: c.dialCode, body: clean.slice(c.dialCode.length).replace(/[^\d]/g, '') }
}
}
return { dialCode: '+44', body: clean.replace(/[^\d]/g, '') }
}
export function SupportServiceView() {
const [channels, setChannels] = useState<SupportChannel[]>(() => {
try {
const saved = localStorage.getItem('aqila_support_channels')
if (saved) return JSON.parse(saved)
} catch {
// fallback
}
return INITIAL_SUPPORT_CHANNELS
})
const persistChannels = (updated: SupportChannel[]) => {
setChannels(updated)
try {
localStorage.setItem('aqila_support_channels', JSON.stringify(updated))
} catch {
// ignore
}
}
// زبان پیش‌نمایش کارت‌ها (پیش‌فرض عربی مطابق اپلیکیشن)
const [previewLang, setPreviewLang] = useState<ServiceLanguageCode>('ar')
// فیلترها
const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL')
const [searchQuery, setSearchQuery] = useState('')
// دیالوگ افزودن / ویرایش
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingChannel, setEditingChannel] = useState<SupportChannel | null>(null)
const [dialogLangTab, setDialogLangTab] = useState<ServiceLanguageCode>('ar')
const [formTitles, setFormTitles] = useState<Record<ServiceLanguageCode, string>>({
ar: '', fa: '', en: '', ru: '', id: '',
})
const [formDescriptions, setFormDescriptions] = useState<Record<ServiceLanguageCode, string>>({
ar: '', fa: '', en: '', ru: '', id: '',
})
const [formCountryDialCode, setFormCountryDialCode] = useState('+44')
const [formPhoneBody, setFormPhoneBody] = useState('')
const [formIcon, setFormIcon] = useState('headset')
const [formOrder, setFormOrder] = useState<number>(1)
const [formIsActive, setFormIsActive] = useState(true)
const handleOpenCreate = () => {
setEditingChannel(null)
setDialogLangTab('ar')
setFormTitles({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormDescriptions({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormCountryDialCode('+44')
setFormPhoneBody('')
setFormIcon('headset')
setFormOrder(channels.length + 1)
setFormIsActive(true)
setIsDialogOpen(true)
}
const handleOpenEdit = (channel: SupportChannel) => {
setEditingChannel(channel)
setDialogLangTab('ar')
setFormTitles({ ...channel.titles })
setFormDescriptions({ ...channel.descriptions })
const parsed = splitPhoneNumber(channel.phoneNumber)
setFormCountryDialCode(parsed.dialCode)
setFormPhoneBody(parsed.body)
setFormIcon(channel.icon)
setFormOrder(channel.order)
setFormIsActive(channel.isActive)
setIsDialogOpen(true)
}
const handleTestWhatsAppInModal = () => {
const cleanBody = formPhoneBody.replace(/\D/g, '')
if (!cleanBody) {
toast.error('لطفاً شماره تماس را وارد نمایید.')
return
}
const full = `${formCountryDialCode.replace('+', '')}${cleanBody}`
window.open(`https://wa.me/${full}`, '_blank')
toast.success(`چت واتس‌اپ برای شماره ${formCountryDialCode} ${cleanBody} باز شد.`)
}
const handleSaveChannel = (e: React.FormEvent) => {
e.preventDefault()
if (!formTitles.ar.trim() && !formTitles.fa.trim() && !formTitles.en.trim()) {
toast.error('لطفاً عنوان کانال را وارد کنید.')
return
}
const cleanBody = formPhoneBody.replace(/\D/g, '')
if (!cleanBody) {
toast.error('لطفاً شماره تماس را وارد کنید.')
return
}
const fullPhone = `${formCountryDialCode}${cleanBody}`
if (editingChannel) {
const updated = channels.map((c) =>
c.id === editingChannel.id
? {
...c,
icon: formIcon,
phoneNumber: fullPhone,
whatsappNumber: fullPhone,
order: formOrder,
isActive: formIsActive,
titles: { ...formTitles },
descriptions: { ...formDescriptions },
}
: c
)
persistChannels(updated)
toast.success('کانال پشتیبانی به‌روزرسانی شد.')
} else {
const newChannel: SupportChannel = {
id: `ch-${Date.now()}`,
icon: formIcon,
phoneNumber: fullPhone,
whatsappNumber: fullPhone,
order: formOrder,
isActive: formIsActive,
titles: { ...formTitles },
descriptions: { ...formDescriptions },
}
persistChannels([...channels, newChannel])
toast.success('کانال پشتیبانی با موفقیت اضافه شد.')
}
setIsDialogOpen(false)
}
const handleToggleActive = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation()
const updated = channels.map((c) => {
if (c.id === id) {
const nextState = !c.isActive
toast.success(nextState ? 'کانال فعال شد.' : 'کانال غیرفعال شد.')
return { ...c, isActive: nextState }
}
return c
})
persistChannels(updated)
}
const handleDeleteChannel = (channel: SupportChannel) => {
if (confirm(`آیا از حذف این کانال اطمینان دارید؟`)) {
const updated = channels.filter((c) => c.id !== channel.id)
persistChannels(updated)
toast.success('کانال پشتیبانی حذف شد.')
}
}
const handleTestWhatsApp = (phone: string) => {
const cleanNumber = phone.replace(/[^\d+]/g, '')
const url = `https://wa.me/${cleanNumber.replace('+', '')}`
window.open(url, '_blank')
}
const filteredChannels = useMemo(() => {
return channels
.filter((c) => {
if (statusFilter === 'ACTIVE' && !c.isActive) return false
if (statusFilter === 'INACTIVE' && c.isActive) return false
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim()
const matchPhone = c.phoneNumber.includes(q)
const matchTitle = Object.values(c.titles).some((t) => t.toLowerCase().includes(q))
if (!matchPhone && !matchTitle) return false
}
return true
})
.sort((a, b) => a.order - b.order)
}, [channels, statusFilter, searchQuery])
const activeCount = channels.filter((c) => c.isActive).length
const inactiveCount = channels.length - activeCount
return (
<div className="space-y-4">
{/* نوار کنترل مینیمال و یکپارچه */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 rounded-2xl border border-border-soft bg-card/40 p-3 backdrop-blur-xs">
{/* فیلتر وضعیت و پیش‌نمایش زبان */}
<div className="flex items-center gap-2 flex-wrap">
{/* تب‌های وضعیت */}
<div className="flex items-center rounded-xl border border-border-soft bg-surface-base p-0.5">
<button
onClick={() => setStatusFilter('ALL')}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-bold transition',
statusFilter === 'ALL'
? 'bg-card text-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground'
)}
>
همه ({toFa(channels.length)})
</button>
<button
onClick={() => setStatusFilter('ACTIVE')}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-bold transition flex items-center gap-1',
statusFilter === 'ACTIVE'
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
: 'text-grey-3 hover:text-foreground'
)}
>
<span className="size-1.5 rounded-full bg-emerald-500" />
<span>فعال ({toFa(activeCount)})</span>
</button>
<button
onClick={() => setStatusFilter('INACTIVE')}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-bold transition flex items-center gap-1',
statusFilter === 'INACTIVE'
? 'bg-zinc-500/15 text-zinc-400'
: 'text-grey-3 hover:text-foreground'
)}
>
<span>غیرفعال ({toFa(inactiveCount)})</span>
</button>
</div>
<div className="h-5 w-px bg-border-soft hidden sm:block" />
{/* انتخابگر زبان پیش‌نمایش ۵ زبانه */}
<div className="flex items-center gap-1">
{SERVICE_LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => setPreviewLang(l.code)}
title={l.name}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-bold transition',
previewLang === l.code
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground hover:bg-surface-base'
)}
>
<span>{l.flag}</span>
<span>{l.badge}</span>
</button>
))}
</div>
</div>
{/* جستجو و دکمه افزودن */}
<div className="flex items-center gap-2">
<div className="relative min-w-[180px] flex-1 sm:flex-none">
<Ic name="search" className="absolute start-2.5 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="جستجو..."
className="h-8 ps-8 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
<Button
onClick={handleOpenCreate}
size="sm"
className="rounded-xl text-xs gap-1.5 h-8 font-bold bg-primary text-primary-foreground hover:bg-primary/90 shrink-0"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن</span>
</Button>
</div>
</div>
{/* کارت‌های ساده، خلوت و مدرن کانال‌ها */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredChannels.map((channel) => {
const title = channel.titles[previewLang] || channel.titles.ar || channel.titles.fa
const desc = channel.descriptions[previewLang] || channel.descriptions.ar
return (
<Card
key={channel.id}
className={cn(
'rounded-2xl border transition p-4 flex flex-col justify-between shadow-xs',
channel.isActive
? 'border-border-soft bg-card/40 hover:border-primary/40'
: 'border-dashed border-border-soft/60 bg-card/15 opacity-65'
)}
>
<CardContent className="p-0 space-y-3">
{/* ردیف اول: آیکون، عنوان و سوئیچ وضعیت */}
<div className="flex items-start justify-between gap-2.5">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name={channel.icon as IconName || 'headset'} className="size-5" />
</div>
<div>
<h4 className="text-sm font-black text-foreground line-clamp-1">
{title}
</h4>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-grey-3 font-mono">
#{toFa(channel.order)}
</span>
<span
className={cn(
'text-[10px] font-bold',
channel.isActive ? 'text-emerald-500' : 'text-zinc-500'
)}
>
{channel.isActive ? 'فعال' : 'غیرفعال'}
</span>
</div>
</div>
</div>
<Switch
checked={channel.isActive}
onCheckedChange={() => handleToggleActive(channel.id)}
className="data-[state=checked]:bg-emerald-500"
/>
</div>
{/* توضیحات مختصر در صورت وجود */}
{desc && (
<p className="text-xs text-grey-3 line-clamp-1 leading-relaxed">
{desc}
</p>
)}
{/* ردیف شماره تماس و اکشن‌ها */}
<div className="flex items-center justify-between pt-2.5 border-t border-border-soft text-xs">
<button
onClick={() => handleTestWhatsApp(channel.whatsappNumber || channel.phoneNumber)}
className="flex items-center gap-1.5 font-mono dir-ltr text-grey-3 hover:text-emerald-500 transition text-[11px]"
title="ارسال پیام واتس‌اپ"
>
<Ic name="whatsapp" className="size-3.5 text-emerald-500 shrink-0" />
<span>{channel.phoneNumber}</span>
</button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(channel)}
className="size-7 p-0 rounded-lg text-primary hover:bg-primary/10"
title="ویرایش"
>
<Ic name="edit" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteChannel(channel)}
className="size-7 p-0 rounded-lg text-rose-500 hover:bg-rose-500/10"
title="حذف"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* دیالوگ تمیز افزودن و ویرایش */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto rounded-3xl p-5 bg-card border-border-soft">
<DialogHeader className="text-right pb-1">
<DialogTitle className="text-sm font-black text-foreground">
{editingChannel ? 'ویرایش کانال پشتیبانی' : 'افزودن کانال پشتیبانی'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSaveChannel} className="space-y-4 pt-2">
{/* تب‌های ۵ زبان با کامپوننت مشترک ServiceTranslation */}
<ServiceTranslation
activeLang={dialogLangTab}
onLangChange={(lang) => setDialogLangTab(lang)}
completedLangs={Object.fromEntries(
Object.entries(formTitles).map(([k, v]) => [k, Boolean(v?.trim())])
)}
primaryLang="ar"
title="زبان محتوا"
variant="grid"
/>
{/* عنوان به زبان فعال */}
{(() => {
const currentMeta = SERVICE_LANGUAGES.find((l) => l.code === dialogLangTab) || SERVICE_LANGUAGES[0]
return (
<div className="space-y-2.5">
<div className="space-y-1">
<label className="text-xs font-bold text-foreground block">
عنوان کانال ({currentMeta.name})
</label>
<Input
value={formTitles[dialogLangTab] || ''}
onChange={(e) =>
setFormTitles({ ...formTitles, [dialogLangTab]: e.target.value })
}
dir={currentMeta.dir}
placeholder={`عنوان به ${currentMeta.name}...`}
className="h-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grey-3 block">
توضیح کوتاه ({currentMeta.name})
</label>
<Input
value={formDescriptions[dialogLangTab] || ''}
onChange={(e) =>
setFormDescriptions({ ...formDescriptions, [dialogLangTab]: e.target.value })
}
dir={currentMeta.dir}
placeholder="توضیح کوتاه اختیاری..."
className="h-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
</div>
)
})()}
{/* شماره تماس همراه با انتخابگر کشور و پرچم + دکمه تست مستقیم واتس‌اپ */}
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-foreground flex items-center gap-1">
<span>شماره تماس و واتس‌اپ</span>
<span className="text-rose-500">*</span>
</label>
{/* دکمه اختصاصی باز کردن و تست مستقیم واتس‌اپ */}
<button
type="button"
onClick={handleTestWhatsAppInModal}
className="h-7 px-2.5 rounded-lg text-[11px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white flex items-center gap-1.5 transition active:scale-95 shadow-xs"
title="باز کردن مستقیم چت در واتس‌اپ"
>
<Ic name="whatsapp" className="size-3.5 text-white" />
<span>تست مستقیم واتس‌اپ</span>
</button>
</div>
{/* کامپوننت ترکیبی انتخاب کشور با پرچم + اینپوت شماره */}
<div className="flex items-center rounded-xl border border-border-soft bg-surface-base overflow-hidden focus-within:border-primary transition">
{/* سلکتور پیش‌شماره کشور همراه با پرچم */}
<div className="relative shrink-0 border-l border-border-soft bg-card/60">
<select
value={formCountryDialCode}
onChange={(e) => setFormCountryDialCode(e.target.value)}
className="h-9 bg-transparent px-2.5 text-xs font-mono font-bold text-foreground border-none outline-none cursor-pointer appearance-none pr-7 pl-2"
dir="ltr"
>
{COUNTRY_DIAL_CODES.map((c) => (
<option key={c.code} value={c.dialCode} className="bg-card text-foreground">
{c.flag} {c.dialCode} ({c.name})
</option>
))}
</select>
<Ic name="chevDown" className="size-3 text-grey-3 absolute end-2 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
{/* فیلد وارد کردن شماره تماس */}
<Input
value={formPhoneBody}
onChange={(e) => setFormPhoneBody(e.target.value)}
dir="ltr"
placeholder="2071234577"
className="h-9 border-0 bg-transparent text-xs font-mono focus-visible:ring-0 shadow-none px-3"
required
/>
</div>
{/* پیش‌نمایش زنده شماره */}
<div className="flex items-center justify-between text-[11px] text-grey-3 px-1">
<span>شماره نهایی:</span>
<span className="font-mono font-bold text-emerald-500 dir-ltr">
{formCountryDialCode} {formPhoneBody || '---'}
</span>
</div>
</div>
{/* ترتیب نمایش */}
<div className="space-y-1">
<label className="text-xs font-bold text-foreground block">
ترتیب نمایش در اپلیکیشن
</label>
<Input
type="number"
min="1"
value={formOrder}
onChange={(e) => setFormOrder(parseInt(e.target.value) || 1)}
className="h-9 text-xs bg-surface-base rounded-xl border-border-soft font-mono"
/>
</div>
</div>
{/* آیکون و وضعیت فعال */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">
انتخاب آیکون
</label>
<div className="grid grid-cols-7 gap-1.5">
{AVAILABLE_ICONS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setFormIcon(item.id)}
className={cn(
'p-2 rounded-xl border transition flex items-center justify-center',
formIcon === item.id
? 'border-primary bg-primary/10 text-primary'
: 'border-border-soft bg-surface-base text-grey-3 hover:text-foreground'
)}
title={item.label}
>
<Ic name={item.icon} className="size-4" />
</button>
))}
</div>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-2.5 flex items-center justify-between">
<span className="text-xs font-bold text-foreground">
وضعیت فعال در اپلیکیشن
</span>
<Switch
checked={formIsActive}
onCheckedChange={setFormIsActive}
className="data-[state=checked]:bg-emerald-500"
/>
</div>
<DialogFooter className="flex items-center justify-between gap-2 pt-2 border-t border-border-soft">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="rounded-xl text-xs h-8"
>
انصراف
</Button>
<Button
type="submit"
className="rounded-xl text-xs h-8 bg-primary text-primary-foreground hover:bg-primary/90 font-bold"
>
ذخیره
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}
export default SupportServiceView

2
src/icons.tsx

@ -113,6 +113,8 @@ const PATHS: Record<string, string> = {
train: '<rect width="16" height="16" x="4" y="3" rx="2"/><path d="M4 11h16"/><path d="M12 3v8"/><path d="m8 19-2 3"/><path d="m18 22-2-3"/><circle cx="8" cy="15" r="1"/><circle cx="16" cy="15" r="1"/>', train: '<rect width="16" height="16" x="4" y="3" rx="2"/><path d="M4 11h16"/><path d="M12 3v8"/><path d="m8 19-2 3"/><path d="m18 22-2-3"/><circle cx="8" cy="15" r="1"/><circle cx="16" cy="15" r="1"/>',
bus: '<path d="M8 6v6"/><path d="M15 6v6"/><path d="M2 12h19.6"/><path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v8c0 .4.1.8.2 1.2.3 1.1.8 2.8.8 2.8h3"/><circle cx="7" cy="18" r="2"/><path d="M9 18h5"/><circle cx="16" cy="18" r="2"/>', bus: '<path d="M8 6v6"/><path d="M15 6v6"/><path d="M2 12h19.6"/><path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v8c0 .4.1.8.2 1.2.3 1.1.8 2.8.8 2.8h3"/><circle cx="7" cy="18" r="2"/><path d="M9 18h5"/><circle cx="16" cy="18" r="2"/>',
building: '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>', building: '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
whatsapp: '<path d="M3 21l1.65-3.8a9 9 0 1 1 3.4 2.9L3 21"/><path d="M9 10a.5.5 0 0 0 1 0V9a.5.5 0 0 0-1 0v1a5 5 0 0 0 5 5h1a.5.5 0 0 0 0-1h-1a.5.5 0 0 0 0 1"/>',
} }
export type IconName = keyof typeof PATHS export type IconName = keyof typeof PATHS

2
vite.config.ts

@ -111,7 +111,7 @@ function addDataLocatorPlugin(): Plugin {
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
/* مقصد بک‌اند برای پراکسیِ dev — همهٔ '/api/*' بدون CORS به این‌جا می‌رود. */ /* مقصد بک‌اند برای پراکسیِ dev — همهٔ '/api/*' بدون CORS به این‌جا می‌رود. */
const apiTarget = env.VITE_API_TARGET ?? 'http://localhost:8000'
const apiTarget = env.VITE_API_TARGET ?? 'http://localhost:8001'
return { return {
plugins: [ plugins: [

Loading…
Cancel
Save