Browse Source
feat(support): add support channels UI with 5 languages, country dial codes, and whatsapp testing
master
feat(support): add support channels UI with 5 languages, country dial codes, and whatsapp testing
master
7 changed files with 764 additions and 220 deletions
-
2.env.example
-
2src/components/layout/ServerStatusDialog.tsx
-
2src/config/navigation.ts
-
227src/features/services/ServicesPage.tsx
-
747src/features/services/components/SupportServiceView.tsx
-
2src/icons.tsx
-
2vite.config.ts
@ -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 |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue