Browse Source

feat: add CustomTrip destination detail page with multi-language support and management tools

master
Muhammad A. Ghorbani 2 weeks ago
parent
commit
96ca543d00
  1. 281
      src/components/shared/CountryDialSelect.tsx
  2. 6
      src/components/ui/popup.tsx
  3. 2262
      src/data/countries.json
  4. 63
      src/data/countries.ts
  5. 264
      src/features/services/components/SupportServiceView.tsx
  6. 51
      src/features/services/services/support-api.ts
  7. 4
      src/index.css

281
src/components/shared/CountryDialSelect.tsx

@ -0,0 +1,281 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import { cn } from '@/lib/utils'
import {
ALL_COUNTRIES,
POPULAR_COUNTRIES,
normalizeDialDigits,
findCountryByDialCode,
type CountryDialInfo,
} from '@/data/countries'
export interface CountryDialSelectProps {
value: string
onChange: (dialCode: string, country?: CountryDialInfo) => void
disabled?: boolean
className?: string
buttonClassName?: string
}
export function CountryDialSelect({
value,
onChange,
disabled = false,
className,
buttonClassName,
}: CountryDialSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// Find currently selected country
const selectedCountry = useMemo(() => {
return findCountryByDialCode(value) || ALL_COUNTRIES[0]
}, [value])
// Auto-focus search input on popover open
useEffect(() => {
if (open) {
const timer = setTimeout(() => {
searchInputRef.current?.focus()
}, 60)
return () => clearTimeout(timer)
} else {
setQuery('')
}
}, [open])
// Filter countries based on search query
const filteredCountries = useMemo(() => {
const raw = query.trim()
if (!raw) return ALL_COUNTRIES
const normalized = normalizeDialDigits(raw).toLowerCase()
const cleanDigits = normalized.replace(/[^\d]/g, '')
const cleanDigitsNoZero = cleanDigits.replace(/^0+/, '')
return ALL_COUNTRIES.filter((c) => {
// 1. Dial code match (e.g. 98, +98, 44, 964)
const cClean = c.dialCode.replace(/[^\d]/g, '')
const dialMatch =
cleanDigits.length > 0 &&
(cClean.includes(cleanDigits) || (cleanDigitsNoZero.length > 0 && cClean.includes(cleanDigitsNoZero)))
// 2. Name matches (Persian, English, Arabic)
const nameFaMatch = c.nameFa.toLowerCase().includes(normalized)
const nameEnMatch = c.nameEn.toLowerCase().includes(normalized)
const nameArMatch = c.nameAr ? c.nameAr.toLowerCase().includes(normalized) : false
// 3. ISO code match (e.g. IR, IQ, SA, GB, US)
const codeMatch = c.code.toLowerCase() === normalized
return dialMatch || nameFaMatch || nameEnMatch || nameArMatch || codeMatch
})
}, [query])
// Split into popular & rest if query is empty for quick access
const isSearching = query.trim().length > 0
const popularList = useMemo(() => POPULAR_COUNTRIES, [])
const otherList = useMemo(
() => ALL_COUNTRIES.filter((c) => !c.isPopular),
[]
)
const handleSelect = (c: CountryDialInfo) => {
onChange(c.dialCode, c)
setOpen(false)
setQuery('')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
'h-9 px-2.5 flex items-center gap-1.5 bg-transparent hover:bg-muted/50 cursor-pointer outline-none transition text-xs font-mono font-bold text-foreground select-none disabled:opacity-50 disabled:cursor-not-allowed',
buttonClassName,
className
)}
title={`انتخاب کشور (${selectedCountry?.nameFa || ''})`}
>
<span className="text-base leading-none select-none shrink-0" aria-hidden="true">
{selectedCountry?.flag || '🌐'}
</span>
<span className="dir-ltr shrink-0 font-mono text-xs font-bold text-foreground">
{selectedCountry?.dialCode || value}
</span>
<Ic
name="chevDown"
className={cn(
'size-3 text-muted-foreground shrink-0 transition-transform duration-200',
open && 'rotate-180'
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className="z-[70] w-80 max-w-[90vw] p-2.5 rounded-2xl border-border-soft bg-popover/95 backdrop-blur-md shadow-2xl space-y-2.5 outline-none"
>
{/* نوار جستجوی زنده کشور */}
<div className="relative">
<Ic
name="search"
className="absolute start-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none"
/>
<Input
ref={searchInputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="جستجوی نام یا پیش‌شماره کشور..."
className="h-9 ps-8 pe-8 text-xs bg-surface-base rounded-xl border-border-soft focus-visible:ring-1 focus-visible:ring-primary shadow-none"
/>
{query.trim().length > 0 && (
<button
type="button"
onClick={() => {
setQuery('')
searchInputRef.current?.focus()
}}
className="absolute end-2 top-1/2 -translate-y-1/2 size-5 flex items-center justify-center rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition"
title="پاک کردن جستجو"
>
<Ic name="close" className="size-3" />
</button>
)}
</div>
{/* فهرست کشورها */}
<div className="max-h-64 sm:max-h-72 overflow-y-auto space-y-1 pr-0.5 no-scrollbar">
{isSearching ? (
/* نتایج جستجو */
filteredCountries.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground space-y-1">
<p className="font-medium">کشوری با این مشخصات یافت نشد.</p>
<p className="text-[11px] text-muted-foreground/80">
می‌توانید نام فارسی، انگلیسی یا کد کشور را وارد کنید.
</p>
</div>
) : (
<div className="space-y-0.5">
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground flex items-center justify-between">
<span>نتایج جستجو</span>
<span className="font-mono text-[10px]">{filteredCountries.length} کشور</span>
</div>
{filteredCountries.map((c) => {
const isSelected = c.dialCode === value
return (
<CountryItemRow
key={c.code + c.dialCode}
country={c}
isSelected={isSelected}
onSelect={() => handleSelect(c)}
/>
)
})}
</div>
)
) : (
/* حالت پیش‌فرض: بخش‌بندی پرکاربردها و سایر کشورها */
<div className="space-y-2">
{/* کشورهای پرکاربرد */}
<div className="space-y-0.5">
<div className="px-2 py-1 text-[11px] font-bold text-muted-foreground flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-primary inline-block" />
<span>کشورهای پرکاربرد زیارتی و منطقه‌ای</span>
</div>
{popularList.map((c) => {
const isSelected = c.dialCode === value
return (
<CountryItemRow
key={`pop-${c.code}-${c.dialCode}`}
country={c}
isSelected={isSelected}
onSelect={() => handleSelect(c)}
/>
)
})}
</div>
{/* خط جداکننده */}
<div className="border-t border-border-soft my-1.5" />
{/* سایر کشورها */}
<div className="space-y-0.5">
<div className="px-2 py-1 text-[11px] font-bold text-muted-foreground">
<span>همه کشورها (به ترتیب الفبا)</span>
</div>
{otherList.map((c) => {
const isSelected = c.dialCode === value
return (
<CountryItemRow
key={`all-${c.code}-${c.dialCode}`}
country={c}
isSelected={isSelected}
onSelect={() => handleSelect(c)}
/>
)
})}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
function CountryItemRow({
country,
isSelected,
onSelect,
}: {
country: CountryDialInfo
isSelected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full flex items-center justify-between px-2.5 py-1.5 rounded-xl text-xs transition cursor-pointer text-start select-none group',
isSelected
? 'bg-primary/15 text-primary font-bold'
: 'hover:bg-muted/70 text-foreground'
)}
>
<div className="flex items-center gap-2.5 min-w-0">
<span className="text-base select-none shrink-0" aria-hidden="true">
{country.flag}
</span>
<div className="flex items-center gap-1.5 min-w-0 truncate">
<span className="font-medium truncate">{country.nameFa}</span>
<span className="text-[11px] text-muted-foreground font-mono truncate hidden sm:inline">
({country.nameEn})
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={cn(
'font-mono text-xs dir-ltr px-1.5 py-0.5 rounded-md transition-colors',
isSelected
? 'bg-primary/20 text-primary font-black'
: 'text-muted-foreground group-hover:text-foreground bg-surface-base border border-border-soft/60'
)}
>
{country.dialCode}
</span>
{isSelected && <Ic name="check" className="size-3.5 text-primary shrink-0" />}
</div>
</button>
)
}

6
src/components/ui/popup.tsx

@ -0,0 +1,6 @@
/**
* Popup component alias (re-exports standard Dialog from ./dialog)
* Created to make finding popup/modal components faster and easier in the project.
*/
export * from './dialog'
export { Dialog as Popup } from './dialog'

2262
src/data/countries.json
File diff suppressed because it is too large
View File

63
src/data/countries.ts

@ -0,0 +1,63 @@
import countriesData from './countries.json'
export interface CountryDialInfo {
code: string
name: string
nameFa: string
nameEn: string
nameAr?: string
flag: string
dialCode: string
isPopular?: boolean
}
export const ALL_COUNTRIES: CountryDialInfo[] = countriesData as CountryDialInfo[]
export const POPULAR_COUNTRIES: CountryDialInfo[] = ALL_COUNTRIES.filter((c) => c.isPopular)
// For backwards compatibility
export const COUNTRY_DIAL_CODES: CountryDialInfo[] = ALL_COUNTRIES
/**
* Normalizes Persian and Arabic digits to standard English digits
*/
export function normalizeDialDigits(input: string): string {
if (!input) return ''
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
const arabicDigits = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩']
let res = input
for (let i = 0; i < 10; i++) {
res = res.replace(new RegExp(persianDigits[i], 'g'), String(i))
res = res.replace(new RegExp(arabicDigits[i], 'g'), String(i))
}
return res
}
/**
* Splits a full international phone number into dial code and national body
*/
export function splitPhoneNumber(fullPhone: string, defaultCode = '+44'): { dialCode: string; body: string } {
const clean = (fullPhone || '').trim().replace(/^00/, '+')
if (!clean) return { dialCode: defaultCode, body: '' }
// Sort by dialCode length descending to match longest code first (+1264 before +1)
const sorted = [...ALL_COUNTRIES].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: defaultCode, body: clean.replace(/[^\d]/g, '') }
}
/**
* Finds country by dial code
*/
export function findCountryByDialCode(dialCode: string): CountryDialInfo | undefined {
if (!dialCode) return undefined
const clean = dialCode.startsWith('+') ? dialCode : '+' + dialCode
return ALL_COUNTRIES.find((c) => c.dialCode === clean)
}

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

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { Ic, type IconName } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@ -12,6 +12,8 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogBody,
DialogFooter,
} from '@/components/ui/dialog'
import {
@ -19,9 +21,21 @@ import {
type ServiceLanguageCode,
SERVICE_LANGUAGES,
} from '@/components/shared/ServiceTranslation'
import { useTranslation } from '@/i18n'
import { CountryDialSelect } from '@/components/shared/CountryDialSelect'
import { COUNTRY_DIAL_CODES, splitPhoneNumber } from '@/data/countries'
import {
fetchAdminSupportChannels,
createSupportChannel,
updateSupportChannel,
toggleSupportChannelActive,
deleteSupportChannel,
} from '../services/support-api'
export { COUNTRY_DIAL_CODES }
export interface SupportChannel {
id: string
id: string | number
icon: string
phoneNumber: string
whatsappNumber: string
@ -29,6 +43,7 @@ export interface SupportChannel {
isActive: boolean
titles: Record<ServiceLanguageCode, string>
descriptions: Record<ServiceLanguageCode, string>
title?: string
}
const INITIAL_SUPPORT_CHANNELS: SupportChannel[] = [
@ -154,41 +169,6 @@ const AVAILABLE_ICONS: { id: string; label: string; icon: IconName }[] = [
{ 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[]>(() => {
@ -201,6 +181,26 @@ export function SupportServiceView() {
return INITIAL_SUPPORT_CHANNELS
})
// بارگذاری کانال‌ها از سرور به هنگام بارگذاری صفحه
useEffect(() => {
let isMounted = true
fetchAdminSupportChannels().then((data) => {
if (isMounted && Array.isArray(data) && data.length > 0) {
setChannels(data)
try {
localStorage.setItem('aqila_support_channels', JSON.stringify(data))
} catch {
// ignore
}
}
}).catch((err) => {
console.error('Failed to load support channels from backend', err)
})
return () => {
isMounted = false
}
}, [])
const persistChannels = (updated: SupportChannel[]) => {
setChannels(updated)
try {
@ -210,8 +210,8 @@ export function SupportServiceView() {
}
}
// زبان پیش‌نمایش کارت‌ها (پیش‌فرض عربی مطابق اپلیکیشن)
const [previewLang, setPreviewLang] = useState<ServiceLanguageCode>('ar')
// دریافت زبان فعال پنل
const { t, language } = useTranslation()
// فیلترها
const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL')
@ -220,7 +220,7 @@ export function SupportServiceView() {
// دیالوگ افزودن / ویرایش
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingChannel, setEditingChannel] = useState<SupportChannel | null>(null)
const [dialogLangTab, setDialogLangTab] = useState<ServiceLanguageCode>('ar')
const [dialogLangTab, setDialogLangTab] = useState<ServiceLanguageCode>((language as ServiceLanguageCode) || 'fa')
const [formTitles, setFormTitles] = useState<Record<ServiceLanguageCode, string>>({
ar: '', fa: '', en: '', ru: '', id: '',
@ -236,7 +236,7 @@ export function SupportServiceView() {
const handleOpenCreate = () => {
setEditingChannel(null)
setDialogLangTab('ar')
setDialogLangTab((language as ServiceLanguageCode) || 'fa')
setFormTitles({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormDescriptions({ ar: '', fa: '', en: '', ru: '', id: '' })
setFormCountryDialCode('+44')
@ -249,7 +249,7 @@ export function SupportServiceView() {
const handleOpenEdit = (channel: SupportChannel) => {
setEditingChannel(channel)
setDialogLangTab('ar')
setDialogLangTab((language as ServiceLanguageCode) || 'fa')
setFormTitles({ ...channel.titles })
setFormDescriptions({ ...channel.descriptions })
const parsed = splitPhoneNumber(channel.phoneNumber)
@ -272,7 +272,7 @@ export function SupportServiceView() {
toast.success(`چت واتس‌اپ برای شماره ${formCountryDialCode} ${cleanBody} باز شد.`)
}
const handleSaveChannel = (e: React.FormEvent) => {
const handleSaveChannel = async (e: React.FormEvent) => {
e.preventDefault()
if (!formTitles.ar.trim() && !formTitles.fa.trim() && !formTitles.en.trim()) {
toast.error('لطفاً عنوان کانال را وارد کنید.')
@ -285,58 +285,81 @@ export function SupportServiceView() {
}
const fullPhone = `${formCountryDialCode}${cleanBody}`
if (editingChannel) {
const updated = channels.map((c) =>
c.id === editingChannel.id
? {
...c,
const channelPayload = {
icon: formIcon,
phoneNumber: fullPhone,
contact: fullPhone,
whatsappNumber: fullPhone,
order: formOrder,
isActive: formIsActive,
titles: { ...formTitles },
descriptions: { ...formDescriptions },
}
if (editingChannel) {
const updated = channels.map((c) =>
String(c.id) === String(editingChannel.id)
? {
...c,
...channelPayload,
id: editingChannel.id,
}
: c
)
persistChannels(updated)
toast.success('کانال پشتیبانی به‌روزرسانی شد.')
try {
await updateSupportChannel(editingChannel.id, channelPayload)
} catch (err) {
toast.error('خطا در ذخیره‌سازی تغییرات در سرور.')
}
} else {
try {
const created = await createSupportChannel(channelPayload)
persistChannels([...channels, created])
toast.success('کانال پشتیبانی با موفقیت اضافه شد.')
} catch (err) {
const newChannel: SupportChannel = {
id: `ch-${Date.now()}`,
icon: formIcon,
phoneNumber: fullPhone,
whatsappNumber: fullPhone,
order: formOrder,
isActive: formIsActive,
titles: { ...formTitles },
descriptions: { ...formDescriptions },
...channelPayload,
}
persistChannels([...channels, newChannel])
toast.success('کانال پشتیبانی با موفقیت اضافه شد.')
}
}
setIsDialogOpen(false)
}
const handleToggleActive = (id: string, e?: React.MouseEvent) => {
const handleToggleActive = async (id: string | number, e?: React.MouseEvent) => {
e?.stopPropagation()
const updated = channels.map((c) => {
if (c.id === id) {
const nextState = !c.isActive
const target = channels.find((c) => String(c.id) === String(id))
if (!target) return
const nextState = !target.isActive
const updated = channels.map((c) =>
String(c.id) === String(id) ? { ...c, isActive: nextState } : c
)
persistChannels(updated)
toast.success(nextState ? 'کانال فعال شد.' : 'کانال غیرفعال شد.')
return { ...c, isActive: nextState }
try {
await toggleSupportChannelActive(id, nextState)
} catch (err) {
console.error('Failed to toggle active on backend', err)
toast.error('خطا در ذخیره‌سازی وضعیت در سرور.')
}
return c
})
persistChannels(updated)
}
const handleDeleteChannel = (channel: SupportChannel) => {
const handleDeleteChannel = async (channel: SupportChannel) => {
if (confirm(`آیا از حذف این کانال اطمینان دارید؟`)) {
const updated = channels.filter((c) => c.id !== channel.id)
const updated = channels.filter((c) => String(c.id) !== String(channel.id))
persistChannels(updated)
toast.success('کانال پشتیبانی حذف شد.')
try {
await deleteSupportChannel(channel.id)
} catch (err) {
console.error('Failed to delete on server', err)
}
}
}
@ -383,7 +406,7 @@ export function SupportServiceView() {
: 'text-grey-3 hover:text-foreground'
)}
>
همه ({toFa(channels.length)})
{t('all')} ({toFa(channels.length)})
</button>
<button
onClick={() => setStatusFilter('ACTIVE')}
@ -395,7 +418,7 @@ export function SupportServiceView() {
)}
>
<span className="size-1.5 rounded-full bg-emerald-500" />
<span>فعال ({toFa(activeCount)})</span>
<span>{t('active')} ({toFa(activeCount)})</span>
</button>
<button
onClick={() => setStatusFilter('INACTIVE')}
@ -406,31 +429,9 @@ export function SupportServiceView() {
: 'text-grey-3 hover:text-foreground'
)}
>
<span>غیرفعال ({toFa(inactiveCount)})</span>
<span>{t('inactive')} ({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>
{/* جستجو و دکمه افزودن */}
@ -440,7 +441,7 @@ export function SupportServiceView() {
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="جستجو..."
placeholder={`${t('search')}...`}
className="h-8 ps-8 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
@ -451,7 +452,7 @@ export function SupportServiceView() {
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>
<span>{t('add')}</span>
</Button>
</div>
</div>
@ -459,8 +460,20 @@ export function SupportServiceView() {
{/* کارت‌های ساده، خلوت و مدرن کانال‌ها */}
<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
const langKey = (language as ServiceLanguageCode) || 'fa'
const title =
channel.titles?.[langKey] ||
channel.titles?.fa ||
channel.titles?.ar ||
channel.titles?.en ||
channel.title ||
''
const desc =
channel.descriptions?.[langKey] ||
channel.descriptions?.fa ||
channel.descriptions?.ar ||
channel.descriptions?.en ||
''
return (
<Card
@ -493,7 +506,7 @@ export function SupportServiceView() {
channel.isActive ? 'text-emerald-500' : 'text-zinc-500'
)}
>
{channel.isActive ? 'فعال' : 'غیرفعال'}
{channel.isActive ? t('active') : t('inactive')}
</span>
</div>
</div>
@ -516,12 +529,14 @@ export function SupportServiceView() {
{/* ردیف شماره تماس و اکشن‌ها */}
<div className="flex items-center justify-between pt-2.5 border-t border-border-soft text-xs">
<button
type="button"
dir="ltr"
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]"
className="flex items-center gap-1.5 font-mono 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>
<span dir="ltr">{channel.phoneNumber}</span>
</button>
<div className="flex items-center gap-1">
@ -551,17 +566,23 @@ export function SupportServiceView() {
})}
</div>
{/* دیالوگ تمیز افزودن و ویرایش */}
{/* دیالوگ استاندارد افزودن و ویرایش مطابق معماری dialog.tsx */}
<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">
<DialogContent className="max-w-lg sm:max-w-xl max-h-[88vh] flex flex-col p-0 overflow-hidden rounded-2xl bg-card border border-border-soft shadow-2xl">
{/* هدر استاندارد با تفکیک و دکمه بستن داخلی */}
<DialogHeader className="p-5 sm:p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogTitle className="text-base font-bold text-foreground">
{editingChannel ? 'ویرایش کانال پشتیبانی' : 'افزودن کانال پشتیبانی'}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3">
تنظیم اطلاعات تماس، زبان‌های محتوا و وضعیت نمایش در اپلیکیشن
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSaveChannel} className="space-y-4 pt-2">
{/* تب‌های ۵ زبان با کامپوننت مشترک ServiceTranslation */}
{/* فرم با اسکرول بدنه و فوتر پین‌شده */}
<form onSubmit={handleSaveChannel} className="flex flex-col flex-1 overflow-hidden min-h-0">
<DialogBody className="space-y-4 p-5 sm:p-6 flex-1 overflow-y-auto">
{/* تب‌های ۵ زبان با حالت قرصی ریسپانسیو بدون کراپ متن */}
<ServiceTranslation
activeLang={dialogLangTab}
onLangChange={(lang) => setDialogLangTab(lang)}
@ -570,7 +591,7 @@ export function SupportServiceView() {
)}
primaryLang="ar"
title="زبان محتوا"
variant="grid"
variant="pills"
/>
{/* عنوان به زبان فعال */}
@ -624,7 +645,7 @@ export function SupportServiceView() {
<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"
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 cursor-pointer"
title="باز کردن مستقیم چت در واتس‌اپ"
>
<Ic name="whatsapp" className="size-3.5 text-white" />
@ -634,21 +655,12 @@ export function SupportServiceView() {
{/* کامپوننت ترکیبی انتخاب کشور با پرچم + اینپوت شماره */}
<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
{/* سلکتور پیش‌شماره کشور همراه با پرچم و قابلیت سرچ */}
<div className="shrink-0 border-l border-border-soft bg-card/60">
<CountryDialSelect
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" />
onChange={(dialCode) => setFormCountryDialCode(dialCode)}
/>
</div>
{/* فیلد وارد کردن شماره تماس */}
@ -665,7 +677,7 @@ export function SupportServiceView() {
{/* پیش‌نمایش زنده شماره */}
<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">
<span dir="ltr" className="font-mono font-bold text-emerald-500">
{formCountryDialCode} {formPhoneBody || '---'}
</span>
</div>
@ -698,7 +710,7 @@ export function SupportServiceView() {
type="button"
onClick={() => setFormIcon(item.id)}
className={cn(
'p-2 rounded-xl border transition flex items-center justify-center',
'p-2 rounded-xl border transition flex items-center justify-center cursor-pointer',
formIcon === item.id
? 'border-primary bg-primary/10 text-primary'
: 'border-border-soft bg-surface-base text-grey-3 hover:text-foreground'
@ -721,21 +733,23 @@ export function SupportServiceView() {
className="data-[state=checked]:bg-emerald-500"
/>
</div>
</DialogBody>
<DialogFooter className="flex items-center justify-between gap-2 pt-2 border-t border-border-soft">
{/* فوتر استاندارد ثابت با استایل سیستم طراحی پنل */}
<DialogFooter className="p-4 sm:p-5 border-t border-border-soft shrink-0 bg-card/95 backdrop-blur-xs flex items-center justify-end gap-2.5">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="rounded-xl text-xs h-8"
className="rounded-xl text-xs h-9 px-4"
>
انصراف
</Button>
<Button
type="submit"
className="rounded-xl text-xs h-8 bg-primary text-primary-foreground hover:bg-primary/90 font-bold"
className="rounded-xl text-xs h-9 px-5 bg-primary text-primary-foreground hover:bg-primary/90 font-bold"
>
ذخیره
{editingChannel ? 'ذخیره تغییرات' : 'افزودن کانال'}
</Button>
</DialogFooter>
</form>

51
src/features/services/services/support-api.ts

@ -0,0 +1,51 @@
import { apiFetch } from '@/services/http'
import type { SupportChannel } from '../components/SupportServiceView'
/**
* دریافت لیست کانال‌های پشتیبانی از سرور برای پنل ادمین
*/
export async function fetchAdminSupportChannels(params?: { search?: string }): Promise<SupportChannel[]> {
const q = new URLSearchParams()
if (params?.search) q.set('search', params.search)
const url = `contact/admin/consultants/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* ایجاد کانال پشتیبانی جدید
*/
export async function createSupportChannel(data: Partial<SupportChannel>): Promise<SupportChannel> {
return apiFetch<SupportChannel>('contact/admin/consultants/', {
method: 'POST',
body: data,
})
}
/**
* ویرایش کانال پشتیبانی
*/
export async function updateSupportChannel(id: string | number, data: Partial<SupportChannel>): Promise<SupportChannel> {
return apiFetch<SupportChannel>(`contact/admin/consultants/${id}/`, {
method: 'PATCH',
body: data,
})
}
/**
* تغییر وضعیت فعال/غیرفعال کانال پشتیبانی
*/
export async function toggleSupportChannelActive(id: string | number, isActive: boolean): Promise<SupportChannel> {
return updateSupportChannel(id, { isActive, is_active: isActive } as any)
}
/**
* حذف کانال پشتیبانی
*/
export async function deleteSupportChannel(id: string | number): Promise<void> {
return apiFetch<void>(`contact/admin/consultants/${id}/`, {
method: 'DELETE',
})
}

4
src/index.css

@ -320,4 +320,6 @@ html.light .shimmer-wave,
animation: shimmerWave 1.5s infinite linear;
}
.dir-ltr {
direction: ltr !important;
}
Loading…
Cancel
Save