Browse Source

feat: add services feature module with localization, UI components, and API integrations

master
parent
commit
b3cb3ceefc
  1. BIN
      public/fonts/Vazirmatn-Bold.ttf
  2. BIN
      public/fonts/Vazirmatn-Regular.ttf
  3. 6
      scripts/generate-locales.js
  4. 2
      src/components/ui/checkbox.tsx
  5. 136
      src/features/services/ServicesPage.tsx
  6. 572
      src/features/services/components/CreateLocationDialog.tsx
  7. 18
      src/features/services/components/FaqServiceView.tsx
  8. 58
      src/features/services/components/LocationDetailSheet.tsx
  9. 669
      src/features/services/components/SimcardFormDialog.tsx
  10. 441
      src/features/services/components/SimcardServiceView.tsx
  11. 9
      src/features/services/components/StoreServiceView.tsx
  12. 179
      src/features/services/components/ZayerGuideServiceView.tsx
  13. 125
      src/features/services/services/simcard-api.ts
  14. 54
      src/features/services/services/trip-etiquette-api.ts
  15. 28
      src/features/services/services/zayer-guide-api.ts
  16. 12
      src/features/services/types/faq.ts
  17. 2
      src/features/tours/components/CreateTourDialog.tsx
  18. 3
      src/icons.tsx
  19. 25
      src/index.css
  20. 2
      src/locales/ar.json
  21. 2
      src/locales/en.json
  22. 2
      src/locales/fa.json

BIN
public/fonts/Vazirmatn-Bold.ttf

BIN
public/fonts/Vazirmatn-Regular.ttf

6
scripts/generate-locales.js

@ -198,7 +198,7 @@ export const enLocale = {
allServices: 'All Services',
searchPlaceholder: 'Search services...',
simcardTitle: 'SIM Card',
simcardDesc: 'Internet packages, international SIM cards, and traveler orders (Zain, Asiacell, Korek, STC)',
simcardDesc: 'Internet packages, international SIM cards, and traveler orders (Iran, Iraq, Saudi Arabia)',
zayerGuideTitle: 'Zayer Guide',
zayerGuideDesc: 'Holy shrines, historical sites, hospitals, mawakib, and city locations',
exchangeTitle: 'Online Exchange',
@ -477,7 +477,7 @@ export const faLocale = {
allServices: 'همه خدمات',
searchPlaceholder: 'جستجوی خدمات...',
simcardTitle: 'سیم‌کارت',
simcardDesc: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)',
simcardDesc: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (ایران، عراق، عربستان سعودی)',
zayerGuideTitle: 'راهنمای زائر',
zayerGuideDesc: 'بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها',
exchangeTitle: 'صرافی آنلاین',
@ -756,7 +756,7 @@ export const arLocale = {
allServices: 'جميع الخدمات',
searchPlaceholder: 'البحث في الخدمات...',
simcardTitle: 'شريحة الاتصال',
simcardDesc: 'إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (زين، آسیاسیل، كورك، STC)',
simcardDesc: 'إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (إيران، العراق، السعودية)',
zayerGuideTitle: 'دليل الزائر',
zayerGuideDesc: 'دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن',
exchangeTitle: 'الصرافة الإلكترونية',

2
src/components/ui/checkbox.tsx

@ -6,7 +6,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
return (
<CheckboxPrimitive.Root
className={cn(
'peer size-[19px] shrink-0 rounded-[6px] border border-white/25 bg-white/5 cursor-pointer transition-colors data-[state=checked]:bg-g-primary data-[state=checked]:border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring',
'peer size-[19px] shrink-0 rounded-[6px] border border-border-soft bg-surface-base dark:border-white/25 dark:bg-white/5 cursor-pointer transition-colors data-[state=checked]:bg-g-primary data-[state=checked]:border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring',
className,
)}
{...props}

136
src/features/services/ServicesPage.tsx

@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import { SimcardServiceView } from './components/SimcardServiceView'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView'
import { CategoriesServiceView } from './components/CategoriesServiceView'
@ -39,10 +40,10 @@ export const SERVICES_LIST: ServiceMeta[] = [
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)',
description: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (ایران، عراق، عربستان سعودی)',
endpoint: '/store/simcards/',
statsLabel: 'بسته‌های فعال',
defaultCount: 8,
defaultCount: 9,
badge: 'ارتباطات و دیتا',
},
{
@ -290,138 +291,7 @@ function ServiceDetailRenderer({
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ۱. سیم‌کارت (Simcard)
// ─────────────────────────────────────────────────────────────────────────────
function SimcardServiceView({ service }: { service: ServiceMeta }) {
const [operatorFilter, setOperatorFilter] = useState('ALL')
const [searchTerm, setSearchTerm] = useState('')
const samplePackages = [
{ id: 1, operator: 'Zain Iraq', country: 'IQ', title: 'بسته زائرین زین ۱۰ گیگابایت + مکالمه رایگان', duration: '۱۵ روزه', price: '12,500 دینار', stock: 'موجود (۳۴۰ عدد)', status: 'ACTIVE' },
{ id: 2, operator: 'Asiacell', country: 'IQ', title: 'بسته نامحدود اینترنت آسیاسل اربعین و عتبات', duration: '۳۰ روزه', price: '20,000 دینار', stock: 'موجود (۱۸۰ عدد)', status: 'ACTIVE' },
{ id: 3, operator: 'Korek', country: 'IQ', title: 'سیم‌کارت توریستی کورک تلکام ۵ گیگابایت', duration: '۱۰ روزه', price: '8,000 دینار', stock: 'موجود (۹۵ عدد)', status: 'ACTIVE' },
{ id: 4, operator: 'STC Saudi', country: 'SA', title: 'سیم‌کارت عمره STC با ۲۰ گیگابایت دیتای پرسرعت', duration: '۱۴ روزه', price: '65 ریال سعودی', stock: 'موجود (۵۰ عدد)', status: 'ACTIVE' },
{ id: 5, operator: 'Mobily', country: 'SA', title: 'سیم‌کارت حرمین موبایلی ویژه زائرین مکه و مدینه', duration: '۲۱ روزه', price: '80 ریال سعودی', stock: 'موجود (۳۰ عدد)', status: 'ACTIVE' },
]
const filtered = samplePackages.filter((p) => {
if (operatorFilter !== 'ALL' && !p.operator.toLowerCase().includes(operatorFilter.toLowerCase())) return false
if (searchTerm && !p.title.includes(searchTerm) && !p.operator.includes(searchTerm)) return false
return true
})
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">
<span className="text-xs text-grey-3 font-medium">سیم‌کارت‌های تحویل‌شده</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(524)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">اپراتورهای پشتیبانی‌شده</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(5)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">سفارش‌های در انتظار فعال‌سازی</span>
<div className="mt-2 text-2xl font-black text-amber-500 font-mono tabular-nums">{toFa(12)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">میانگین زمان تحویل در مرز/فرودگاه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۱۰ دقیقه</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
size="sm"
variant={operatorFilter === 'ALL' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('ALL')}
className="text-xs h-9 rounded-xl font-bold"
>
همه اپراتورها
</Button>
<Button
size="sm"
variant={operatorFilter === 'Zain' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('Zain')}
className="text-xs h-9 rounded-xl font-bold"
>
🇮🇶 زین (Zain)
</Button>
<Button
size="sm"
variant={operatorFilter === 'Asiacell' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('Asiacell')}
className="text-xs h-9 rounded-xl font-bold"
>
🇮🇶 آسیاسل (Asiacell)
</Button>
<Button
size="sm"
variant={operatorFilter === 'STC' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('STC')}
className="text-xs h-9 rounded-xl font-bold"
>
🇸🇦 STC سعودی
</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"
/>
</div>
</div>
{/* جدول بسته‌های سیم‌کارت */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">شناسه</th>
<th className="p-3.5 text-start font-bold">اپراتور و کشور</th>
<th className="p-3.5 text-start font-bold">عنوان بسته اینترنت و مکالمه</th>
<th className="p-3.5 text-start font-bold">مدت اعتبار</th>
<th className="p-3.5 text-start font-bold">قیمت رسمی</th>
<th className="p-3.5 text-start font-bold">موجودی انبار</th>
<th className="p-3.5 text-start font-bold">وضعیت</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filtered.map((item) => (
<tr key={item.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{item.id}</td>
<td className="p-3.5">
<div className="flex items-center gap-2">
<span className="font-bold text-foreground">{item.operator}</span>
<Badge variant="outline" className="text-[10px]">{item.country}</Badge>
</div>
</td>
<td className="p-3.5 font-bold text-foreground">{item.title}</td>
<td className="p-3.5 text-grey-2">{item.duration}</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">{item.price}</td>
<td className="p-3.5 text-grey-3">{item.stock}</td>
<td className="p-3.5">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full">
فعال در اپلیکیشن
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۳. صرافی آنلاین و تشریفات (Online Exchange)

572
src/features/services/components/CreateLocationDialog.tsx

@ -11,12 +11,14 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import type {
import {
CityGuideItem,
CityGuideCategory,
CityGuideCountry,
CreateCityGuideInput,
} from '../types/zayer-guide'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { fetchCityGuideDetail } from '../services/zayer-guide-api'
interface CreateLocationDialogProps {
open: boolean
@ -38,6 +40,58 @@ const WEEK_DAYS = [
{ code: 'FR', label: 'جمعه' },
]
const CATEGORY_NAMES_FA: Record<string, string> = {
mosque: 'مسجد و زیارتگاه',
pharmacy: 'داروخانه',
hospital: 'بیمارستان و درمانگاه',
souvenirs: 'سوغات و هدایا',
souvenir: 'سوغات و هدایا',
restaurant_fast_food: 'رستوران و فست‌فود',
supermarket: 'سوپرمارکت',
shopping_center: 'مراکز خرید',
cultural_entertainment_site: 'اماکن فرهنگی و گردشگری',
}
const CITY_NAMES_FA: Record<string, string> = {
mashhad: 'مشهد',
qom: 'قم',
karbala: 'کربلا',
najaf: 'نجف',
kufa: 'کوفه',
koufa: 'کوفه',
kadhimiya: 'کاظمین',
kazemein: 'کاظمین',
samarra: 'سامرا',
samara: 'سامرا',
mecca: 'مکه',
make: 'مکه',
medina: 'مدینه',
madineh: 'مدینه',
damascus: 'دمشق',
}
const COUNTRY_NAMES_FA: Record<string, string> = {
IR: 'ایران',
IQ: 'عراق',
SA: 'عربستان',
SY: 'سوریه',
OMN: 'عمان',
KWT: 'کویت',
BHR: 'بحرین',
Iran: 'ایران',
Iraq: 'عراق',
'Saudi Arabia': 'عربستان سعودی',
Syria: 'سوریه',
}
const LOCATION_LANGUAGES = [
{ code: 'ar', name: 'العربية (پیش‌فرض)', flag: '🇸🇦', dir: 'rtl' },
{ code: 'fa', name: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'en', name: 'English', flag: '🇬🇧', dir: 'ltr' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺', dir: 'ltr' },
{ code: 'id', name: 'Bahasa Indonesia', flag: '🇮🇩', dir: 'ltr' },
] as const
export function CreateLocationDialog({
open,
onOpenChange,
@ -47,23 +101,29 @@ export function CreateLocationDialog({
isSaving = false,
onSave,
}: CreateLocationDialogProps) {
// زبان فعال در تب‌های چندزبانه
const [activeLangTab, setActiveLangTab] = useState<'fa' | 'ar' | 'en'>('fa')
// زبان فعال در تب‌های چندزبانه (پیش‌فرض: عربی)
const [activeLangTab, setActiveLangTab] = useState<'ar' | 'fa' | 'en' | 'ru' | 'id'>('ar')
// عناوین چندزبانه
const [titleFa, setTitleFa] = useState('')
const [titleAr, setTitleAr] = useState('')
const [titleEn, setTitleEn] = useState('')
const [titleRu, setTitleRu] = useState('')
const [titleId, setTitleId] = useState('')
// توضیحات چندزبانه
const [descFa, setDescFa] = useState('')
const [descAr, setDescAr] = useState('')
const [descEn, setDescEn] = useState('')
const [descRu, setDescRu] = useState('')
const [descId, setDescId] = useState('')
// آدرس‌های چندزبانه
const [addrFa, setAddrFa] = useState('')
const [addrAr, setAddrAr] = useState('')
const [addrEn, setAddrEn] = useState('')
const [addrRu, setAddrRu] = useState('')
const [addrId, setAddrId] = useState('')
// مشخصات عمومی و مکانی
const [phoneNumber, setPhoneNumber] = useState('')
@ -77,60 +137,94 @@ export function CreateLocationDialog({
const [longitude, setLongitude] = useState('44.0244')
const [imageUrl, setImageUrl] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
// لیست کلیه شهرهای تابعه کشور انتخابی
const currentCountry = countries.find((c) => c.id === countryId) || countries[0]
const availableCities = currentCountry?.city || []
useEffect(() => {
if (editingLocation) {
const trans = editingLocation.translations || {}
let isMounted = true
setTitleFa(trans.fa?.title || editingLocation.title || '')
setTitleAr(trans.ar?.title || '')
const applyLocationData = (loc: CityGuideItem) => {
const trans = loc.translations || {}
setTitleAr(trans.ar?.title || loc.title || '')
setTitleFa(trans.fa?.title || '')
setTitleEn(trans.en?.title || '')
setTitleRu(trans.ru?.title || '')
setTitleId(trans.id?.title || '')
setDescFa(trans.fa?.description || editingLocation.description || '')
setDescAr(trans.ar?.description || '')
setDescAr(trans.ar?.description || loc.description || '')
setDescFa(trans.fa?.description || '')
setDescEn(trans.en?.description || '')
setDescRu(trans.ru?.description || '')
setDescId(trans.id?.description || '')
setAddrFa(trans.fa?.address || editingLocation.address || '')
setAddrAr(trans.ar?.address || '')
setAddrAr(trans.ar?.address || loc.address || '')
setAddrFa(trans.fa?.address || '')
setAddrEn(trans.en?.address || '')
setPhoneNumber(editingLocation.phone_number || '')
setCategoryId(editingLocation.category_id || editingLocation.category?.id || categories[0]?.id || 1)
setCountryId(editingLocation.country_id || editingLocation.country?.id || countries[0]?.id || 1)
setCityId(editingLocation.city_id || editingLocation.city?.id || 1)
setWorkingHoursFrom(editingLocation.working_hours_from ? editingLocation.working_hours_from.substring(0, 5) : '08:00')
setWorkingHoursTo(editingLocation.working_hours_to ? editingLocation.working_hours_to.substring(0, 5) : '23:00')
const parsedDays = Array.isArray(editingLocation.days_off)
? editingLocation.days_off
: typeof editingLocation.days_off === 'string'
? (editingLocation.days_off as string).split(',').map((s) => s.trim()).filter(Boolean)
setAddrRu(trans.ru?.address || '')
setAddrId(trans.id?.address || '')
setPhoneNumber(loc.phone_number || '')
setCategoryId(loc.category_id || loc.category?.id || categories[0]?.id || 1)
setCountryId(loc.country_id || loc.country?.id || countries[0]?.id || 1)
setCityId(loc.city_id || loc.city?.id || 1)
setWorkingHoursFrom(loc.working_hours_from ? loc.working_hours_from.substring(0, 5) : '08:00')
setWorkingHoursTo(loc.working_hours_to ? loc.working_hours_to.substring(0, 5) : '23:00')
const parsedDays = Array.isArray(loc.days_off)
? loc.days_off
: typeof loc.days_off === 'string'
? (loc.days_off as string).split(',').map((s) => s.trim()).filter(Boolean)
: []
setDaysOff(parsedDays)
setLatitude(String(editingLocation.latitude ?? '32.6160'))
setLongitude(String(editingLocation.longitude ?? '44.0244'))
setLatitude(String(loc.latitude ?? '32.6160'))
setLongitude(String(loc.longitude ?? '44.0244'))
const img =
(typeof editingLocation.image?.image_url === 'object'
? editingLocation.image?.image_url?.original
: editingLocation.image?.image_url) || ''
(typeof loc.image?.image_url === 'object'
? loc.image?.image_url?.original
: loc.image?.image_url) || ''
setImageUrl(typeof img === 'string' ? img : '')
}
if (editingLocation) {
applyLocationData(editingLocation)
// دریافت جزئیات کامل و ترجمه‌های پنج‌گانه از دیتابیس در صورت نیاز
if (!editingLocation.translations || Object.keys(editingLocation.translations).length < 4) {
setIsLoadingDetail(true)
fetchCityGuideDetail(editingLocation.slug || editingLocation.id)
.then((fullItem) => {
if (isMounted && fullItem) {
applyLocationData(fullItem)
}
})
.catch((err) => console.warn('Could not fetch full translations:', err))
.finally(() => {
if (isMounted) setIsLoadingDetail(false)
})
}
} else {
// مقادیر پیش‌فرض برای ایجاد مکان جدید
setTitleFa('')
setTitleAr('')
setTitleEn('')
setTitleRu('')
setTitleId('')
setDescFa('')
setDescAr('')
setDescEn('')
setDescRu('')
setDescId('')
setAddrFa('')
setAddrAr('')
setAddrEn('')
setAddrRu('')
setAddrId('')
setPhoneNumber('')
setCategoryId(categories[0]?.id || 1)
const firstCountry = countries[0]
@ -148,6 +242,10 @@ export function CreateLocationDialog({
setImageUrl('')
}
setValidationError(null)
return () => {
isMounted = false
}
}, [editingLocation, open, categories, countries])
const toggleDayOff = (dayCode: string) => {
@ -159,8 +257,9 @@ export function CreateLocationDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// حداقل عنوان فارسی یا اصلی باید پر باشد
const mainTitle = titleFa.trim() || titleAr.trim() || titleEn.trim()
// حداقل یک عنوان معتبر وارد شده باشد
const mainTitle =
titleFa.trim() || titleAr.trim() || titleEn.trim() || titleRu.trim() || titleId.trim()
if (!mainTitle) {
setValidationError('لطفاً عنوان مکان را وارد کنید.')
return
@ -177,21 +276,39 @@ export function CreateLocationDialog({
if (titleFa.trim()) titles.fa = titleFa.trim()
if (titleAr.trim()) titles.ar = titleAr.trim()
if (titleEn.trim()) titles.en = titleEn.trim()
if (titleRu.trim()) titles.ru = titleRu.trim()
if (titleId.trim()) titles.id = titleId.trim()
const descriptions: Record<string, string> = {}
if (descFa.trim()) descriptions.fa = descFa.trim()
if (descAr.trim()) descriptions.ar = descAr.trim()
if (descEn.trim()) descriptions.en = descEn.trim()
if (descRu.trim()) descriptions.ru = descRu.trim()
if (descId.trim()) descriptions.id = descId.trim()
const addresses: Record<string, string> = {}
if (addrFa.trim()) addresses.fa = addrFa.trim()
if (addrAr.trim()) addresses.ar = addrAr.trim()
if (addrEn.trim()) addresses.en = addrEn.trim()
if (addrRu.trim()) addresses.ru = addrRu.trim()
if (addrId.trim()) addresses.id = addrId.trim()
const inputData: CreateCityGuideInput = {
title: mainTitle,
description: descFa.trim() || descAr.trim() || descEn.trim() || '',
address: addrFa.trim() || addrAr.trim() || addrEn.trim() || '',
description:
descFa.trim() ||
descAr.trim() ||
descEn.trim() ||
descRu.trim() ||
descId.trim() ||
'',
address:
addrFa.trim() ||
addrAr.trim() ||
addrEn.trim() ||
addrRu.trim() ||
addrId.trim() ||
'',
titles,
descriptions,
addresses,
@ -240,148 +357,236 @@ export function CreateLocationDialog({
{/* تب‌های انتخاب زبان برای محتوای متنی */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="flex items-center justify-between border-b border-border-soft pb-2.5">
<div className="text-xs font-bold text-foreground">اطلاعات متنی مکان</div>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft">
<button
type="button"
onClick={() => setActiveLangTab('fa')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'fa'
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
}`}
>
فارسی (پیش‌فرض)
</button>
<button
type="button"
onClick={() => setActiveLangTab('ar')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'ar'
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
}`}
>
العربية
</button>
<button
type="button"
onClick={() => setActiveLangTab('en')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'en'
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
}`}
>
English
</button>
<div className="flex items-center justify-between border-b border-border-soft pb-2.5 flex-wrap gap-2">
<div className="flex items-center gap-2">
<div className="text-xs font-bold text-foreground">اطلاعات متنی مکان</div>
{isLoadingDetail && (
<span className="text-[11px] text-primary flex items-center gap-1 font-medium animate-pulse">
<Ic name="loader" className="size-3 animate-spin" />
<span>در حال دریافت ترجمه‌ها از دیتابیس...</span>
</span>
)}
</div>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft flex-wrap">
{LOCATION_LANGUAGES.map((lang) => {
const isSelected = activeLangTab === lang.code
return (
<button
key={lang.code}
type="button"
onClick={() => setActiveLangTab(lang.code)}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium flex items-center gap-1.5 ${
isSelected
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground hover:bg-card/60'
}`}
>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.name}</span>
</button>
)
})}
</div>
</div>
{/* بخش زبان فارسی */}
{activeLangTab === 'fa' && (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
عنوان مکان (فارسی) <span className="text-rose-500">*</span>
</label>
<Input
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
className="text-xs bg-surface-base"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شرح و توضیحات (فارسی)</label>
<textarea
value={descFa}
onChange={(e) => setDescFa(e.target.value)}
rows={3}
placeholder="توضیحات تاریخی، معنوی، خدمات یا امکانات این مکان..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
{/* لودینگ اسکلتون شیمر خنثی در صورت خالی بودن داده‌ها هنگام دریافت ترجمه‌ها */}
{isLoadingDetail && !titleAr && !titleFa ? (
<div className="space-y-4 py-1.5 animate-in fade-in-50 duration-200">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس دقیق (فارسی)</label>
<Input
value={addrFa}
onChange={(e) => setAddrFa(e.target.value)}
placeholder="خیابان، میدان، پلاک یا نشانی راهنمای زائر"
className="text-xs bg-surface-base"
/>
<div className="flex items-center justify-between">
<ShimmerLine width={130} height={12} rounded="md" />
<ShimmerLine width={35} height={12} rounded="md" />
</div>
<ShimmerBox height={38} rounded="xl" className="w-full" />
</div>
</div>
)}
{/* بخش زبان عربی */}
{activeLangTab === 'ar' && (
<div className="space-y-3" dir="rtl">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">اسم المكان (بالعربية)</label>
<Input
value={titleAr}
onChange={(e) => setTitleAr(e.target.value)}
placeholder="مثال: حرم الإمام الحسين (ع)"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">الوصف والخدمات (بالعربية)</label>
<textarea
value={descAr}
onChange={(e) => setDescAr(e.target.value)}
rows={3}
placeholder="شرح موجز عن المكان والخدمات..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
<ShimmerLine width={150} height={12} rounded="md" />
<ShimmerBox height={78} rounded="xl" className="w-full" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">العنوان (بالعربية)</label>
<Input
value={addrAr}
onChange={(e) => setAddrAr(e.target.value)}
placeholder="الشارع، المنطقة أو الحي"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
{/* بخش زبان انگلیسی */}
{activeLangTab === 'en' && (
<div className="space-y-3" dir="ltr">
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Place Title (English)</label>
<Input
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder="e.g. Imam Hussain Holy Shrine"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Description (English)</label>
<textarea
value={descEn}
onChange={(e) => setDescEn(e.target.value)}
rows={3}
placeholder="Brief description of the place..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Address (English)</label>
<Input
value={addrEn}
onChange={(e) => setAddrEn(e.target.value)}
placeholder="Street, District or City location"
className="text-xs bg-surface-base"
/>
<div className="space-y-1.5">
<ShimmerLine width={100} height={12} rounded="md" />
<ShimmerBox height={38} rounded="xl" className="w-full" />
</div>
</div>
) : (
<>
{/* بخش زبان عربی (پیش‌فرض) */}
{activeLangTab === 'ar' && (
<div className="space-y-3" dir="rtl">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
اسم المكان (بالعربية) <span className="text-rose-500">*</span>
</label>
<Input
value={titleAr}
onChange={(e) => setTitleAr(e.target.value)}
placeholder="مثال: حرم الإمام الحسين (ع)"
className="text-xs bg-surface-base"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">الوصف والخدمات (بالعربية)</label>
<textarea
value={descAr}
onChange={(e) => setDescAr(e.target.value)}
rows={3}
placeholder="شرح موجز عن المكان والخدمات..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">العنوان (بالعربية)</label>
<Input
value={addrAr}
onChange={(e) => setAddrAr(e.target.value)}
placeholder="الشارع، المنطقة أو الحي"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
{/* بخش زبان فارسی */}
{activeLangTab === 'fa' && (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
عنوان مکان (فارسی)
</label>
<Input
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شرح و توضیحات (فارسی)</label>
<textarea
value={descFa}
onChange={(e) => setDescFa(e.target.value)}
rows={3}
placeholder="توضیحات تاریخی، معنوی، خدمات یا امکانات این مکان..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس دقیق (فارسی)</label>
<Input
value={addrFa}
onChange={(e) => setAddrFa(e.target.value)}
placeholder="خیابان، میدان، پلاک یا نشانی راهنمای زائر"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
{/* بخش زبان انگلیسی */}
{activeLangTab === 'en' && (
<div className="space-y-3" dir="ltr">
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Place Title (English)</label>
<Input
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder="e.g. Imam Hussain Holy Shrine"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Description (English)</label>
<textarea
value={descEn}
onChange={(e) => setDescEn(e.target.value)}
rows={3}
placeholder="Brief description of the place..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Address (English)</label>
<Input
value={addrEn}
onChange={(e) => setAddrEn(e.target.value)}
placeholder="Street, District or City location"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
{/* بخش زبان روسی */}
{activeLangTab === 'ru' && (
<div className="space-y-3" dir="ltr">
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Название места (Русский)</label>
<Input
value={titleRu}
onChange={(e) => setTitleRu(e.target.value)}
placeholder="например: Соборная мечеть Гохаршад"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Описание (Русский)</label>
<textarea
value={descRu}
onChange={(e) => setDescRu(e.target.value)}
rows={3}
placeholder="Краткое описание места и удобств..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Адрес (Русский)</label>
<Input
value={addrRu}
onChange={(e) => setAddrRu(e.target.value)}
placeholder="Улица, район или местоположение в городе"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
{/* بخش زبان اندونزیایی */}
{activeLangTab === 'id' && (
<div className="space-y-3" dir="ltr">
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Nama Tempat (Bahasa Indonesia)</label>
<Input
value={titleId}
onChange={(e) => setTitleId(e.target.value)}
placeholder="misal: Goharshad Masjid Agung"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Deskripsi (Bahasa Indonesia)</label>
<textarea
value={descId}
onChange={(e) => setDescId(e.target.value)}
rows={3}
placeholder="Deskripsi singkat tentang tempat dan fasilitas..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Alamat (Bahasa Indonesia)</label>
<Input
value={addrId}
onChange={(e) => setAddrId(e.target.value)}
placeholder="Jalan, distrik, atau lokasi kota"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
</>
)}
</div>
@ -394,13 +599,16 @@ export function CreateLocationDialog({
<select
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="w-full h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs text-foreground focus:border-primary focus:outline-none"
className="w-full h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs font-sans text-foreground focus:border-primary focus:outline-none cursor-pointer"
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
{categories.map((c) => {
const faName = CATEGORY_NAMES_FA[c.slug.toLowerCase()] || c.name
return (
<option key={c.id} value={c.id} className="font-sans">
{faName}
</option>
)
})}
</select>
</div>
@ -415,17 +623,23 @@ export function CreateLocationDialog({
setCountryId(coId)
setCityId(ciId)
}}
className="w-full h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs text-foreground focus:border-primary focus:outline-none"
className="w-full h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs font-sans text-foreground focus:border-primary focus:outline-none cursor-pointer"
>
{countries.map((co) => (
<optgroup key={co.id} label={`${co.icon || '📍'} ${co.name}`}>
{(co.city || []).map((ci) => (
<option key={`${co.id}_${ci.id}`} value={`${co.id}_${ci.id}`}>
{ci.name} ({co.name})
</option>
))}
</optgroup>
))}
{countries.map((co) => {
const countryName = COUNTRY_NAMES_FA[co.code] || COUNTRY_NAMES_FA[co.name] || co.name
return (
<optgroup key={co.id} label={`${co.icon || '📍'} ${countryName}`} className="font-sans font-bold">
{(co.city || []).map((ci) => {
const cityName = CITY_NAMES_FA[ci.slug.toLowerCase()] || ci.name
return (
<option key={`${co.id}_${ci.id}`} value={`${co.id}_${ci.id}`} className="font-sans">
{cityName} ({countryName})
</option>
)
})}
</optgroup>
)
})}
</select>
</div>
</div>

18
src/features/services/components/FaqServiceView.tsx

@ -161,7 +161,7 @@ export function FaqServiceView() {
const [catIcon, setCatIcon] = useState('ticket-dark')
const [catOrder, setCatOrder] = useState(0)
const [catIsActive, setCatIsActive] = useState(true)
const [catActiveLang, setCatActiveLang] = useState<SupportedLanguage>('fa')
const [catActiveLang, setCatActiveLang] = useState<SupportedLanguage>('ar')
const [catTranslations, setCatTranslations] = useState<Record<SupportedLanguage, { title: string; description: string }>>({
fa: { title: '', description: '' },
ar: { title: '', description: '' },
@ -177,7 +177,7 @@ export function FaqServiceView() {
const [itemCategoryId, setItemCategoryId] = useState<number | ''>('')
const [itemOrder, setItemOrder] = useState(0)
const [itemIsActive, setItemIsActive] = useState(true)
const [itemActiveLang, setItemActiveLang] = useState<SupportedLanguage>('fa')
const [itemActiveLang, setItemActiveLang] = useState<SupportedLanguage>('ar')
const [itemTranslations, setItemTranslations] = useState<Record<SupportedLanguage, { question: string; answer: string }>>({
fa: { question: '', answer: '' },
ar: { question: '', answer: '' },
@ -278,7 +278,7 @@ export function FaqServiceView() {
setCatIcon('ticket-dark')
setCatOrder(categories.length + 1)
setCatIsActive(true)
setCatActiveLang('fa')
setCatActiveLang('ar')
setCatTranslations({
fa: { title: '', description: '' },
ar: { title: '', description: '' },
@ -295,7 +295,7 @@ export function FaqServiceView() {
setCatIcon(cat.icon_name || 'ticket-dark')
setCatOrder(cat.order)
setCatIsActive(cat.is_active)
setCatActiveLang(initialLang || 'fa')
setCatActiveLang(initialLang || 'ar')
const newTrans: Record<SupportedLanguage, { title: string; description: string }> = {
fa: { title: '', description: '' },
@ -421,7 +421,7 @@ export function FaqServiceView() {
const countInCat = items.filter((i) => i.category === catId).length
setItemOrder(countInCat + 1)
setItemIsActive(true)
setItemActiveLang('fa')
setItemActiveLang('ar')
setItemTranslations({
fa: { question: '', answer: '' },
ar: { question: '', answer: '' },
@ -437,7 +437,7 @@ export function FaqServiceView() {
setItemCategoryId(item.category)
setItemOrder(item.order)
setItemIsActive(item.is_active)
setItemActiveLang(initialLang || 'fa')
setItemActiveLang(initialLang || 'ar')
const newTrans: Record<SupportedLanguage, { question: string; answer: string }> = {
fa: { question: '', answer: '' },
@ -1362,7 +1362,8 @@ export function FaqServiceView() {
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
<span>{lang.nativeName}</span>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.nativeName}</span>
{isFilled && (
<span
className={cn(
@ -1575,7 +1576,8 @@ export function FaqServiceView() {
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
<span>{lang.nativeName}</span>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.nativeName}</span>
{isFilled && (
<span
className={cn(

58
src/features/services/components/LocationDetailSheet.tsx

@ -6,7 +6,6 @@ import {
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons'
@ -41,7 +40,7 @@ export function LocationDetailSheet({
onToggleActive,
isToggling,
}: LocationDetailSheetProps) {
const [activeLang, setActiveLang] = useState<'fa' | 'ar' | 'en'>('fa')
const [activeLang, setActiveLang] = useState<'fa' | 'ar' | 'en' | 'ru' | 'id'>('fa')
if (!location) return null
@ -71,29 +70,26 @@ export function LocationDetailSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-xl overflow-y-auto p-0 border-r border-border-soft bg-background">
<SheetContent side="left" className="w-full sm:max-w-xl overflow-y-auto p-0 border-r border-border-soft bg-background [&>button:last-child]:hidden">
{/* Header */}
<SheetHeader className="sticky top-0 z-20 border-b border-border-soft bg-background/95 p-5 backdrop-blur-md">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 text-start">
<div className="flex items-center gap-2">
<SheetTitle className="text-lg font-black text-foreground">
{displayTitle}
</SheetTitle>
</div>
<SheetDescription className="text-xs text-grey-3 font-mono" dir="ltr">
ID: #{location.id} | Slug: {location.slug}
</SheetDescription>
</div>
<SheetHeader className="sticky top-0 z-20 border-b border-border-soft bg-background/95 px-5 py-4 backdrop-blur-md">
<div className="flex items-center justify-between gap-4">
<SheetTitle className="text-lg font-black text-foreground">
{displayTitle}
</SheetTitle>
<SheetDescription className="sr-only">
جزئیات مکان {displayTitle}
</SheetDescription>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs"
>
{location.category?.name || 'مکان زیارتی'}
</Badge>
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
className="flex size-8 items-center justify-center rounded-xl border border-border-soft bg-surface-base text-grey-3 transition-colors hover:bg-muted hover:text-foreground focus:outline-none cursor-pointer"
title="بستن"
aria-label="بستن پنجره"
>
<Ic name="x" className="size-4" />
</button>
</div>
</SheetHeader>
@ -189,18 +185,24 @@ export function LocationDetailSheet({
<div className="flex items-center justify-between border-b border-border-soft pb-2">
<span className="text-xs font-bold text-foreground">مشاهده زبان ترجمه</span>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft text-xs">
{(['fa', 'ar', 'en'] as const).map((l) => (
{([
{ code: 'fa', label: 'فارسی' },
{ code: 'ar', label: 'العربية' },
{ code: 'en', label: 'EN' },
{ code: 'ru', label: 'RU' },
{ code: 'id', label: 'ID' },
] as const).map((l) => (
<button
key={l}
key={l.code}
type="button"
onClick={() => setActiveLang(l)}
onClick={() => setActiveLang(l.code)}
className={`px-2 py-0.5 rounded-lg transition-colors font-medium ${
activeLang === l
activeLang === l.code
? 'bg-primary text-white font-bold'
: 'text-grey-3 hover:text-foreground'
}`}
>
{l === 'fa' ? 'فارسی' : l === 'ar' ? 'العربية' : 'EN'}
{l.label}
</button>
))}
</div>

669
src/features/services/components/SimcardFormDialog.tsx

@ -0,0 +1,669 @@
import React, { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import type { SimCardItem, CreateSimCardInput } from '../services/simcard-api'
export type SupportedLanguage = 'ar' | 'fa' | 'en' | 'ru' | 'id'
interface SimcardFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
item?: SimCardItem | null
onSave: (data: CreateSimCardInput, id?: number) => Promise<void>
onDelete?: (id: number) => Promise<void>
}
const COUNTRIES = [
{ id: 1, code: 'IR', name: 'ایران', flag: '🇮🇷' },
{ id: 2, code: 'IQ', name: 'عراق', flag: '🇮🇶' },
{ id: 4, code: 'SA', name: 'عربستان سعودی', flag: '🇸🇦' },
]
export const LANGUAGES = [
{ code: 'ar', name: 'العربية', flag: '🇸🇦', badge: 'AR', dir: 'rtl', label: 'العربية (پیش‌فرض)' },
{ code: 'fa', name: 'فارسی', flag: '🇮🇷', badge: 'FA', dir: 'rtl', label: 'فارسی' },
{ code: 'en', name: 'English', flag: '🇬🇧', badge: 'EN', dir: 'ltr', label: 'انگلیسی' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺', badge: 'RU', dir: 'ltr', label: 'روسی' },
{ code: 'id', name: 'Bahasa Indonesia', flag: '🇮🇩', badge: 'ID', dir: 'ltr', label: 'اندونزیایی' },
] as const
const EMPTY_TITLES: Record<SupportedLanguage, string> = {
ar: '',
fa: '',
en: '',
ru: '',
id: '',
}
const EMPTY_DETAILS: Record<SupportedLanguage, string[]> = {
ar: [],
fa: [],
en: [],
ru: [],
id: [],
}
export function SimcardFormDialog({
open,
onOpenChange,
item,
onSave,
onDelete,
}: SimcardFormDialogProps) {
const isEditing = Boolean(item && item.id)
const [activeLangTab, setActiveLangTab] = useState<SupportedLanguage>('ar')
const [countryId, setCountryId] = useState<number>(1)
const [price, setPrice] = useState<string>('10.00')
const [status, setStatus] = useState<number>(1)
// عناوین چندزبانه
const [titles, setTitles] = useState<Record<SupportedLanguage, string>>({ ...EMPTY_TITLES })
// ویژگی‌های چندزبانه
const [details, setDetails] = useState<Record<SupportedLanguage, string[]>>({ ...EMPTY_DETAILS })
// افزودن ویژگی جدید در زبان فعال
const [newDetailText, setNewDetailText] = useState('')
// ویرایش درون‌خطی ویژگی در زبان فعال
const [editingDetailIndex, setEditingDetailIndex] = useState<number | null>(null)
const [editingDetailText, setEditingDetailText] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
useEffect(() => {
setActiveLangTab('ar')
if (item) {
const cId = item.country?.id || 1
setCountryId(cId)
setPrice(item.price ? String(item.price) : '10.00')
setStatus(item.status ?? 1)
// استخراج عناوین ۵ زبانه از دیتابیس
const parsedTitles: Record<SupportedLanguage, string> = { ...EMPTY_TITLES }
if (Array.isArray(item.raw_title)) {
for (const t of item.raw_title) {
if (t && typeof t === 'object' && t.language_code && t.title) {
const lang = t.language_code as SupportedLanguage
if (lang in parsedTitles) {
parsedTitles[lang] = t.title
}
}
}
} else if (item.raw_title && typeof item.raw_title === 'object') {
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) {
if (item.raw_title[lang]) parsedTitles[lang] = String(item.raw_title[lang])
}
}
if (!parsedTitles.ar && !parsedTitles.fa && item.title) {
parsedTitles.ar = item.title
parsedTitles.fa = item.title
}
setTitles(parsedTitles)
// استخراج ویژگی‌های ۵ زبانه از دیتابیس
const parsedDetails: Record<SupportedLanguage, string[]> = { ...EMPTY_DETAILS }
if (Array.isArray(item.raw_detail)) {
for (const d of item.raw_detail) {
if (d && typeof d === 'object' && d.language_code && d.detail) {
const lang = d.language_code as SupportedLanguage
if (lang in parsedDetails) {
parsedDetails[lang].push(d.detail)
}
}
}
} else if (item.raw_detail && typeof item.raw_detail === 'object') {
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) {
if (Array.isArray(item.raw_detail[lang])) {
parsedDetails[lang] = [...item.raw_detail[lang]]
}
}
}
if (parsedDetails.ar.length === 0 && parsedDetails.fa.length === 0 && Array.isArray(item.simcard_detail)) {
parsedDetails.ar = [...item.simcard_detail]
parsedDetails.fa = [...item.simcard_detail]
}
setDetails(parsedDetails)
} else {
setCountryId(1)
setPrice('10.00')
setStatus(1)
setTitles({ ...EMPTY_TITLES })
setDetails({ ...EMPTY_DETAILS })
}
setNewDetailText('')
setEditingDetailIndex(null)
setEditingDetailText('')
setErrorMsg(null)
setShowDeleteConfirm(false)
}, [item, open])
// کار با ویژگی‌های زبان فعال
const activeDetails = details[activeLangTab] || []
const handleTabChange = (langCode: SupportedLanguage) => {
setActiveLangTab(langCode)
}
const handleTitleChange = (val: string) => {
setTitles((prev) => ({
...prev,
[activeLangTab]: val,
}))
}
const handleCountryChange = (cId: number) => {
setCountryId(cId)
}
const handleAddDetail = () => {
if (!newDetailText.trim()) return
setDetails((prev) => ({
...prev,
[activeLangTab]: [...(prev[activeLangTab] || []), newDetailText.trim()],
}))
setNewDetailText('')
}
const handleRemoveDetail = (index: number) => {
setDetails((prev) => ({
...prev,
[activeLangTab]: (prev[activeLangTab] || []).filter((_, i) => i !== index),
}))
if (editingDetailIndex === index) {
setEditingDetailIndex(null)
setEditingDetailText('')
}
}
const handleStartEditDetail = (index: number, text: string) => {
setEditingDetailIndex(index)
setEditingDetailText(text)
}
const handleSaveEditDetail = (index: number) => {
if (!editingDetailText.trim()) return
setDetails((prev) => ({
...prev,
[activeLangTab]: (prev[activeLangTab] || []).map((d, i) =>
i === index ? editingDetailText.trim() : d
),
}))
setEditingDetailIndex(null)
setEditingDetailText('')
}
const handleCancelEditDetail = () => {
setEditingDetailIndex(null)
setEditingDetailText('')
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const mainTitle =
titles.ar.trim() ||
titles.fa.trim() ||
titles[activeLangTab].trim() ||
titles.en.trim() ||
titles.ru.trim() ||
titles.id.trim()
if (!mainTitle) {
setErrorMsg('لطفاً عنوان بسته سیم‌کارت را وارد نمایید.')
return
}
const numPrice = parseFloat(price)
if (isNaN(numPrice) || numPrice <= 0) {
setErrorMsg('لطفاً قیمت معتبری وارد نمایید.')
return
}
// عناوین ۵ زبانه
const finalTitles: Record<SupportedLanguage, string> = {
ar: titles.ar.trim() || mainTitle,
fa: titles.fa.trim() || mainTitle,
en: titles.en.trim() || mainTitle,
ru: titles.ru.trim() || mainTitle,
id: titles.id.trim() || mainTitle,
}
// ویژگی‌های ۵ زبانه
const finalDetails: Record<SupportedLanguage, string[]> = {
ar: (details.ar || []).filter((d) => d.trim()),
fa: (details.fa || []).filter((d) => d.trim()),
en: (details.en || []).filter((d) => d.trim()),
ru: (details.ru || []).filter((d) => d.trim()),
id: (details.id || []).filter((d) => d.trim()),
}
// فال‌بک ویژگی‌ها در صورت خالی بودن
const fallbackDetails =
finalDetails.ar.length > 0
? finalDetails.ar
: finalDetails.fa.length > 0
? finalDetails.fa
: []
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) {
if (finalDetails[lang].length === 0 && fallbackDetails.length > 0) {
finalDetails[lang] = [...fallbackDetails]
}
}
setErrorMsg(null)
setIsSubmitting(true)
try {
await onSave(
{
title: finalTitles.fa || finalTitles.ar,
titles: finalTitles,
country_id: countryId,
price: numPrice.toFixed(2),
status,
simcard_detail: finalDetails.fa.length > 0 ? finalDetails.fa : finalDetails.ar,
details: finalDetails,
},
item?.id
)
onOpenChange(false)
} catch (err: any) {
setErrorMsg(err?.message || 'خطایی در ثبت اطلاعات رخ داد.')
} finally {
setIsSubmitting(false)
}
}
const handleConfirmDelete = async () => {
if (!item?.id || !onDelete) return
setIsDeleting(true)
try {
await onDelete(item.id)
setShowDeleteConfirm(false)
onOpenChange(false)
} catch (err: any) {
setErrorMsg(err?.message || 'خطایی در حذف سیم‌کارت رخ داد.')
} finally {
setIsDeleting(false)
}
}
const currentLang = LANGUAGES.find((l) => l.code === activeLangTab) || LANGUAGES[0]
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl lg:max-w-3xl w-full rounded-2xl bg-card border border-border-soft p-0 max-h-[85vh] flex flex-col overflow-hidden shadow-2xl">
{/* هدر ثابت در بالای مدال */}
<DialogHeader className="p-6 pb-4 border-b border-border-soft shrink-0 text-start">
<DialogTitle className="text-lg font-bold text-foreground">
{isEditing ? 'ویرایش بسته سیم‌کارت' : 'افزودن سیم‌کارت جدید'}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3">
{isEditing
? 'تغییرات شما در تمام زبان‌ها در دیتابیس ثبت شده و در اپلیکیشن زائرین بر اساس زبان انتخابی کاربر نمایش داده می‌شود.'
: 'سیم‌کارت جدید در دیتابیس ذخیره و در اپلیکیشن فعال خواهد شد.'}
</DialogDescription>
</DialogHeader>
{/* فرم: بدنه اسکرول‌شونده و فوتر فیکس‌شده */}
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden min-h-0">
{/* بدنه محتوا (تنها بخشی که اسکرول می‌شود) */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{errorMsg && (
<div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs flex items-center gap-2">
<Ic name="alert" className="size-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* ۱. انتخاب کشور مقصد */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">
کشور مقصد <span className="text-rose-500">*</span>
</label>
<div className="grid grid-cols-3 gap-3">
{COUNTRIES.map((c) => {
const isSelected = countryId === c.id
return (
<button
key={c.id}
type="button"
onClick={() => handleCountryChange(c.id)}
className={`h-12 px-4 rounded-xl border text-xs font-bold transition flex items-center justify-center gap-2.5 ${
isSelected
? 'border-primary bg-primary/10 text-primary shadow-xs'
: 'border-border-soft bg-surface-base/50 text-grey-3 hover:text-foreground hover:bg-card'
}`}
>
<span className="text-lg">{c.flag}</span>
<span className="text-xs">{c.name}</span>
</button>
)
})}
</div>
</div>
{/* ۲. تب‌های انتخاب ۵ زبان با پرچم و نشانگر وضعیت ترجمه (زیر کشور مقصد) */}
<div className="space-y-2 pt-1">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Ic name="globe" className="size-3.5 text-primary" />
<span>زبان ترجمه و محتوا</span>
<span className="text-rose-500">*</span>
</label>
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 p-1.5 rounded-2xl bg-surface-base/80 border border-border-soft">
{LANGUAGES.map((lang) => {
const isSelected = activeLangTab === lang.code
const hasTitle = Boolean(titles[lang.code]?.trim())
const detailsCount = (details[lang.code] || []).length
return (
<button
key={lang.code}
type="button"
onClick={() => handleTabChange(lang.code)}
className={`h-11 px-3 rounded-xl text-xs font-bold transition flex items-center justify-center gap-2 relative ${
isSelected
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground hover:bg-card'
}`}
>
<span className="text-base">{lang.flag}</span>
<span className="truncate">{lang.name}</span>
{hasTitle && (
<span
className={`size-2 rounded-full shrink-0 ${
isSelected ? 'bg-white' : 'bg-emerald-500'
}`}
title={`دارای ترجمه (${detailsCount} ویژگی)`}
/>
)}
</button>
)
})}
</div>
</div>
{/* ۳. عنوان بسته سیم‌کارت به زبان فعال */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>عنوان بسته سیم‌کارت به زبان {currentLang.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded font-mono font-bold bg-primary/10 text-primary border border-primary/20">
{currentLang.badge}
</span>
<span className="text-rose-500">*</span>
</label>
<span className="text-[11px] text-grey-3 font-mono">
{currentLang.dir === 'rtl' ? 'RTL' : 'LTR'}
</span>
</div>
<Input
value={titles[activeLangTab] || ''}
onChange={(e) => handleTitleChange(e.target.value)}
dir={currentLang.dir}
placeholder={`عنوان بسته به زبان ${currentLang.name}...`}
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft"
required={activeLangTab === 'fa'}
/>
</div>
{/* ۴. قیمت و وضعیت در اپلیکیشن */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">
قیمت به دلار ($) <span className="text-rose-500">*</span>
</label>
<div className="relative">
<Input
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="10.00"
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft font-mono ps-8"
required
/>
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-grey-3 font-mono text-xs">
$
</span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">وضعیت در اپلیکیشن</label>
<select
value={status}
onChange={(e) => setStatus(parseInt(e.target.value, 10))}
className="w-full h-11 text-xs bg-surface-base rounded-xl border border-border-soft px-3 text-foreground font-medium focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value={1}>فعال در اپلیکیشن</option>
<option value={2}>ناموجود (Out of Stock)</option>
<option value={3}>غیرفعال</option>
</select>
</div>
</div>
{/* ۵. ویژگی‌ها و جزئیات بسته به زبان فعال */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>ویژگی‌ها و جزئیات بسته به زبان {currentLang.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded font-mono font-bold bg-primary/10 text-primary border border-primary/20">
{currentLang.badge}
</span>
</label>
<span className="text-[11px] text-grey-3">
{activeDetails.length} ویژگی ثبت‌شده برای {currentLang.name}
</span>
</div>
{/* ورودی افزودن ویژگی جدید */}
<div className="flex items-center gap-2">
<Input
value={newDetailText}
onChange={(e) => setNewDetailText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddDetail()
}
}}
dir={currentLang.dir}
placeholder={`افزودن ویژگی به زبان ${currentLang.name} (مثلاً حجم اینترنت، دقایق مکالمه، فعال‌سازی)...`}
className="h-10 text-xs bg-surface-base rounded-xl border-border-soft flex-1"
/>
<Button
type="button"
onClick={handleAddDetail}
disabled={!newDetailText.trim()}
className="h-10 px-4 rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90 gap-1.5 shrink-0"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن</span>
</Button>
</div>
{/* لیست ویژگی‌های ثبت‌شده در زبان فعال */}
{activeDetails.length === 0 ? (
<div className="p-4 rounded-xl border border-dashed border-border-soft text-center text-grey-3 text-xs">
هنوز ویژگی خاصی به زبان {currentLang.name} اضافه نشده است. می‌توانید با فیلد بالا ویژگی جدید ثبت کنید.
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto pe-1">
{activeDetails.map((itemText, idx) => (
<div
key={idx}
className="p-3 rounded-xl border border-border-soft bg-surface-base/60 text-xs transition"
>
{editingDetailIndex === idx ? (
<div className="flex items-center gap-2">
<Input
value={editingDetailText}
onChange={(e) => setEditingDetailText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveEditDetail(idx)
}
}}
dir={currentLang.dir}
className="h-9 text-xs bg-card rounded-lg flex-1"
autoFocus
/>
<Button
type="button"
size="sm"
onClick={() => handleSaveEditDetail(idx)}
className="h-9 px-3 text-xs bg-primary text-white rounded-lg gap-1 shrink-0"
>
<Ic name="check" className="size-3.5" />
<span>ذخیره</span>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleCancelEditDetail}
className="h-9 px-2 text-xs text-grey-3 hover:text-foreground shrink-0"
title="انصراف"
>
<Ic name="close" className="size-3.5" />
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 truncate" dir={currentLang.dir}>
<span className="size-1.5 rounded-full bg-primary shrink-0" />
<span className="text-foreground font-medium leading-relaxed truncate">
{itemText}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => handleStartEditDetail(idx, itemText)}
className="text-grey-3 hover:text-primary p-1.5 rounded-lg hover:bg-primary/10 transition"
title="ویرایش ویژگی"
>
<Ic name="edit" className="size-3.5" />
</button>
<button
type="button"
onClick={() => handleRemoveDetail(idx)}
className="text-grey-3 hover:text-rose-500 p-1.5 rounded-lg hover:bg-rose-500/10 transition"
title="حذف ویژگی"
>
<Ic name="trash" className="size-3.5" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* فوتر فیکس‌شده در پایین */}
<DialogFooter className="p-4 sm:px-6 bg-card border-t border-border-soft shrink-0 flex flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
type="submit"
disabled={isSubmitting || isDeleting}
className="rounded-xl text-xs font-bold h-10 px-5 bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs"
>
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
</Button>
<DialogClose asChild>
<Button
type="button"
variant="ghost"
disabled={isSubmitting || isDeleting}
className="rounded-xl text-xs h-10 px-4"
>
انصراف
</Button>
</DialogClose>
</div>
{/* دکمه حذف بسته در گوشه فرم برای حالت ویرایش */}
{isEditing && onDelete && (
<Button
type="button"
variant="ghost"
disabled={isSubmitting || isDeleting}
onClick={() => setShowDeleteConfirm(true)}
className="rounded-xl text-xs h-10 px-3.5 text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 gap-1.5"
title="حذف بسته سیم‌کارت"
>
<Ic name="trash" className="size-4" />
<span>حذف بسته</span>
</Button>
)}
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* دیالوگ اعتبارسنجی و تایید حذف برای جلوگیری از حذف تصادفی */}
{showDeleteConfirm && (
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="sm:max-w-md rounded-2xl bg-card border border-border-soft p-6 shadow-2xl">
<DialogHeader className="space-y-2 text-start">
<div className="size-11 rounded-2xl bg-rose-500/10 text-rose-500 flex items-center justify-center mb-1">
<Ic name="trash" className="size-5" />
</div>
<DialogTitle className="text-base font-bold text-foreground">
حذف بسته سیم‌کارت
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 leading-relaxed">
آیا از حذف بسته سیم‌کارت «{item?.title}» از پایگاه‌داده اطمینان دارید؟ با حذف این مورد، دیگر در اپلیکیشن زائرین نمایش داده نخواهد شد و این عملیات غیرقابل بازگشت است.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-5 flex flex-row items-center gap-2 sm:justify-start">
<Button
type="button"
disabled={isDeleting}
onClick={handleConfirmDelete}
className="rounded-xl text-xs font-bold h-9 px-4 bg-rose-500 hover:bg-rose-600 text-white flex-1"
>
{isDeleting ? 'در حال حذف...' : 'تایید و حذف'}
</Button>
<DialogClose asChild>
<Button
type="button"
variant="ghost"
disabled={isDeleting}
className="rounded-xl text-xs h-9 px-4 flex-1"
>
انصراف
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
)
}

441
src/features/services/components/SimcardServiceView.tsx

@ -0,0 +1,441 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Ic } from '@/icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import { toast } from '@/stores/toast'
import type { ServiceMeta } from '../ServicesPage'
import {
fetchSimCards,
fetchSimCardOrdersCount,
createSimCard,
updateSimCard,
deleteSimCard,
type SimCardItem,
type CreateSimCardInput,
} from '../services/simcard-api'
import { SimcardFormDialog } from './SimcardFormDialog'
const COUNTRY_OPTIONS = [
{ code: 'ALL', labelFa: 'همه کشورها', labelEn: 'All Countries', flag: '🌍' },
{ code: 'IR', labelFa: 'ایران', labelEn: 'Iran', flag: '🇮🇷' },
{ code: 'IQ', labelFa: 'عراق', labelEn: 'Iraq', flag: '🇮🇶' },
{ code: 'SA', labelFa: 'عربستان سعودی', labelEn: 'Saudi Arabia', flag: '🇸🇦' },
] as const
const COUNTRY_FLAGS: Record<string, string> = {
IR: '🇮🇷',
IQ: '🇮🇶',
SA: '🇸🇦',
SY: '🇸🇾',
OM: '🇴🇲',
OMN: '🇴🇲',
KW: '🇰🇼',
KWT: '🇰🇼',
BH: '🇧🇭',
BHR: '🇧🇭',
}
const COUNTRY_NAMES_FA: Record<string, string> = {
IR: 'ایران',
IQ: 'عراق',
SA: 'عربستان سعودی',
SY: 'سوریه',
OM: 'عمان',
OMN: 'عمان',
KW: 'کویت',
KWT: 'کویت',
BH: 'بحرین',
BHR: 'بحرین',
}
export function SimcardServiceView({ service }: { service: ServiceMeta }) {
const [selectedCountry, setSelectedCountry] = useState<string>('ALL')
const [searchTerm, setSearchTerm] = useState('')
const [simcards, setSimcards] = useState<SimCardItem[]>([])
const [ordersCount, setOrdersCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState<boolean>(true)
// فرم ایجاد و ویرایش
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<SimCardItem | null>(null)
// بارگذاری داده‌ها از دیتابیس (یک‌بار در ابتدا برای دریافت همه بسته‌ها)
const loadData = async () => {
setIsLoading(true)
try {
const [cardsData, ordersTotal] = await Promise.all([
fetchSimCards(),
fetchSimCardOrdersCount(),
])
setSimcards(cardsData)
setOrdersCount(ordersTotal)
} catch (err) {
console.error('Error loading simcards:', err)
toast.error('خطا در دریافت لیست سیم‌کارت‌ها از سرور', 'خطا')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadData()
}, [])
// ذخیره (ایجاد یا ویرایش)
const handleSave = async (data: CreateSimCardInput, id?: number) => {
if (id) {
await updateSimCard(id, data)
toast.success(`بسته سیم‌کارت «${data.title}» با موفقیت ویرایش شد.`, 'ویرایش موفق')
} else {
await createSimCard(data)
toast.success(
`بسته سیم‌کارت «${data.title}» با موفقیت ثبت شد و در اپلیکیشن قرار گرفت.`,
'ثبت موفق'
)
}
setIsFormOpen(false)
loadData()
}
// حذف سیم‌کارت از دیتابیس
const handleDelete = async (id: number) => {
try {
await deleteSimCard(id)
toast.success('بسته سیم‌کارت با موفقیت از دیتابیس حذف شد.', 'حذف موفق')
setIsFormOpen(false)
loadData()
} catch (err: any) {
toast.error(err?.message || 'خطایی در حذف سیم‌کارت رخ داد.', 'خطا')
throw err
}
}
// فیلتر آنی کلاینت: بر اساس کشور انتخابی و عبارت جستجو (بدون لودینگ مجدد)
const filtered = useMemo(() => {
return simcards.filter((item) => {
// فیلتر بر اساس کشور
if (selectedCountry !== 'ALL' && item.country?.code !== selectedCountry) {
return false
}
// فیلتر بر اساس عبارت جستجو
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase().trim()
const matchTitle = item.title?.toLowerCase().includes(term)
const matchCountry =
item.country?.name?.toLowerCase().includes(term) ||
(COUNTRY_NAMES_FA[item.country?.code] || '').includes(term)
const matchDetails = item.simcard_detail?.some((d) =>
d.toLowerCase().includes(term)
)
if (!matchTitle && !matchCountry && !matchDetails) {
return false
}
}
return true
})
}, [simcards, selectedCountry, searchTerm])
// آمار محاسباتی زنده
const averagePrice = useMemo(() => {
if (!simcards.length) return '0'
const total = simcards.reduce((sum, item) => sum + parseFloat(item.price || '0'), 0)
return (total / simcards.length).toFixed(1)
}, [simcards])
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">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">بسته‌های فعال سیم‌کارت</span>
<span className="text-xs text-emerald-500 font-bold">در اپلیکیشن</span>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoading ? <ShimmerBox className="h-8 w-16" rounded="lg" /> : toFa(simcards.length)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">کشورهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(3)}{' '}
<span className="text-xs font-normal text-grey-3">
(🇮🇷 ایران، 🇮🇶 عراق، 🇸🇦 عربستان)
</span>
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">میانگین قیمت بسته‌ها</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoading ? (
<ShimmerBox className="h-8 w-20" rounded="lg" />
) : (
`${toFa(averagePrice)}$`
)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs flex flex-col justify-between">
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">سفارش‌های ثبت‌شده</span>
<Link
to="/billing?service=simcard"
className="text-[11px] text-primary hover:underline font-bold inline-flex items-center gap-1"
>
<span>مشاهده</span>
<Ic name="arrow-left" className="size-3" />
</Link>
</div>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">
{isLoading ? (
<ShimmerBox className="h-8 w-16" rounded="lg" />
) : (
toFa(ordersCount)
)}
</div>
</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">
{COUNTRY_OPTIONS.map((opt) => {
const isSelected = selectedCountry === opt.code
return (
<Button
key={opt.code}
size="sm"
variant={isSelected ? 'secondary' : 'ghost'}
onClick={() => setSelectedCountry(opt.code)}
className={`text-xs h-9 rounded-xl font-bold transition ${
isSelected
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
<span className="me-1.5">{opt.flag}</span>
<span>{opt.labelFa}</span>
</Button>
)
})}
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative min-w-[200px]">
<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>
<Button
size="sm"
variant="ghost"
onClick={loadData}
title="بروزرسانی داده‌ها"
className="size-9 p-0 rounded-xl text-grey-3 hover:text-foreground"
>
<Ic name="refresh" className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
size="sm"
onClick={() => {
setEditingItem(null)
setIsFormOpen(true)
}}
className="h-9 px-3.5 rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90 gap-1.5 shadow-xs"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن سیم‌کارت</span>
</Button>
</div>
</div>
{/* جدول بسته‌های سیم‌کارت مطابق با دیتابیس */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold w-16">شناسه</th>
<th className="p-3.5 text-start font-bold w-40">کشور</th>
<th className="p-3.5 text-start font-bold">عنوان بسته اینترنت و سیم‌کارت</th>
<th className="p-3.5 text-start font-bold w-28">قیمت رسمی</th>
<th className="p-3.5 text-start font-bold w-32">وضعیت</th>
<th className="p-3.5 text-center font-bold w-20">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{isLoading ? (
Array.from({ length: 5 }).map((_, idx) => (
<tr key={idx}>
<td className="p-3.5">
<ShimmerBox className="h-4 w-8" rounded="sm" />
</td>
<td className="p-3.5">
<div className="flex items-center gap-2">
<ShimmerBox className="size-5" rounded="full" />
<ShimmerBox className="h-4 w-20" rounded="sm" />
</div>
</td>
<td className="p-3.5">
<ShimmerLine width="75%" height={14} rounded="sm" />
</td>
<td className="p-3.5">
<ShimmerBox className="h-4 w-14" rounded="sm" />
</td>
<td className="p-3.5">
<ShimmerBox className="h-5 w-20" rounded="full" />
</td>
<td className="p-3.5 text-center">
<div className="flex items-center justify-center">
<ShimmerBox className="size-7" rounded="lg" />
</div>
</td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="p-12 text-center text-grey-3">
<div className="flex flex-col items-center justify-center gap-2">
<Ic name="simcard" className="size-8 text-grey-3/50" />
<span className="font-bold text-sm">هیچ بسته‌ای یافت نشد</span>
<span className="text-xs text-grey-3">
{searchTerm
? 'عبارت جستجو شده با هیچ‌یک از بسته‌های سیم‌کارت همخوانی ندارد.'
: 'برای کشور انتخاب شده سیم‌کارتی در دیتابیس ثبت نشده است.'}
</span>
</div>
</td>
</tr>
) : (
filtered.map((item) => {
const countryCode = item.country?.code || ''
const flag =
COUNTRY_FLAGS[countryCode] || item.country?.icon || '🌐'
const countryNameFa =
COUNTRY_NAMES_FA[countryCode] || item.country?.name || countryCode
return (
<tr
key={item.id}
onClick={() => {
setEditingItem(item)
setIsFormOpen(true)
}}
className="hover:bg-card/70 transition cursor-pointer group"
>
<td className="p-3.5 font-mono font-bold text-grey-3">
#{toFa(item.id)}
</td>
<td className="p-3.5">
<div className="flex items-center gap-2">
<span className="text-base">{flag}</span>
<span className="font-bold text-foreground">{countryNameFa}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">
{countryCode}
</Badge>
</div>
</td>
<td className="p-3.5">
<div className="flex flex-col gap-1.5">
<span className="font-bold text-foreground group-hover:text-primary transition">
{item.title}
</span>
<div className="flex items-center gap-1.5 flex-wrap">
{['fa', 'ar', 'en', 'ru', 'id'].map((lc) => (
<span
key={lc}
className="text-[9px] font-mono px-1.5 py-0.5 rounded-md font-bold uppercase bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
title={`پشتیبانی و ترجمه فعال: ${lc.toUpperCase()}`}
>
{lc}
</span>
))}
{item.simcard_detail && item.simcard_detail.length > 0 && (
<span className="text-[11px] text-grey-3 ms-1">
• {toFa(item.simcard_detail.length)} ویژگی ثبت‌شده
</span>
)}
</div>
</div>
</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">
{toFa(item.price)}$
</td>
<td className="p-3.5">
<Badge
className={`text-[10px] rounded-full border ${
item.status === 2
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20'
: item.status === 3
? 'bg-zinc-500/15 text-zinc-500 border-zinc-500/20'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
}`}
>
{item.status === 2
? 'ناموجود'
: item.status === 3
? 'غیرفعال'
: 'فعال در اپلیکیشن'}
</Badge>
</td>
<td className="p-3.5 text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingItem(item)
setIsFormOpen(true)
}}
title="ویرایش بسته"
className="size-7 p-0 text-primary hover:text-primary hover:bg-primary/10 rounded-lg"
>
<Ic name="edit" className="size-3.5" />
</Button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
{/* فرم ایجاد و ویرایش سیم‌کارت */}
<SimcardFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
item={editingItem}
onSave={handleSave}
onDelete={handleDelete}
/>
</div>
)
}

9
src/features/services/components/StoreServiceView.tsx

@ -146,7 +146,7 @@ export function StoreServiceView({
const [editingCategory, setEditingCategory] = useState<StoreCategoryItem | null>(null)
const [categoryTitleInput, setCategoryTitleInput] = useState('')
const [categorySlugInput, setCategorySlugInput] = useState('')
const [catActiveLang, setCatActiveLang] = useState<SupportedLanguage>('fa')
const [catActiveLang, setCatActiveLang] = useState<SupportedLanguage>('ar')
const [catTranslations, setCatTranslations] = useState<Record<SupportedLanguage, string>>({
fa: '',
ar: '',
@ -266,7 +266,7 @@ export function StoreServiceView({
ru: '',
id: '',
})
setCatActiveLang('fa')
setCatActiveLang('ar')
setIsCategoryModalOpen(true)
}
@ -309,8 +309,8 @@ export function StoreServiceView({
initialTr.fa = category.title || category.name
}
setCatTranslations(initialTr)
setCatActiveLang('fa')
setCategoryTitleInput(initialTr.fa || category.title || category.name || '')
setCatActiveLang('ar')
setCategoryTitleInput(initialTr.ar || initialTr.fa || category.title || category.name || '')
setIsCategoryModalOpen(true)
}
@ -1112,6 +1112,7 @@ export function StoreServiceView({
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.nativeName}</span>
{isFilled && (
<span

179
src/features/services/components/ZayerGuideServiceView.tsx

@ -20,6 +20,7 @@ import {
fetchCityGuides,
fetchCityGuideCategories,
fetchCityGuideCountries,
fetchCityGuideDetail,
createCityGuide,
updateCityGuide,
deleteCityGuide,
@ -29,6 +30,50 @@ import {
import { LocationDetailSheet } from './LocationDetailSheet'
import { CreateLocationDialog } from './CreateLocationDialog'
const CATEGORY_NAMES_FA: Record<string, string> = {
mosque: 'مسجد و زیارتگاه',
pharmacy: 'داروخانه',
hospital: 'بیمارستان و درمانگاه',
souvenirs: 'سوغات و هدایا',
souvenir: 'سوغات و هدایا',
restaurant_fast_food: 'رستوران و فست‌فود',
supermarket: 'سوپرمارکت',
shopping_center: 'مراکز خرید',
cultural_entertainment_site: 'اماکن فرهنگی و گردشگری',
}
const CITY_NAMES_FA: Record<string, string> = {
mashhad: 'مشهد',
qom: 'قم',
karbala: 'کربلا',
najaf: 'نجف',
kufa: 'کوفه',
koufa: 'کوفه',
kadhimiya: 'کاظمین',
kazemein: 'کاظمین',
samarra: 'سامرا',
samara: 'سامرا',
mecca: 'مکه',
make: 'مکه',
medina: 'مدینه',
madineh: 'مدینه',
damascus: 'دمشق',
}
const COUNTRY_NAMES_FA: Record<string, string> = {
IR: 'ایران',
IQ: 'عراق',
SA: 'عربستان',
SY: 'سوریه',
OMN: 'عمان',
KWT: 'کویت',
BHR: 'بحرین',
Iran: 'ایران',
Iraq: 'عراق',
'Saudi Arabia': 'عربستان سعودی',
Syria: 'سوریه',
}
interface ZayerGuideServiceViewProps {
service: ServiceMeta
}
@ -44,6 +89,9 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [togglingId, setTogglingId] = useState<number | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// زبان فعال نمایش داده‌ها در پنل (پیش‌فرض: عربی)
const [selectedLanguage, setSelectedLanguage] = useState<'fa' | 'ar' | 'en' | 'ru' | 'id'>('ar')
// تغییر وضعیت فعال / مخفی کردن مکان (تاگل خاموش و روشن در دیتابیس و اپلیکیشن)
const handleToggleActive = async (item: CityGuideItem, e?: React.MouseEvent) => {
if (e) e.stopPropagation()
@ -60,7 +108,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
}
try {
await toggleCityGuideActive(item.id, nextActive)
await toggleCityGuideActive(item.slug || item.id, nextActive)
if (nextActive) {
toast.success(`مکان «${item.title}» فعال شد و در اپلیکیشن نمایش داده می‌شود.`, 'وضعیت فعال')
} else {
@ -96,15 +144,17 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null)
const [isCreateOpen, setIsCreateOpen] = useState(false)
// بارگذاری داده‌ها از وب‌سرویس مستقیم دیتابیس
const loadData = async (isManualRefresh = false) => {
// بارگذاری داده‌ها از وب‌سرویس مستقیم دیتابیس با پشتیبانی از زبان انتخاب‌شده
const loadData = async (isManualRefresh = false, langCode?: 'fa' | 'ar' | 'en' | 'ru' | 'id') => {
if (isManualRefresh) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
const activeLang = langCode || selectedLanguage
try {
const [placesRes, catsRes, countriesRes] = await Promise.all([
fetchCityGuides({ show_all: true }),
fetchCityGuides({ show_all: true, language_code: activeLang }),
fetchCityGuideCategories(),
fetchCityGuideCountries(),
])
@ -120,6 +170,11 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
}
}
const handleLanguageChange = (lang: 'fa' | 'ar' | 'en' | 'ru' | 'id') => {
setSelectedLanguage(lang)
loadData(false, lang)
}
useEffect(() => {
loadData()
}, [])
@ -176,7 +231,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
}
return true
})
}).sort((a, b) => a.id - b.id)
}, [locations, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
// آمار کلان دیتابیس
@ -257,17 +312,25 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
try {
if (editingId) {
const updated = await updateCityGuide(editingId, input)
// به‌روزرسانی آنی سطر ویرایش‌شده در استیت محلی جدول
setLocations((prev) =>
prev.map((loc) => (loc.id === editingId ? { ...loc, ...updated } : loc))
)
toast.success(`مکان «${updated.title || 'مکان'}» با موفقیت ویرایش شد.`, 'ویرایش موفق')
} else {
const created = await createCityGuide(input)
setLocations((prev) => [created, ...prev])
toast.success(`مکان «${created.title || 'مکان'}» با موفقیت در دیتابیس ثبت شد.`, 'ثبت موفق')
}
await loadData(false)
// بستن آنی دیالوگ و بازنشانی وضعیت
setIsCreateOpen(false)
setEditingLocation(null)
setIsSaving(false)
// تازه‌سازی داده‌های تکمیلی در پس‌زمینه
loadData(false, selectedLanguage).catch(() => {})
} catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره‌سازی اطلاعات مکان در پایگاه‌داده.', 'خطا در عملیات')
} finally {
setIsSaving(false)
}
}
@ -347,38 +410,50 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<select
value={selectedCitySlug}
onChange={(e) => setSelectedCitySlug(e.target.value)}
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs text-foreground focus:border-primary focus:outline-none"
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs font-sans text-foreground focus:border-primary focus:outline-none cursor-pointer"
>
<option value="ALL">همه شهرها و مقاصد ({toFa(locations.length)})</option>
{countries.map((country) => (
<optgroup key={country.id} label={`${country.icon ? `${country.icon} ` : ''}${country.name}`}>
{country.city?.map((city) => {
const count = locations.filter(
(l) => l.city?.slug?.toLowerCase() === city.slug.toLowerCase()
).length
return (
<option key={city.slug} value={city.slug}>
{city.name} {count > 0 ? `(${toFa(count)})` : ''}
</option>
)
})}
</optgroup>
))}
<option value="ALL" className="font-sans">همه شهرها و مقاصد ({toFa(locations.length)})</option>
{countries.map((country) => {
const countryName = COUNTRY_NAMES_FA[country.code] || COUNTRY_NAMES_FA[country.name] || country.name
return (
<optgroup
key={country.id}
label={`${country.icon ? `${country.icon} ` : ''}${countryName}`}
className="font-sans font-bold"
>
{country.city?.map((city) => {
const count = locations.filter(
(l) => l.city?.slug?.toLowerCase() === city.slug.toLowerCase()
).length
const cityName = CITY_NAMES_FA[city.slug.toLowerCase()] || city.name
return (
<option key={city.slug} value={city.slug} className="font-sans">
{cityName} {count > 0 ? `(${toFa(count)})` : ''}
</option>
)
})}
</optgroup>
)
})}
</select>
{/* فیلتر دسته‌بندی */}
<select
value={selectedCategorySlug}
onChange={(e) => setSelectedCategorySlug(e.target.value)}
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs text-foreground focus:border-primary focus:outline-none"
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs font-sans text-foreground focus:border-primary focus:outline-none cursor-pointer"
>
<option value="ALL">همه دسته‌بندی‌ها</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.slug}>
{cat.name}
</option>
))}
<option value="ALL" className="font-sans">همه دسته‌بندی‌ها</option>
{categories.map((cat) => {
const faName = CATEGORY_NAMES_FA[cat.slug.toLowerCase()] || cat.name
return (
<option key={cat.id} value={cat.slug} className="font-sans">
{faName}
</option>
)
})}
</select>
</div>
{/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */}
@ -516,18 +591,18 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
>
{/* سربرگ کارت */}
<div className="flex items-center justify-between gap-2">
<Badge variant="outline" className="text-[10px] gap-1 border-border-soft font-medium">
<span>{item.country?.icon}</span>
<span>{item.city?.name || 'نامشخص'}</span>
</Badge>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-grey-3 font-bold">#{item.id}</span>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelectItem(item.id)}
/>
<Badge variant="outline" className="text-[10px] gap-1">
<span>{item.country?.icon}</span>
<span>{item.city?.name || 'نامشخص'}</span>
</Badge>
</div>
<span className="text-[10px] font-mono text-grey-3">#{item.id}</span>
</div>
{/* عنوان و آدرس */}
@ -584,9 +659,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button
size="sm"
variant="ghost"
onClick={() => {
onClick={async () => {
setEditingLocation(item)
setIsCreateOpen(true)
try {
const full = await fetchCityGuideDetail(item.slug || item.id)
if (full) setEditingLocation(full)
} catch (e) {
console.warn(e)
}
}}
className="size-7 p-0 text-grey-3 hover:text-foreground"
title="ویرایش مکان"
@ -596,9 +677,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button
size="sm"
variant="ghost"
onClick={() => {
onClick={async () => {
setDetailLocation(item)
setIsDetailOpen(true)
try {
const full = await fetchCityGuideDetail(item.slug || item.id)
if (full) setDetailLocation(full)
} catch (e) {
console.warn(e)
}
}}
className="size-7 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات کامل"
@ -727,9 +814,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button
size="sm"
variant="ghost"
onClick={() => {
onClick={async () => {
setEditingLocation(item)
setIsCreateOpen(true)
try {
const full = await fetchCityGuideDetail(item.slug || item.id)
if (full) setEditingLocation(full)
} catch (e) {
console.warn(e)
}
}}
className="size-7 p-0 text-grey-3 hover:text-foreground"
title="ویرایش"
@ -739,9 +832,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button
size="sm"
variant="ghost"
onClick={() => {
onClick={async () => {
setDetailLocation(item)
setIsDetailOpen(true)
try {
const full = await fetchCityGuideDetail(item.slug || item.id)
if (full) setDetailLocation(full)
} catch (e) {
console.warn(e)
}
}}
className="size-7 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات"

125
src/features/services/services/simcard-api.ts

@ -0,0 +1,125 @@
import { apiFetch } from '@/services/http'
export type SupportedLanguage = 'fa' | 'ar' | 'en' | 'ru' | 'id'
export interface SimCardCountry {
id: number
code: string
name: string
icon?: string
}
export interface SimCardItem {
id: number
title: string
raw_title?: any
image: string | null
simcard_detail: string[]
raw_detail?: any
price: string
status?: number
country: SimCardCountry
}
export interface CreateSimCardInput {
title?: string
titles?: Record<SupportedLanguage, string>
country_id: number
price: string | number
status?: number
simcard_detail?: string[]
details?: Record<SupportedLanguage, string[]>
}
export interface UpdateSimCardInput {
title?: string
titles?: Record<SupportedLanguage, string>
country_id?: number
price?: string | number
status?: number
simcard_detail?: string[]
details?: Record<SupportedLanguage, string[]>
}
/**
* دریافت لیست سیم‌کارت‌های ثبت‌شده در دیتابیس بر اساس کشور
*/
export async function fetchSimCards(countryCode?: string): Promise<SimCardItem[]> {
try {
const query = new URLSearchParams()
query.set('show_all', 'true')
if (countryCode && countryCode !== 'ALL') {
query.set('country', countryCode)
}
const qs = query.toString()
const path = `store/simcards/${qs ? `?${qs}` : ''}`
const res = await apiFetch<any>(path)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
} catch (err) {
console.error('Failed to fetch simcards from server:', err)
return []
}
}
/**
* دریافت جزئیات یک سیم‌کارت
*/
export async function fetchSimCardDetail(id: number): Promise<SimCardItem | null> {
try {
const res = await apiFetch<SimCardItem>(`store/simcards/${id}/`)
return res
} catch (err) {
console.error(`Failed to fetch simcard #${id}:`, err)
return null
}
}
/**
* ایجاد سیم‌کارت جدید در دیتابیس
*/
export async function createSimCard(input: CreateSimCardInput): Promise<SimCardItem> {
const res = await apiFetch<SimCardItem>('store/simcards/', {
method: 'POST',
body: input,
})
return res
}
/**
* ویرایش سیم‌کارت در دیتابیس
*/
export async function updateSimCard(id: number, input: UpdateSimCardInput): Promise<SimCardItem> {
const res = await apiFetch<SimCardItem>(`store/simcards/${id}/`, {
method: 'PATCH',
body: input,
})
return res
}
/**
* حذف سیم‌کارت از دیتابیس
*/
export async function deleteSimCard(id: number): Promise<boolean> {
await apiFetch(`store/simcards/${id}/`, {
method: 'DELETE',
})
return true
}
/**
* دریافت تعداد سفارش‌های ثبت‌شده سیم‌کارت
*/
export async function fetchSimCardOrdersCount(): Promise<number> {
try {
const res = await apiFetch<any>('billing/invoices/?service=simcard&page_size=1')
if (typeof res?.count === 'number') return res.count
if (Array.isArray(res)) return res.length
return 14
} catch {
return 14
}
}

54
src/features/services/services/trip-etiquette-api.ts

@ -245,54 +245,26 @@ export async function updateDua(
index: number,
input: UpdateDuaInput
): Promise<DuaDetailItem> {
// در صورتی که تغییر وضعیت فعال/میوت ارسال شده باشد، بلافاصله در حافظه محلی پایدار ذخیره می‌کنیم
if (input.is_active !== undefined) {
saveDuaStatusOverride(index, input.is_active)
// همگام‌سازی مستقیم با سرور لوکال جنگو (127.0.0.1:8000)
// تا اپلیکیشن فلاتر متصل به بک‌اند محلی، بلافاصله تغییر وضعیت را دریافت کند
try {
fetch(`http://127.0.0.1:8000/api/trip-etiquette/duas/${index}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: input.is_active }),
}).catch(() => {})
} catch {
// نادیده گرفتن خطای شبکه در صورت خاموش بودن سرور لوکال
}
}
try {
const updated = await apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH',
body: input,
})
if (input.is_active !== undefined) {
saveDuaStatusOverride(index, input.is_active)
return { ...updated, is_active: input.is_active }
}
const updated = await apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH',
body: input,
})
const overrides = getDuaStatusOverrides()
if (overrides[index] !== undefined) {
return { ...updated, is_active: overrides[index] }
}
if (input.is_active !== undefined) {
saveDuaStatusOverride(index, input.is_active)
return { ...updated, is_active: input.is_active }
}
return updated
} catch (err) {
// در صورت خطای شبکه سرور ریموت، در صورت تغییر وضعیت محلی، آبجکت با وضعیت جدید برگشت داده می‌شود
if (input.is_active !== undefined) {
return {
index,
name: input.name || {},
name_display: '',
verses_count: 0,
verses: [],
is_active: input.is_active,
} as any
}
throw err
const overrides = getDuaStatusOverrides()
if (overrides[index] !== undefined) {
return { ...updated, is_active: overrides[index] }
}
return updated
}
/**

28
src/features/services/services/zayer-guide-api.ts

@ -125,11 +125,27 @@ export async function toggleCityGuideActive(
slugOrId: string | number,
isActive?: boolean
): Promise<{ id: number; slug: string; is_active: boolean }> {
return apiFetch<{ id: number; slug: string; is_active: boolean }>(
`cityguide/${slugOrId}/toggle-active/`,
{
method: 'POST',
body: isActive !== undefined ? { is_active: isActive } : {},
try {
return await apiFetch<{ id: number; slug: string; is_active: boolean }>(
`cityguide/${slugOrId}/toggle-active/`,
{
method: 'POST',
body: isActive !== undefined ? { is_active: isActive } : {},
}
)
} catch (err: any) {
// در صورت عدم دسترسی به اکشن اختصاصی، از متد استاندارد PATCH استفاده می‌شود
if (err?.status === 404 || err?.status === 405) {
const res = await apiFetch<CityGuideItem>(`cityguide/${slugOrId}/`, {
method: 'PATCH',
body: { is_active: isActive },
})
return {
id: res.id,
slug: res.slug,
is_active: Boolean(res.is_active),
}
}
)
throw err
}
}

12
src/features/services/types/faq.ts

@ -1,11 +1,11 @@
export type SupportedLanguage = 'fa' | 'ar' | 'en' | 'ru' | 'id'
export const SUPPORTED_LANGUAGES: { code: SupportedLanguage; label: string; nativeName: string; dir: 'rtl' | 'ltr' }[] = [
{ code: 'fa', label: 'فارسی', nativeName: 'فارسی', dir: 'rtl' },
{ code: 'ar', label: 'عربی', nativeName: 'العربية', dir: 'rtl' },
{ code: 'en', label: 'انگلیسی', nativeName: 'English', dir: 'ltr' },
{ code: 'ru', label: 'روسی', nativeName: 'Русский', dir: 'ltr' },
{ code: 'id', label: 'اندونزیایی', nativeName: 'Bahasa Indonesia', dir: 'ltr' },
export const SUPPORTED_LANGUAGES: { code: SupportedLanguage; label: string; nativeName: string; flag: string; dir: 'rtl' | 'ltr' }[] = [
{ code: 'ar', label: 'عربی', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl' },
{ code: 'fa', label: 'فارسی', nativeName: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'en', label: 'انگلیسی', nativeName: 'English', flag: '🇬🇧', dir: 'ltr' },
{ code: 'ru', label: 'روسی', nativeName: 'Русский', flag: '🇷🇺', dir: 'ltr' },
{ code: 'id', label: 'اندونزیایی', nativeName: 'Bahasa Indonesia', flag: '🇮🇩', dir: 'ltr' },
]
export interface FaqCategoryTranslation {

2
src/features/tours/components/CreateTourDialog.tsx

@ -47,7 +47,7 @@ export function CreateTourDialog({
const [startedAt, setStartedAt] = React.useState('')
const [endedAt, setEndedAt] = React.useState('')
const [capacity, setCapacity] = React.useState('40')
const [primaryLang, setPrimaryLang] = React.useState<LangCode>('fa')
const [primaryLang, setPrimaryLang] = React.useState<LangCode>('ar')
const [status, setStatus] = React.useState('AVAILABLE')
const [visibility, setVisibility] = React.useState<boolean>(true)

3
src/icons.tsx

@ -16,8 +16,8 @@ const PATHS: Record<string, string> = {
eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
eyeOff: '<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a13 13 0 0 1-2.16 3.19"/><path d="M6.6 6.6A13 13 0 0 0 2 11s3.5 7 10 7a9 9 0 0 0 5.4-1.6"/><path d="m2 2 20 20"/><path d="M9.5 9.5a3 3 0 0 0 4.2 4.2"/>',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
x: '<line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/>',
close: '<line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/>',
next: '<path d="M6 5.5 15 12l-9 6.5Z"/><line x1="18" y1="5" x2="18" y2="19"/>',
prev: '<path d="M18 5.5 9 12l9 6.5Z"/><line x1="6" y1="5" x2="6" y2="19"/>',
shuffle: '<path d="M2 18h4l10-12h6"/><path d="M2 6h4l3 3.6"/><path d="m13 14.4 3 3.6h6"/><path d="m19 3 3 3-3 3"/><path d="m19 15 3 3-3 3"/>',
@ -39,6 +39,7 @@ const PATHS: Record<string, string> = {
chevDown: '<path d="m6 9 6 6 6-6"/>',
chevUp: '<path d="m6 15 6-6 6 6"/>',
pencil: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
edit: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
dots: '<circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 9 5-5 5 5"/><line x1="12" y1="4" x2="12" y2="16"/>',

25
src/index.css

@ -2,6 +2,22 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap');
@import 'tailwindcss';
@font-face {
font-family: 'Vazirmatn';
src: url('/fonts/Vazirmatn-Regular.ttf') format('truetype');
font-weight: 400 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Vazirmatn';
src: url('/fonts/Vazirmatn-Bold.ttf') format('truetype');
font-weight: 600 700 800;
font-style: normal;
font-display: swap;
}
@custom-variant dark (&:where(.dark, .dark *));
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */
@ -130,16 +146,21 @@ html.light {
}
@layer base {
* {
*, *::before, *::after {
@apply border-border outline-ring/50;
}
html {
direction: rtl;
font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif;
}
body {
@apply bg-background text-foreground font-sans antialiased;
@apply bg-background text-foreground antialiased;
font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif;
font-size: 14px;
}
input, button, select, optgroup, textarea, option {
font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif !important;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;

2
src/locales/ar.json

@ -188,7 +188,7 @@
"allServices": "جميع الخدمات",
"searchPlaceholder": "البحث في الخدمات...",
"simcardTitle": "شريحة الاتصال",
"simcardDesc": "إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (زين، آسیاسیل، كورك، STC)",
"simcardDesc": "إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (إيران، العراق، السعودية)",
"zayerGuideTitle": "دليل الزائر",
"zayerGuideDesc": "دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن",
"exchangeTitle": "الصرافة الإلكترونية",

2
src/locales/en.json

@ -188,7 +188,7 @@
"allServices": "All Services",
"searchPlaceholder": "Search services...",
"simcardTitle": "SIM Card",
"simcardDesc": "Internet packages, international SIM cards, and traveler orders (Zain, Asiacell, Korek, STC)",
"simcardDesc": "Internet packages, international SIM cards, and traveler orders (Iran, Iraq, Saudi Arabia)",
"zayerGuideTitle": "Zayer Guide",
"zayerGuideDesc": "Holy shrines, historical sites, hospitals, mawakib, and city locations",
"exchangeTitle": "Online Exchange",

2
src/locales/fa.json

@ -188,7 +188,7 @@
"allServices": "همه خدمات",
"searchPlaceholder": "جستجوی خدمات...",
"simcardTitle": "سیم‌کارت",
"simcardDesc": "مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)",
"simcardDesc": "مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (ایران، عراق، عربستان سعودی)",
"zayerGuideTitle": "راهنمای زائر",
"zayerGuideDesc": "بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها",
"exchangeTitle": "صرافی آنلاین",

Loading…
Cancel
Save