Browse Source
feat: add CustomTrip destination detail page with multi-language support and management tools
master
feat: add CustomTrip destination detail page with multi-language support and management tools
master
7 changed files with 2935 additions and 256 deletions
-
281src/components/shared/CountryDialSelect.tsx
-
6src/components/ui/popup.tsx
-
2262src/data/countries.json
-
63src/data/countries.ts
-
524src/features/services/components/SupportServiceView.tsx
-
51src/features/services/services/support-api.ts
-
4src/index.css
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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
File diff suppressed because it is too large
View File
@ -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) |
||||
|
} |
||||
@ -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', |
||||
|
}) |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue