Browse Source

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

master
Muhammad A. Ghorbani 2 weeks ago
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. 422
      src/features/services/components/CreateLocationDialog.tsx
  7. 14
      src/features/services/components/FaqServiceView.tsx
  8. 48
      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. 159
      src/features/services/components/ZayerGuideServiceView.tsx
  13. 125
      src/features/services/services/simcard-api.ts
  14. 28
      src/features/services/services/trip-etiquette-api.ts
  15. 18
      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', allServices: 'All Services',
searchPlaceholder: 'Search services...', searchPlaceholder: 'Search services...',
simcardTitle: 'SIM Card', 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', zayerGuideTitle: 'Zayer Guide',
zayerGuideDesc: 'Holy shrines, historical sites, hospitals, mawakib, and city locations', zayerGuideDesc: 'Holy shrines, historical sites, hospitals, mawakib, and city locations',
exchangeTitle: 'Online Exchange', exchangeTitle: 'Online Exchange',
@ -477,7 +477,7 @@ export const faLocale = {
allServices: 'همه خدمات', allServices: 'همه خدمات',
searchPlaceholder: 'جستجوی خدمات...', searchPlaceholder: 'جستجوی خدمات...',
simcardTitle: 'سیم‌کارت', simcardTitle: 'سیم‌کارت',
simcardDesc: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)',
simcardDesc: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (ایران، عراق، عربستان سعودی)',
zayerGuideTitle: 'راهنمای زائر', zayerGuideTitle: 'راهنمای زائر',
zayerGuideDesc: 'بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها', zayerGuideDesc: 'بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها',
exchangeTitle: 'صرافی آنلاین', exchangeTitle: 'صرافی آنلاین',
@ -756,7 +756,7 @@ export const arLocale = {
allServices: 'جميع الخدمات', allServices: 'جميع الخدمات',
searchPlaceholder: 'البحث في الخدمات...', searchPlaceholder: 'البحث في الخدمات...',
simcardTitle: 'شريحة الاتصال', simcardTitle: 'شريحة الاتصال',
simcardDesc: 'إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (زين، آسیاسیل، كورك، STC)',
simcardDesc: 'إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (إيران، العراق، السعودية)',
zayerGuideTitle: 'دليل الزائر', zayerGuideTitle: 'دليل الزائر',
zayerGuideDesc: 'دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن', zayerGuideDesc: 'دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن',
exchangeTitle: 'الصرافة الإلكترونية', exchangeTitle: 'الصرافة الإلكترونية',

2
src/components/ui/checkbox.tsx

@ -6,7 +6,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
className={cn( 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, className,
)} )}
{...props} {...props}

136
src/features/services/ServicesPage.tsx

@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/shimmer_frontend' import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { SimcardServiceView } from './components/SimcardServiceView'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView' import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView' import { DuasServiceView } from './components/DuasServiceView'
import { CategoriesServiceView } from './components/CategoriesServiceView' import { CategoriesServiceView } from './components/CategoriesServiceView'
@ -39,10 +40,10 @@ export const SERVICES_LIST: ServiceMeta[] = [
color: 'text-primary', color: 'text-primary',
bgColor: 'bg-primary/10', bgColor: 'bg-primary/10',
borderColor: 'border-primary/20', borderColor: 'border-primary/20',
description: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)',
description: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (ایران، عراق، عربستان سعودی)',
endpoint: '/store/simcards/', endpoint: '/store/simcards/',
statsLabel: 'بسته‌های فعال', statsLabel: 'بسته‌های فعال',
defaultCount: 8,
defaultCount: 9,
badge: 'ارتباطات و دیتا', 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) // ۳. صرافی آنلاین و تشریفات (Online Exchange)

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

@ -11,12 +11,14 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type {
import {
CityGuideItem, CityGuideItem,
CityGuideCategory, CityGuideCategory,
CityGuideCountry, CityGuideCountry,
CreateCityGuideInput, CreateCityGuideInput,
} from '../types/zayer-guide' } from '../types/zayer-guide'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { fetchCityGuideDetail } from '../services/zayer-guide-api'
interface CreateLocationDialogProps { interface CreateLocationDialogProps {
open: boolean open: boolean
@ -38,6 +40,58 @@ const WEEK_DAYS = [
{ code: 'FR', label: 'جمعه' }, { 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({ export function CreateLocationDialog({
open, open,
onOpenChange, onOpenChange,
@ -47,23 +101,29 @@ export function CreateLocationDialog({
isSaving = false, isSaving = false,
onSave, onSave,
}: CreateLocationDialogProps) { }: CreateLocationDialogProps) {
// زبان فعال در تب‌های چندزبانه
const [activeLangTab, setActiveLangTab] = useState<'fa' | 'ar' | 'en'>('fa')
// زبان فعال در تب‌های چندزبانه (پیش‌فرض: عربی)
const [activeLangTab, setActiveLangTab] = useState<'ar' | 'fa' | 'en' | 'ru' | 'id'>('ar')
// عناوین چندزبانه // عناوین چندزبانه
const [titleFa, setTitleFa] = useState('') const [titleFa, setTitleFa] = useState('')
const [titleAr, setTitleAr] = useState('') const [titleAr, setTitleAr] = useState('')
const [titleEn, setTitleEn] = useState('') const [titleEn, setTitleEn] = useState('')
const [titleRu, setTitleRu] = useState('')
const [titleId, setTitleId] = useState('')
// توضیحات چندزبانه // توضیحات چندزبانه
const [descFa, setDescFa] = useState('') const [descFa, setDescFa] = useState('')
const [descAr, setDescAr] = useState('') const [descAr, setDescAr] = useState('')
const [descEn, setDescEn] = useState('') const [descEn, setDescEn] = useState('')
const [descRu, setDescRu] = useState('')
const [descId, setDescId] = useState('')
// آدرس‌های چندزبانه // آدرس‌های چندزبانه
const [addrFa, setAddrFa] = useState('') const [addrFa, setAddrFa] = useState('')
const [addrAr, setAddrAr] = useState('') const [addrAr, setAddrAr] = useState('')
const [addrEn, setAddrEn] = useState('') const [addrEn, setAddrEn] = useState('')
const [addrRu, setAddrRu] = useState('')
const [addrId, setAddrId] = useState('')
// مشخصات عمومی و مکانی // مشخصات عمومی و مکانی
const [phoneNumber, setPhoneNumber] = useState('') const [phoneNumber, setPhoneNumber] = useState('')
@ -77,60 +137,94 @@ export function CreateLocationDialog({
const [longitude, setLongitude] = useState('44.0244') const [longitude, setLongitude] = useState('44.0244')
const [imageUrl, setImageUrl] = useState('') const [imageUrl, setImageUrl] = useState('')
const [validationError, setValidationError] = useState<string | null>(null) const [validationError, setValidationError] = useState<string | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
// لیست کلیه شهرهای تابعه کشور انتخابی // لیست کلیه شهرهای تابعه کشور انتخابی
const currentCountry = countries.find((c) => c.id === countryId) || countries[0] const currentCountry = countries.find((c) => c.id === countryId) || countries[0]
const availableCities = currentCountry?.city || [] const availableCities = currentCountry?.city || []
useEffect(() => { useEffect(() => {
if (editingLocation) {
const trans = editingLocation.translations || {}
let isMounted = true
const applyLocationData = (loc: CityGuideItem) => {
const trans = loc.translations || {}
setTitleFa(trans.fa?.title || editingLocation.title || '')
setTitleAr(trans.ar?.title || '')
setTitleAr(trans.ar?.title || loc.title || '')
setTitleFa(trans.fa?.title || '')
setTitleEn(trans.en?.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 || '') 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 || '') 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) 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 = 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 : '') 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 { } else {
// مقادیر پیش‌فرض برای ایجاد مکان جدید // مقادیر پیش‌فرض برای ایجاد مکان جدید
setTitleFa('') setTitleFa('')
setTitleAr('') setTitleAr('')
setTitleEn('') setTitleEn('')
setTitleRu('')
setTitleId('')
setDescFa('') setDescFa('')
setDescAr('') setDescAr('')
setDescEn('') setDescEn('')
setDescRu('')
setDescId('')
setAddrFa('') setAddrFa('')
setAddrAr('') setAddrAr('')
setAddrEn('') setAddrEn('')
setAddrRu('')
setAddrId('')
setPhoneNumber('') setPhoneNumber('')
setCategoryId(categories[0]?.id || 1) setCategoryId(categories[0]?.id || 1)
const firstCountry = countries[0] const firstCountry = countries[0]
@ -148,6 +242,10 @@ export function CreateLocationDialog({
setImageUrl('') setImageUrl('')
} }
setValidationError(null) setValidationError(null)
return () => {
isMounted = false
}
}, [editingLocation, open, categories, countries]) }, [editingLocation, open, categories, countries])
const toggleDayOff = (dayCode: string) => { const toggleDayOff = (dayCode: string) => {
@ -159,8 +257,9 @@ export function CreateLocationDialog({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// حداقل عنوان فارسی یا اصلی باید پر باشد
const mainTitle = titleFa.trim() || titleAr.trim() || titleEn.trim()
// حداقل یک عنوان معتبر وارد شده باشد
const mainTitle =
titleFa.trim() || titleAr.trim() || titleEn.trim() || titleRu.trim() || titleId.trim()
if (!mainTitle) { if (!mainTitle) {
setValidationError('لطفاً عنوان مکان را وارد کنید.') setValidationError('لطفاً عنوان مکان را وارد کنید.')
return return
@ -177,21 +276,39 @@ export function CreateLocationDialog({
if (titleFa.trim()) titles.fa = titleFa.trim() if (titleFa.trim()) titles.fa = titleFa.trim()
if (titleAr.trim()) titles.ar = titleAr.trim() if (titleAr.trim()) titles.ar = titleAr.trim()
if (titleEn.trim()) titles.en = titleEn.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> = {} const descriptions: Record<string, string> = {}
if (descFa.trim()) descriptions.fa = descFa.trim() if (descFa.trim()) descriptions.fa = descFa.trim()
if (descAr.trim()) descriptions.ar = descAr.trim() if (descAr.trim()) descriptions.ar = descAr.trim()
if (descEn.trim()) descriptions.en = descEn.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> = {} const addresses: Record<string, string> = {}
if (addrFa.trim()) addresses.fa = addrFa.trim() if (addrFa.trim()) addresses.fa = addrFa.trim()
if (addrAr.trim()) addresses.ar = addrAr.trim() if (addrAr.trim()) addresses.ar = addrAr.trim()
if (addrEn.trim()) addresses.en = addrEn.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 = { const inputData: CreateCityGuideInput = {
title: mainTitle, 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, titles,
descriptions, descriptions,
addresses, addresses,
@ -240,110 +357,128 @@ export function CreateLocationDialog({
{/* تب‌های انتخاب زبان برای محتوای متنی */} {/* تب‌های انتخاب زبان برای محتوای متنی */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4"> <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="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> <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>
{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 <button
key={lang.code}
type="button" type="button"
onClick={() => setActiveLangTab('en')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'en'
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' ? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
: 'text-grey-2 hover:text-foreground hover:bg-card/60'
}`} }`}
> >
English
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.name}</span>
</button> </button>
)
})}
</div> </div>
</div> </div>
{/* بخش زبان فارسی */}
{activeLangTab === 'fa' && (
<div className="space-y-3">
{/* لودینگ اسکلتون شیمر خنثی در صورت خالی بودن داده‌ها هنگام دریافت ترجمه‌ها */}
{isLoadingDetail && !titleAr && !titleFa ? (
<div className="space-y-4 py-1.5 animate-in fade-in-50 duration-200">
<div className="space-y-1.5">
<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 className="space-y-1.5">
<ShimmerLine width={150} height={12} rounded="md" />
<ShimmerBox height={78} rounded="xl" className="w-full" />
</div>
<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"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground"> <label className="text-xs font-bold text-foreground">
عنوان مکان (فارسی) <span className="text-rose-500">*</span>
اسم المكان (بالعربية) <span className="text-rose-500">*</span>
</label> </label>
<Input <Input
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
value={titleAr}
onChange={(e) => setTitleAr(e.target.value)}
placeholder="مثال: حرم الإمام الحسين (ع)"
className="text-xs bg-surface-base" className="text-xs bg-surface-base"
required required
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شرح و توضیحات (فارسی)</label>
<label className="text-xs font-bold text-foreground">الوصف والخدمات (بالعربية)</label>
<textarea <textarea
value={descFa}
onChange={(e) => setDescFa(e.target.value)}
value={descAr}
onChange={(e) => setDescAr(e.target.value)}
rows={3} rows={3}
placeholder="توضیحات تاریخی، معنوی، خدمات یا امکانات این مکان..."
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" 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>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس دقیق (فارسی)</label>
<label className="text-xs font-bold text-foreground">العنوان (بالعربية)</label>
<Input <Input
value={addrFa}
onChange={(e) => setAddrFa(e.target.value)}
placeholder="خیابان، میدان، پلاک یا نشانی راهنمای زائر"
value={addrAr}
onChange={(e) => setAddrAr(e.target.value)}
placeholder="الشارع، المنطقة أو الحي"
className="text-xs bg-surface-base" className="text-xs bg-surface-base"
/> />
</div> </div>
</div> </div>
)} )}
{/* بخش زبان عربی */}
{activeLangTab === 'ar' && (
<div className="space-y-3" dir="rtl">
{/* بخش زبان فارسی */}
{activeLangTab === 'fa' && (
<div className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">اسم المكان (بالعربية)</label>
<label className="text-xs font-bold text-foreground">
عنوان مکان (فارسی)
</label>
<Input <Input
value={titleAr}
onChange={(e) => setTitleAr(e.target.value)}
placeholder="مثال: حرم الإمام الحسين (ع)"
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
className="text-xs bg-surface-base" className="text-xs bg-surface-base"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">الوصف والخدمات (بالعربية)</label>
<label className="text-xs font-bold text-foreground">شرح و توضیحات (فارسی)</label>
<textarea <textarea
value={descAr}
onChange={(e) => setDescAr(e.target.value)}
value={descFa}
onChange={(e) => setDescFa(e.target.value)}
rows={3} rows={3}
placeholder="شرح موجز عن المكان والخدمات..."
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" 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>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">العنوان (بالعربية)</label>
<label className="text-xs font-bold text-foreground">آدرس دقیق (فارسی)</label>
<Input <Input
value={addrAr}
onChange={(e) => setAddrAr(e.target.value)}
placeholder="الشارع، المنطقة أو الحي"
value={addrFa}
onChange={(e) => setAddrFa(e.target.value)}
placeholder="خیابان، میدان، پلاک یا نشانی راهنمای زائر"
className="text-xs bg-surface-base" className="text-xs bg-surface-base"
/> />
</div> </div>
@ -383,6 +518,76 @@ export function CreateLocationDialog({
</div> </div>
</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> </div>
{/* دسته‌بندی و شهر/کشور مقصد */} {/* دسته‌بندی و شهر/کشور مقصد */}
@ -394,13 +599,16 @@ export function CreateLocationDialog({
<select <select
value={categoryId} value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))} 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}
{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> </option>
))}
)
})}
</select> </select>
</div> </div>
@ -415,17 +623,23 @@ export function CreateLocationDialog({
setCountryId(coId) setCountryId(coId)
setCityId(ciId) 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})
{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> </option>
))}
)
})}
</optgroup> </optgroup>
))}
)
})}
</select> </select>
</div> </div>
</div> </div>

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

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

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

@ -6,7 +6,6 @@ import {
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons' import { Ic } from '@/icons'
@ -41,7 +40,7 @@ export function LocationDetailSheet({
onToggleActive, onToggleActive,
isToggling, isToggling,
}: LocationDetailSheetProps) { }: LocationDetailSheetProps) {
const [activeLang, setActiveLang] = useState<'fa' | 'ar' | 'en'>('fa')
const [activeLang, setActiveLang] = useState<'fa' | 'ar' | 'en' | 'ru' | 'id'>('fa')
if (!location) return null if (!location) return null
@ -71,29 +70,26 @@ export function LocationDetailSheet({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <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 */} {/* 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">
<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"> <SheetTitle className="text-lg font-black text-foreground">
{displayTitle} {displayTitle}
</SheetTitle> </SheetTitle>
</div>
<SheetDescription className="text-xs text-grey-3 font-mono" dir="ltr">
ID: #{location.id} | Slug: {location.slug}
<SheetDescription className="sr-only">
جزئیات مکان {displayTitle}
</SheetDescription> </SheetDescription>
</div>
<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"
<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="بستن پنجره"
> >
{location.category?.name || 'مکان زیارتی'}
</Badge>
</div>
<Ic name="x" className="size-4" />
</button>
</div> </div>
</SheetHeader> </SheetHeader>
@ -189,18 +185,24 @@ export function LocationDetailSheet({
<div className="flex items-center justify-between border-b border-border-soft pb-2"> <div className="flex items-center justify-between border-b border-border-soft pb-2">
<span className="text-xs font-bold text-foreground">مشاهده زبان ترجمه</span> <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"> <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 <button
key={l}
key={l.code}
type="button" type="button"
onClick={() => setActiveLang(l)}
onClick={() => setActiveLang(l.code)}
className={`px-2 py-0.5 rounded-lg transition-colors font-medium ${ className={`px-2 py-0.5 rounded-lg transition-colors font-medium ${
activeLang === l
activeLang === l.code
? 'bg-primary text-white font-bold' ? 'bg-primary text-white font-bold'
: 'text-grey-3 hover:text-foreground' : 'text-grey-3 hover:text-foreground'
}`} }`}
> >
{l === 'fa' ? 'فارسی' : l === 'ar' ? 'العربية' : 'EN'}
{l.label}
</button> </button>
))} ))}
</div> </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 [editingCategory, setEditingCategory] = useState<StoreCategoryItem | null>(null)
const [categoryTitleInput, setCategoryTitleInput] = useState('') const [categoryTitleInput, setCategoryTitleInput] = useState('')
const [categorySlugInput, setCategorySlugInput] = useState('') const [categorySlugInput, setCategorySlugInput] = useState('')
const [catActiveLang, setCatActiveLang] = useState<SupportedLanguage>('fa')
const [catActiveLang, setCatActiveLang] = useState<SupportedLanguage>('ar')
const [catTranslations, setCatTranslations] = useState<Record<SupportedLanguage, string>>({ const [catTranslations, setCatTranslations] = useState<Record<SupportedLanguage, string>>({
fa: '', fa: '',
ar: '', ar: '',
@ -266,7 +266,7 @@ export function StoreServiceView({
ru: '', ru: '',
id: '', id: '',
}) })
setCatActiveLang('fa')
setCatActiveLang('ar')
setIsCategoryModalOpen(true) setIsCategoryModalOpen(true)
} }
@ -309,8 +309,8 @@ export function StoreServiceView({
initialTr.fa = category.title || category.name initialTr.fa = category.title || category.name
} }
setCatTranslations(initialTr) setCatTranslations(initialTr)
setCatActiveLang('fa')
setCategoryTitleInput(initialTr.fa || category.title || category.name || '')
setCatActiveLang('ar')
setCategoryTitleInput(initialTr.ar || initialTr.fa || category.title || category.name || '')
setIsCategoryModalOpen(true) setIsCategoryModalOpen(true)
} }
@ -1112,6 +1112,7 @@ export function StoreServiceView({
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)} )}
> >
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.nativeName}</span> <span>{lang.nativeName}</span>
{isFilled && ( {isFilled && (
<span <span

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

@ -20,6 +20,7 @@ import {
fetchCityGuides, fetchCityGuides,
fetchCityGuideCategories, fetchCityGuideCategories,
fetchCityGuideCountries, fetchCityGuideCountries,
fetchCityGuideDetail,
createCityGuide, createCityGuide,
updateCityGuide, updateCityGuide,
deleteCityGuide, deleteCityGuide,
@ -29,6 +30,50 @@ import {
import { LocationDetailSheet } from './LocationDetailSheet' import { LocationDetailSheet } from './LocationDetailSheet'
import { CreateLocationDialog } from './CreateLocationDialog' 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 { interface ZayerGuideServiceViewProps {
service: ServiceMeta service: ServiceMeta
} }
@ -44,6 +89,9 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [togglingId, setTogglingId] = useState<number | null>(null) const [togglingId, setTogglingId] = useState<number | null>(null)
const [errorMessage, setErrorMessage] = useState<string | 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) => { const handleToggleActive = async (item: CityGuideItem, e?: React.MouseEvent) => {
if (e) e.stopPropagation() if (e) e.stopPropagation()
@ -60,7 +108,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
} }
try { try {
await toggleCityGuideActive(item.id, nextActive)
await toggleCityGuideActive(item.slug || item.id, nextActive)
if (nextActive) { if (nextActive) {
toast.success(`مکان «${item.title}» فعال شد و در اپلیکیشن نمایش داده می‌شود.`, 'وضعیت فعال') toast.success(`مکان «${item.title}» فعال شد و در اپلیکیشن نمایش داده می‌شود.`, 'وضعیت فعال')
} else { } else {
@ -96,15 +144,17 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null) const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null)
const [isCreateOpen, setIsCreateOpen] = useState(false) 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) if (isManualRefresh) setIsRefreshing(true)
else setIsLoading(true) else setIsLoading(true)
setErrorMessage(null) setErrorMessage(null)
const activeLang = langCode || selectedLanguage
try { try {
const [placesRes, catsRes, countriesRes] = await Promise.all([ const [placesRes, catsRes, countriesRes] = await Promise.all([
fetchCityGuides({ show_all: true }),
fetchCityGuides({ show_all: true, language_code: activeLang }),
fetchCityGuideCategories(), fetchCityGuideCategories(),
fetchCityGuideCountries(), fetchCityGuideCountries(),
]) ])
@ -120,6 +170,11 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
} }
} }
const handleLanguageChange = (lang: 'fa' | 'ar' | 'en' | 'ru' | 'id') => {
setSelectedLanguage(lang)
loadData(false, lang)
}
useEffect(() => { useEffect(() => {
loadData() loadData()
}, []) }, [])
@ -176,7 +231,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
} }
return true return true
})
}).sort((a, b) => a.id - b.id)
}, [locations, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm]) }, [locations, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
// آمار کلان دیتابیس // آمار کلان دیتابیس
@ -257,17 +312,25 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
try { try {
if (editingId) { if (editingId) {
const updated = await updateCityGuide(editingId, input) const updated = await updateCityGuide(editingId, input)
// به‌روزرسانی آنی سطر ویرایش‌شده در استیت محلی جدول
setLocations((prev) =>
prev.map((loc) => (loc.id === editingId ? { ...loc, ...updated } : loc))
)
toast.success(`مکان «${updated.title || 'مکان'}» با موفقیت ویرایش شد.`, 'ویرایش موفق') toast.success(`مکان «${updated.title || 'مکان'}» با موفقیت ویرایش شد.`, 'ویرایش موفق')
} else { } else {
const created = await createCityGuide(input) const created = await createCityGuide(input)
setLocations((prev) => [created, ...prev])
toast.success(`مکان «${created.title || 'مکان'}» با موفقیت در دیتابیس ثبت شد.`, 'ثبت موفق') toast.success(`مکان «${created.title || 'مکان'}» با موفقیت در دیتابیس ثبت شد.`, 'ثبت موفق')
} }
await loadData(false)
// بستن آنی دیالوگ و بازنشانی وضعیت
setIsCreateOpen(false) setIsCreateOpen(false)
setEditingLocation(null) setEditingLocation(null)
setIsSaving(false)
// تازه‌سازی داده‌های تکمیلی در پس‌زمینه
loadData(false, selectedLanguage).catch(() => {})
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره‌سازی اطلاعات مکان در پایگاه‌داده.', 'خطا در عملیات') toast.error(err?.message || 'خطا در ذخیره‌سازی اطلاعات مکان در پایگاه‌داده.', 'خطا در عملیات')
} finally {
setIsSaving(false) setIsSaving(false)
} }
} }
@ -347,38 +410,50 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<select <select
value={selectedCitySlug} value={selectedCitySlug}
onChange={(e) => setSelectedCitySlug(e.target.value)} 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" 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"
> >
<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) => { {country.city?.map((city) => {
const count = locations.filter( const count = locations.filter(
(l) => l.city?.slug?.toLowerCase() === city.slug.toLowerCase() (l) => l.city?.slug?.toLowerCase() === city.slug.toLowerCase()
).length ).length
const cityName = CITY_NAMES_FA[city.slug.toLowerCase()] || city.name
return ( return (
<option key={city.slug} value={city.slug}>
{city.name} {count > 0 ? `(${toFa(count)})` : ''}
<option key={city.slug} value={city.slug} className="font-sans">
{cityName} {count > 0 ? `(${toFa(count)})` : ''}
</option> </option>
) )
})} })}
</optgroup> </optgroup>
))}
)
})}
</select> </select>
{/* فیلتر دسته‌بندی */} {/* فیلتر دسته‌بندی */}
<select <select
value={selectedCategorySlug} value={selectedCategorySlug}
onChange={(e) => setSelectedCategorySlug(e.target.value)} 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 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> </option>
))}
)
})}
</select> </select>
</div> </div>
{/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */} {/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */}
@ -516,18 +591,18 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
> >
{/* سربرگ کارت */} {/* سربرگ کارت */}
<div className="flex items-center justify-between gap-2"> <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"> <div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-grey-3 font-bold">#{item.id}</span>
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => handleToggleSelectItem(item.id)} onCheckedChange={() => handleToggleSelectItem(item.id)}
/> />
<Badge variant="outline" className="text-[10px] gap-1">
<span>{item.country?.icon}</span>
<span>{item.city?.name || 'نامشخص'}</span>
</Badge>
</div> </div>
<span className="text-[10px] font-mono text-grey-3">#{item.id}</span>
</div> </div>
{/* عنوان و آدرس */} {/* عنوان و آدرس */}
@ -584,9 +659,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => {
onClick={async () => {
setEditingLocation(item) setEditingLocation(item)
setIsCreateOpen(true) 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" className="size-7 p-0 text-grey-3 hover:text-foreground"
title="ویرایش مکان" title="ویرایش مکان"
@ -596,9 +677,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => {
onClick={async () => {
setDetailLocation(item) setDetailLocation(item)
setIsDetailOpen(true) 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" className="size-7 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات کامل" title="مشاهده جزئیات کامل"
@ -727,9 +814,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => {
onClick={async () => {
setEditingLocation(item) setEditingLocation(item)
setIsCreateOpen(true) 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" className="size-7 p-0 text-grey-3 hover:text-foreground"
title="ویرایش" title="ویرایش"
@ -739,9 +832,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => {
onClick={async () => {
setDetailLocation(item) setDetailLocation(item)
setIsDetailOpen(true) 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" className="size-7 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات" 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
}
}

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

@ -245,24 +245,10 @@ export async function updateDua(
index: number, index: number,
input: UpdateDuaInput input: UpdateDuaInput
): Promise<DuaDetailItem> { ): Promise<DuaDetailItem> {
// در صورتی که تغییر وضعیت فعال/میوت ارسال شده باشد، بلافاصله در حافظه محلی پایدار ذخیره می‌کنیم
if (input.is_active !== undefined) { if (input.is_active !== undefined) {
saveDuaStatusOverride(index, input.is_active) 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}/`, { const updated = await apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH', method: 'PATCH',
body: input, body: input,
@ -279,20 +265,6 @@ export async function updateDua(
} }
return updated 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
}
} }
/** /**

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

@ -125,11 +125,27 @@ export async function toggleCityGuideActive(
slugOrId: string | number, slugOrId: string | number,
isActive?: boolean isActive?: boolean
): Promise<{ id: number; slug: string; is_active: boolean }> { ): Promise<{ id: number; slug: string; is_active: boolean }> {
return apiFetch<{ id: number; slug: string; is_active: boolean }>(
try {
return await apiFetch<{ id: number; slug: string; is_active: boolean }>(
`cityguide/${slugOrId}/toggle-active/`, `cityguide/${slugOrId}/toggle-active/`,
{ {
method: 'POST', method: 'POST',
body: isActive !== undefined ? { is_active: isActive } : {}, 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 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 { export interface FaqCategoryTranslation {

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

@ -47,7 +47,7 @@ export function CreateTourDialog({
const [startedAt, setStartedAt] = React.useState('') const [startedAt, setStartedAt] = React.useState('')
const [endedAt, setEndedAt] = React.useState('') const [endedAt, setEndedAt] = React.useState('')
const [capacity, setCapacity] = React.useState('40') 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 [status, setStatus] = React.useState('AVAILABLE')
const [visibility, setVisibility] = React.useState<boolean>(true) 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"/>', 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"/>', 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"/>', 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"/>', 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"/>', 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"/>', 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"/>', 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"/>', chevDown: '<path d="m6 9 6 6 6-6"/>',
chevUp: '<path d="m6 15 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"/>', 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"/>', 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"/>', 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"/>', 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 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'; @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 *)); @custom-variant dark (&:where(.dark, .dark *));
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ /* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */
@ -130,16 +146,21 @@ html.light {
} }
@layer base { @layer base {
* {
*, *::before, *::after {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html { html {
direction: rtl; direction: rtl;
font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif;
} }
body { 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; font-size: 14px;
} }
input, button, select, optgroup, textarea, option {
font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif !important;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;

2
src/locales/ar.json

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

2
src/locales/en.json

@ -188,7 +188,7 @@
"allServices": "All Services", "allServices": "All Services",
"searchPlaceholder": "Search services...", "searchPlaceholder": "Search services...",
"simcardTitle": "SIM Card", "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", "zayerGuideTitle": "Zayer Guide",
"zayerGuideDesc": "Holy shrines, historical sites, hospitals, mawakib, and city locations", "zayerGuideDesc": "Holy shrines, historical sites, hospitals, mawakib, and city locations",
"exchangeTitle": "Online Exchange", "exchangeTitle": "Online Exchange",

2
src/locales/fa.json

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

Loading…
Cancel
Save