Browse Source

feat: add Zayer Guide service feature with location management UI and API integration

master
parent
commit
0e1013bd0b
  1. 107
      src/features/services/ServicesPage.tsx
  2. 425
      src/features/services/components/CreateLocationDialog.tsx
  3. 251
      src/features/services/components/LocationDetailSheet.tsx
  4. 782
      src/features/services/components/ZayerGuideServiceView.tsx
  5. 465
      src/features/services/services/zayer-guide-api.ts
  6. 108
      src/features/services/types/zayer-guide.ts

107
src/features/services/ServicesPage.tsx

@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer' import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { apiFetch } from '@/services/http' import { apiFetch } from '@/services/http'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
// تعریف ساختار داده‌های سرویس‌ها // تعریف ساختار داده‌های سرویس‌ها
export interface ServiceMeta { export interface ServiceMeta {
@ -547,112 +548,6 @@ function SimcardServiceView({ service }: { service: ServiceMeta }) {
) )
} }
// ─────────────────────────────────────────────────────────────────────────────
// ۲. راهنمای زائر (Zayer Guide)
// ─────────────────────────────────────────────────────────────────────────────
function ZayerGuideServiceView({ service }: { service: ServiceMeta }) {
const [cityFilter, setCityFilter] = useState('ALL')
const places = [
{ id: 1, title: 'حرم مطهر امام علی (ع)', city: 'نجف اشرف', category: 'اماکن مقدس', rating: '5.0', visits: 18400, country: 'عراق' },
{ id: 2, title: 'حرم مطهر امام حسین (ع) و حضرت عباس (ع)', city: 'کربلای معلی', category: 'اماکن مقدس', rating: '5.0', visits: 24500, country: 'عراق' },
{ id: 3, title: 'مسجد کوفه و مسجد سهله', city: 'کوفه', category: 'مساجد تاریخی', rating: '4.9', visits: 9800, country: 'عراق' },
{ id: 4, title: 'مسجد الحرام و کعبه مشرفه', city: 'مکه مکرمه', category: 'اماکن مقدس', rating: '5.0', visits: 32000, country: 'عربستان' },
{ id: 5, title: 'مسجد النبی (ص) و بقیع', city: 'مدینه منوره', category: 'اماکن مقدس', rating: '5.0', visits: 29000, country: 'عربستان' },
{ id: 6, title: 'حرم مطهر حضرت زینب (س)', city: 'دمشق', category: 'اماکن مقدس', rating: '4.9', visits: 6400, country: 'سوریه' },
]
const filtered = places.filter((p) => {
if (cityFilter !== 'ALL' && !p.city.includes(cityFilter)) 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-sky-500/20 bg-sky-500/5 p-4">
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">اماکن و موقعیتهای ثبتشده</span>
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono">{toFa(142)}</div>
</div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">مواکب و مراکز امدادی فعال</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{toFa(85)}</div>
</div>
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">بازدید ماهانه زائرین از نقشهها</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">{toFa('58.4K')}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">دستهبندیها</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(8)}</div>
</div>
</div>
{/* فیلتر شهرها */}
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<Button
size="sm"
variant={cityFilter === 'ALL' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('ALL')}
className="text-xs h-8"
>
همه شهرها
</Button>
<Button
size="sm"
variant={cityFilter === 'نجف' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('نجف')}
className="text-xs h-8"
>
نجف اشرف
</Button>
<Button
size="sm"
variant={cityFilter === 'کربلا' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('کربلا')}
className="text-xs h-8"
>
کربلای معلی
</Button>
<Button
size="sm"
variant={cityFilter === 'مکه' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('مکه')}
className="text-xs h-8"
>
مکه مکرمه
</Button>
<Button
size="sm"
variant={cityFilter === 'مدینه' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('مدینه')}
className="text-xs h-8"
>
مدینه منوره
</Button>
</div>
{/* لیست کارت‌های مکان‌ها */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((item) => (
<div key={item.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-[10px]">{item.city} ({item.country})</Badge>
<span className="text-amber-500 text-xs font-bold font-mono"> {item.rating}</span>
</div>
<h3 className="text-sm font-black text-foreground">{item.title}</h3>
<div className="flex items-center justify-between text-xs text-grey-3 pt-2 border-t border-border-soft">
<span>دسته: {item.category}</span>
<span className="font-mono">{toFa(item.visits)} بازدید</span>
</div>
</div>
))}
</div>
</div>
)
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ۳. صرافی آنلاین و تشریفات (Online Exchange) // ۳. صرافی آنلاین و تشریفات (Online Exchange)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

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

@ -0,0 +1,425 @@
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 { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons'
import type {
CityGuideItem,
CityGuideCategory,
CityGuideCountry,
CreateCityGuideInput,
} from '../types/zayer-guide'
interface CreateLocationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
categories: CityGuideCategory[]
countries: CityGuideCountry[]
editingLocation?: CityGuideItem | null
onSave: (data: CreateCityGuideInput, editingId?: number) => void
}
const WEEK_DAYS = [
{ code: 'SA', label: 'شنبه' },
{ code: 'SU', label: 'یکشنبه' },
{ code: 'MO', label: 'دوشنبه' },
{ code: 'TU', label: 'سه‌شنبه' },
{ code: 'WE', label: 'چهارشنبه' },
{ code: 'TH', label: 'پنج‌شنبه' },
{ code: 'FR', label: 'جمعه' },
]
export function CreateLocationDialog({
open,
onOpenChange,
categories,
countries,
editingLocation,
onSave,
}: CreateLocationDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [address, setAddress] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const [categoryId, setCategoryId] = useState<number>(categories[0]?.id || 1)
const [countryId, setCountryId] = useState<number>(countries[0]?.id || 1)
const [cityId, setCityId] = useState<number>(1)
const [workingHoursFrom, setWorkingHoursFrom] = useState('08:00')
const [workingHoursTo, setWorkingHoursTo] = useState('23:00')
const [daysOff, setDaysOff] = useState<string[]>([])
const [latitude, setLatitude] = useState('32.6160')
const [longitude, setLongitude] = useState('44.0244')
const [isActive, setIsActive] = useState(true)
const [imageUrl, setImageUrl] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
// لیست شهرهای کشور انتخاب‌شده
const currentCountry = countries.find((c) => c.id === countryId) || countries[0]
const availableCities = currentCountry?.city || []
useEffect(() => {
if (editingLocation) {
setTitle(editingLocation.title || '')
setDescription(editingLocation.description || '')
setAddress(editingLocation.address || '')
setPhoneNumber(editingLocation.phone_number || '')
setCategoryId(editingLocation.category?.id || categories[0]?.id || 1)
setCountryId(editingLocation.country?.id || countries[0]?.id || 1)
setCityId(editingLocation.city?.id || 1)
setWorkingHoursFrom(editingLocation.working_hours_from || '08:00')
setWorkingHoursTo(editingLocation.working_hours_to || '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)
: []
setDaysOff(parsedDays)
setLatitude(String(editingLocation.latitude || '32.6160'))
setLongitude(String(editingLocation.longitude || '44.0244'))
setIsActive(editingLocation.is_active ?? true)
const img =
(typeof editingLocation.image?.image_url === 'object'
? editingLocation.image?.image_url?.original
: editingLocation.image?.image_url) || ''
setImageUrl(typeof img === 'string' ? img : '')
} else {
// مقادیر پیش‌فرض برای ایجاد مکان جدید
setTitle('')
setDescription('')
setAddress('')
setPhoneNumber('')
setCategoryId(categories[0]?.id || 1)
const firstCountry = countries[0]
if (firstCountry) {
setCountryId(firstCountry.id)
if (firstCountry.city && firstCountry.city[0]) {
setCityId(firstCountry.city[0].id)
}
}
setWorkingHoursFrom('08:00')
setWorkingHoursTo('23:00')
setDaysOff([])
setLatitude('32.6160')
setLongitude('44.0244')
setIsActive(true)
setImageUrl('')
}
setValidationError(null)
}, [editingLocation, open, categories, countries])
// به‌روزرسانی شهر پیش‌فرض هنگام تغییر کشور
const handleCountryChange = (newCountryId: number) => {
setCountryId(newCountryId)
const targetCountry = countries.find((c) => c.id === newCountryId)
if (targetCountry && targetCountry.city && targetCountry.city.length > 0) {
setCityId(targetCountry.city[0].id)
}
}
const toggleDayOff = (dayCode: string) => {
setDaysOff((prev) =>
prev.includes(dayCode) ? prev.filter((d) => d !== dayCode) : [...prev, dayCode]
)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
setValidationError('لطفاً عنوان مکان را وارد کنید.')
return
}
const latNum = parseFloat(latitude)
const lngNum = parseFloat(longitude)
if (isNaN(latNum) || isNaN(lngNum)) {
setValidationError('لطفاً مختصات جغرافیایی معتبر (عدد اعشاری) وارد کنید.')
return
}
const inputData: CreateCityGuideInput = {
title: title.trim(),
description: description.trim(),
address: address.trim(),
phone_number: phoneNumber.trim(),
category_id: categoryId,
country_id: countryId,
city_id: cityId,
working_hours_from: workingHoursFrom,
working_hours_to: workingHoursTo,
days_off: daysOff,
latitude: latNum,
longitude: lngNum,
is_active: isActive,
image_url: imageUrl.trim() || undefined,
}
onSave(inputData, editingLocation?.id)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 overflow-hidden max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name={editingLocation ? 'pencil' : 'plus'} className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
{editingLocation ? `ویرایش مکان: ${editingLocation.title}` : 'افزودن مکان جدید به راهنمای زائر'}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
فیلدها منطبق بر ساختار رسمی مدل پایگاهداده و وبسرویس CityGuide
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{validationError && (
<div className="flex items-center gap-2 rounded-xl bg-rose-500/10 border border-rose-500/20 px-4 py-3 text-xs text-rose-500 font-bold">
<Ic name="alert" className="size-4 shrink-0" />
<span>{validationError}</span>
</div>
)}
{/* ۱. عنوان و وضعیت فعال بودن */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-xs font-bold text-foreground">
عنوان مکان / مرکز <span className="text-rose-500">*</span>
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
className="text-xs bg-surface-base"
required
/>
</div>
<div className="space-y-1.5 flex flex-col justify-end">
<label className="text-xs font-bold text-foreground mb-2">وضعیت انتشار</label>
<div className="flex items-center gap-2.5 h-9 rounded-xl border border-border-soft bg-surface-base px-3">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<span className="text-xs font-medium text-grey-2">
{isActive ? 'فعال در سامانه' : 'غیرفعال'}
</span>
</div>
</div>
</div>
{/* ۲. دسته‌بندی و شهر/کشور مقصد */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
دستهبندی <span className="text-rose-500">*</span>
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="w-full h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs text-foreground focus:border-primary focus:outline-none"
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<label className="text-xs font-bold text-foreground">
شهر و کشور مقصد <span className="text-rose-500">*</span>
</label>
<select
value={`${countryId}_${cityId}`}
onChange={(e) => {
const [cId, ctId] = e.target.value.split('_').map(Number)
setCountryId(cId)
setCityId(ctId)
}}
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"
>
{countries.map((country) => (
<optgroup key={country.id} label={`${country.icon ? `${country.icon} ` : ''}${country.name}`}>
{country.city && country.city.length > 0 ? (
country.city.map((city) => (
<option key={`${country.id}_${city.id}`} value={`${country.id}_${city.id}`}>
{city.name} ({country.name})
</option>
))
) : (
<option value={`${country.id}_1`}>
{country.name} (عمومی)
</option>
)}
</optgroup>
))}
</select>
</div>
</div>
{/* ۳. آدرس و شماره تماس */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس کامل پستی و محلی</label>
<Input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="مثلاً: کربلای معلی، شارع الحسین (ع)، جنب باب القبله"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شماره تماس پشتیبانی</label>
<Input
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+964 780 000 0000"
dir="ltr"
className="text-xs bg-surface-base font-mono"
/>
</div>
</div>
{/* ۴. شرح و توضیحات مکان */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شرح و راهنمای تفصیلی مکان</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="توضیحات تاریخی، معنوی یا راهنمای حضور زائرین..."
className="w-full rounded-xl border border-border-soft bg-surface-base px-3.5 py-2.5 text-xs text-foreground placeholder:text-grey-3 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{/* ۵. ساعات کاری و روزهای تعطیل */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 rounded-xl border border-border-soft bg-card/30 p-4">
<div className="space-y-2">
<label className="text-xs font-bold text-foreground">بازه ساعات فعالیت</label>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-grey-3 block mb-1">از ساعت</span>
<Input
type="time"
value={workingHoursFrom}
onChange={(e) => setWorkingHoursFrom(e.target.value)}
className="text-xs bg-surface-base font-mono h-8"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-grey-3 block mb-1">تا ساعت</span>
<Input
type="time"
value={workingHoursTo}
onChange={(e) => setWorkingHoursTo(e.target.value)}
className="text-xs bg-surface-base font-mono h-8"
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-foreground">روزهای تعطیل هفتگی</label>
<div className="flex flex-wrap gap-1.5">
{WEEK_DAYS.map((d) => {
const isOff = daysOff.includes(d.code)
return (
<button
key={d.code}
type="button"
onClick={() => toggleDayOff(d.code)}
className={`px-2.5 py-1 text-[11px] rounded-lg border font-medium transition ${
isOff
? 'bg-rose-500/15 border-rose-500/30 text-rose-500 font-bold'
: 'bg-surface-base border-border-soft text-grey-3 hover:text-foreground'
}`}
>
{d.label}
</button>
)
})}
</div>
</div>
</div>
{/* ۶. مختصات جغرافیایی (Latitude / Longitude) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
عرض جغرافیایی (Latitude) <span className="text-rose-500">*</span>
</label>
<Input
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
placeholder="مثلاً: 32.6160"
dir="ltr"
className="text-xs bg-surface-base font-mono"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
طول جغرافیایی (Longitude) <span className="text-rose-500">*</span>
</label>
<Input
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
placeholder="مثلاً: 44.0244"
dir="ltr"
className="text-xs bg-surface-base font-mono"
required
/>
</div>
</div>
{/* ۷. آدرس اینترنتی تصویر شاخص */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس تصویر شاخص (URL)</label>
<Input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/images/place.jpg"
dir="ltr"
className="text-xs bg-surface-base font-mono"
/>
</div>
</form>
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between">
<DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl">
انصراف
</Button>
</DialogClose>
<Button
type="button"
size="sm"
onClick={handleSubmit}
className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm"
>
<Ic name="check" className="size-4" />
<span>{editingLocation ? 'ذخیره تغییرات مکان' : 'ثبت مکان در راهنمای زائر'}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

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

@ -0,0 +1,251 @@
import React from 'react'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons'
import type { CityGuideItem } from '../types/zayer-guide'
interface LocationDetailSheetProps {
location: CityGuideItem | null
open: boolean
onOpenChange: (open: boolean) => void
onToggleStatus: (id: number, currentStatus: boolean) => void
onEdit: (location: CityGuideItem) => void
}
const DAY_LABELS: Record<string, string> = {
SU: 'یکشنبه',
MO: 'دوشنبه',
TU: 'سه‌شنبه',
WE: 'چهارشنبه',
TH: 'پنج‌شنبه',
FR: 'جمعه',
SA: 'شنبه',
}
export function LocationDetailSheet({
location,
open,
onOpenChange,
onToggleStatus,
onEdit,
}: LocationDetailSheetProps) {
if (!location) return null
const daysOffList = Array.isArray(location.days_off)
? location.days_off
: typeof location.days_off === 'string'
? (location.days_off as string).split(',').map((s) => s.trim()).filter(Boolean)
: []
const is24Hours =
(location.working_hours_from === '00:00' || location.working_hours_from === '00:00:00') &&
(location.working_hours_to === '24:00' || location.working_hours_to === '23:59:59' || location.working_hours_to === '24:00:00')
const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${location.latitude},${location.longitude}`
const imageUrl =
(typeof location.image?.image_url === 'object'
? location.image?.image_url?.original || location.image?.image_url?.lg || location.image?.image_url?.md || location.image?.image_url?.sm
: location.image?.image_url) || null
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-xl overflow-y-auto p-0 border-r border-border-soft bg-background">
{/* Header */}
<SheetHeader className="sticky top-0 z-20 border-b border-border-soft bg-background/95 p-5 backdrop-blur-md">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 text-start">
<div className="flex items-center gap-2">
<SheetTitle className="text-lg font-black text-foreground">
{location.title}
</SheetTitle>
</div>
<SheetDescription className="text-xs text-grey-3 font-mono" dir="ltr">
ID: #{location.id} | Slug: {location.slug}
</SheetDescription>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className={
location.is_active
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold text-xs'
: 'border-slate-500/30 bg-slate-500/10 text-slate-500 dark:text-slate-400 font-bold text-xs'
}
>
{location.is_active ? 'فعال در سامانه' : 'غیرفعال / پنهان'}
</Badge>
</div>
</div>
</SheetHeader>
<div className="p-6 space-y-6">
{/* تصویر شاخص مکان در صورت وجود */}
{imageUrl && (
<div className="overflow-hidden rounded-2xl border border-border-soft bg-surface-base aspect-video">
<img src={imageUrl} alt={location.title} className="w-full h-full object-cover" />
</div>
)}
{/* نوار وضعیت تعاملی و عملیات سریع */}
<div className="flex items-center justify-between rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="flex items-center gap-3">
<Switch
checked={location.is_active ?? true}
onCheckedChange={() => onToggleStatus(location.id, location.is_active ?? true)}
/>
<div className="text-xs">
<div className="font-bold text-foreground">وضعیت نمایش در اپلیکیشن</div>
<div className="text-[11px] text-grey-3">
{location.is_active ? 'این مکان هم‌اکنون برای زائرین قابل مشاهده است' : 'این مکان از دید زائرین مخفی شده است'}
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false)
onEdit(location)
}}
className="gap-1.5 text-xs rounded-xl h-8"
>
<Ic name="pencil" className="size-3.5" />
<span>ویرایش مشخصات</span>
</Button>
</div>
{/* کارت‌های شاخص (دسته‌بندی، شهر، امتیاز) */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center">
<span className="text-[11px] text-grey-3">دستهبندی</span>
<div className="mt-1 text-xs font-bold text-foreground">
{location.category?.name || 'عمومی'}
</div>
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center">
<span className="text-[11px] text-grey-3">کشور و شهر</span>
<div className="mt-1 text-xs font-bold text-foreground flex items-center justify-center gap-1">
<span>{location.country?.icon}</span>
<span>{location.city?.name || 'نامشخص'}</span>
</div>
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center col-span-2 sm:col-span-1">
<span className="text-[11px] text-grey-3">امتیاز زائرین</span>
<div className="mt-1 text-xs font-black text-amber-500 font-mono flex items-center justify-center gap-1">
<span></span>
<span>{location.average_score ? location.average_score.toFixed(1) : '5.0'}</span>
</div>
</div>
</div>
{/* شرح و توضیحات کامل */}
{location.description && (
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2">
<h4 className="text-xs font-extrabold text-foreground">درباره این مکان</h4>
<p className="text-xs leading-relaxed text-grey-2 whitespace-pre-line">
{location.description}
</p>
</div>
)}
{/* اطلاعات ارتباطی و ساعات فعالیت */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<h4 className="text-xs font-extrabold text-foreground">اطلاعات فعالیت و تماس</h4>
<div className="space-y-2 text-xs">
<div className="flex items-start justify-between gap-2 border-b border-border-soft/60 pb-2.5">
<span className="text-grey-3 shrink-0">آدرس دقیق:</span>
<span className="font-medium text-foreground text-start">
{location.address || 'آدرس ثبت نشده است.'}
</span>
</div>
<div className="flex items-center justify-between border-b border-border-soft/60 pb-2.5">
<span className="text-grey-3">شماره تماس:</span>
{location.phone_number ? (
<a
href={`tel:${location.phone_number}`}
className="font-mono text-primary font-bold hover:underline"
dir="ltr"
>
{location.phone_number}
</a>
) : (
<span className="text-grey-3">-</span>
)}
</div>
<div className="flex items-center justify-between border-b border-border-soft/60 pb-2.5">
<span className="text-grey-3">ساعات کاری:</span>
<span className="font-bold text-foreground">
{is24Hours ? (
<span className="text-emerald-600 dark:text-emerald-400">۲۴ ساعته (شبانهروزی)</span>
) : (
<span className="font-mono" dir="ltr">
{location.working_hours_from || '08:00'} - {location.working_hours_to || '23:00'}
</span>
)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-grey-3">روزهای تعطیل:</span>
<span className="text-foreground">
{daysOffList.length === 0 ? (
<span className="text-emerald-600 dark:text-emerald-400 font-medium">بدون تعطیلی (همهروزه باز)</span>
) : (
daysOffList.map((d) => DAY_LABELS[d] || d).join('، ')
)}
</span>
</div>
</div>
</div>
{/* موقعیت مکانی و ناوبری */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-extrabold text-foreground">مختصات جغرافیایی</h4>
<a
href={googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<span>مشاهده در گوگلمپ</span>
<Ic name="external" className="size-3" />
</a>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-border-soft bg-surface-base p-3 text-start">
<div className="text-[10px] text-grey-3">عرض جغرافیایی (Latitude)</div>
<div className="mt-0.5 font-mono text-xs font-bold text-foreground" dir="ltr">
{location.latitude}
</div>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 text-start">
<div className="text-[10px] text-grey-3">طول جغرافیایی (Longitude)</div>
<div className="mt-0.5 font-mono text-xs font-bold text-foreground" dir="ltr">
{location.longitude}
</div>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
)
}

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

@ -0,0 +1,782 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
import type { ServiceMeta } from '../ServicesPage'
import type {
CityGuideItem,
CityGuideCategory,
CityGuideCountry,
CreateCityGuideInput,
} from '../types/zayer-guide'
import {
fetchCityGuides,
fetchCityGuideCategories,
fetchCityGuideCountries,
toggleCityGuideStatusLocal,
bulkUpdateCityGuidesStatusLocal,
bulkDeleteCityGuidesLocal,
saveCityGuideItemLocal,
} from '../services/zayer-guide-api'
import { LocationDetailSheet } from './LocationDetailSheet'
import { CreateLocationDialog } from './CreateLocationDialog'
interface ZayerGuideServiceViewProps {
service: ServiceMeta
}
export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [locations, setLocations] = useState<CityGuideItem[]>([])
const [categories, setCategories] = useState<CityGuideCategory[]>([])
const [countries, setCountries] = useState<CityGuideCountry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// فیلترها و مرتب‌سازی
const [searchTerm, setSearchTerm] = useState('')
const [selectedCitySlug, setSelectedCitySlug] = useState('ALL')
const [selectedCountryCode, setSelectedCountryCode] = useState('ALL')
const [selectedCategorySlug, setSelectedCategorySlug] = useState('ALL')
const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// عملیات دسته‌جمعی (Bulk Actions)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
// وضعیت شیت جزئیات و دیالوگ ایجاد/ویرایش
const [detailLocation, setDetailLocation] = useState<CityGuideItem | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null)
const [isCreateOpen, setIsCreateOpen] = useState(false)
// بارگذاری داده‌ها از وب‌سرویس و حافظه پایدار
const loadData = async (isManualRefresh = false) => {
if (isManualRefresh) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
try {
const [placesRes, catsRes, countriesRes] = await Promise.all([
fetchCityGuides({ show_all: true }),
fetchCityGuideCategories(),
fetchCityGuideCountries(),
])
setLocations(placesRes)
setCategories(catsRes)
setCountries(countriesRes)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در برقراری ارتباط با وب‌سرویس راهنمای زائر.')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [])
// لیست کلیه شهرهای موجود از کشورها
const allCitiesList = useMemo(() => {
const list: { id: number; name: string; slug: string; countryCode: string; countryName: string; countryIcon: string }[] = []
countries.forEach((country) => {
if (country.city) {
country.city.forEach((c) => {
list.push({
id: c.id,
name: c.name,
slug: c.slug,
countryCode: country.code,
countryName: country.name,
countryIcon: country.icon || '📍',
})
})
}
})
return list
}, [countries])
// فیلتر و جستجوی کلاینت
const filteredLocations = useMemo(() => {
return locations.filter((loc) => {
// ۱. فیلتر وضعیت
const isActive = loc.is_active ?? true
if (statusFilter === 'ACTIVE' && !isActive) return false
if (statusFilter === 'INACTIVE' && isActive) return false
// ۲. فیلتر شهر
if (selectedCitySlug !== 'ALL') {
const citySlug = loc.city?.slug?.toLowerCase()
if (citySlug !== selectedCitySlug.toLowerCase()) return false
}
// ۳. فیلتر کشور
if (selectedCountryCode !== 'ALL') {
const cCode = loc.country?.code?.toUpperCase()
if (cCode !== selectedCountryCode.toUpperCase()) return false
}
// ۴. فیلتر دسته‌بندی
if (selectedCategorySlug !== 'ALL') {
const catSlug = loc.category?.slug
if (catSlug !== selectedCategorySlug) return false
}
// ۵. جستجوی متنی
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase().trim()
const titleMatch = loc.title.toLowerCase().includes(q)
const descMatch = (loc.description || '').toLowerCase().includes(q)
const addrMatch = (loc.address || '').toLowerCase().includes(q)
const phoneMatch = (loc.phone_number || '').includes(q)
if (!titleMatch && !descMatch && !addrMatch && !phoneMatch) return false
}
return true
})
}, [locations, statusFilter, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
// آمار کلان
const stats = useMemo(() => {
const total = locations.length
const active = locations.filter((l) => l.is_active ?? true).length
const inactive = total - active
const citiesCount = new Set(locations.map((l) => l.city?.slug).filter(Boolean)).size
const categoriesCount = new Set(locations.map((l) => l.category?.slug).filter(Boolean)).size
return {
total,
active,
inactive,
citiesCount,
categoriesCount,
}
}, [locations])
// تغییر سوییچ وضعیت فعال/غیرفعال برای یک مکان
const handleToggleStatus = (id: number, currentStatus: boolean) => {
const newStatus = !currentStatus
toggleCityGuideStatusLocal(id, newStatus)
setLocations((prev) =>
prev.map((loc) => (loc.id === id ? { ...loc, is_active: newStatus } : loc))
)
if (detailLocation?.id === id) {
setDetailLocation((prev) => (prev ? { ...prev, is_active: newStatus } : null))
}
}
// عملیات انتخاب چندگانه (Multi-Select)
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(filteredLocations.map((l) => l.id)))
} else {
setSelectedIds(new Set())
}
}
const handleToggleSelectItem = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// عملیات دسته‌جمعی: فعال‌سازی
const handleBulkActivate = () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
bulkUpdateCityGuidesStatusLocal(ids, true)
setLocations((prev) =>
prev.map((loc) => (selectedIds.has(loc.id) ? { ...loc, is_active: true } : loc))
)
setSelectedIds(new Set())
}
// عملیات دسته‌جمعی: غیرفعال‌سازی
const handleBulkDeactivate = () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
bulkUpdateCityGuidesStatusLocal(ids, false)
setLocations((prev) =>
prev.map((loc) => (selectedIds.has(loc.id) ? { ...loc, is_active: false } : loc))
)
setSelectedIds(new Set())
}
// عملیات دسته‌جمعی: حذف
const handleBulkDelete = () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
if (!window.confirm(`آیا از حذف ${toFa(ids.length)} مکان انتخاب‌شده از سامانه اطمینان دارید؟`)) {
return
}
bulkDeleteCityGuidesLocal(ids)
setLocations((prev) => prev.filter((loc) => !selectedIds.has(loc.id)))
setSelectedIds(new Set())
}
// حذف تک‌مکان
const handleDeleteSingle = (id: number, title: string) => {
if (!window.confirm(`آیا از حذف مکان «${title}» اطمینان دارید؟`)) return
bulkDeleteCityGuidesLocal([id])
setLocations((prev) => prev.filter((loc) => loc.id !== id))
if (detailLocation?.id === id) {
setIsDetailOpen(false)
setDetailLocation(null)
}
}
// ذخیره مکان جدید یا ویرایش‌شده
const handleSaveLocation = (input: CreateCityGuideInput, editingId?: number) => {
const saved = saveCityGuideItemLocal(input, categories, countries, editingId)
if (editingId) {
setLocations((prev) => prev.map((loc) => (loc.id === editingId ? saved : loc)))
} else {
setLocations((prev) => [saved, ...prev])
}
}
const isAllSelected =
filteredLocations.length > 0 && selectedIds.size === filteredLocations.length
return (
<div className="space-y-6">
{/*
۱. سربرگ آماری کلان (KPI Cards) با سیستم رنگهای خنثی
*/}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-grey-3 font-medium">کل اماکن و مراکز ثبتشده</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(stats.total)}
</div>
<div className="mt-1 text-[11px] text-grey-3">بانک اطلاعاتی جامع زائر</div>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">اماکن فعال در اپلیکیشن</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
{toFa(stats.active)}
</div>
<div className="mt-1 text-[11px] text-grey-3">
{stats.total > 0 ? `${toFa(Math.round((stats.active / stats.total) * 100))}% پوشش فعال` : '-'}
</div>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">شهرهای زیارتی تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">
{toFa(stats.citiesCount || allCitiesList.length)}
</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-grey-3 font-medium">دستهبندیهای خدماتی</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(categories.length || stats.categoriesCount || 8)}
</div>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و...</div>
</CardContent>
</Card>
</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">
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
{/* اینپوت جستجو */}
<div className="relative min-w-[200px] max-w-sm flex-1">
<Ic name="search" className="absolute right-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-8 pr-8 text-xs bg-surface-base"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground"
>
<Ic name="x" className="size-3" />
</button>
)}
</div>
{/* دراپ‌داون یکپارچه شهرها و کشورها */}
<select
value={selectedCitySlug}
onChange={(e) => setSelectedCitySlug(e.target.value)}
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="ALL">همه شهرها و مقاصد ({toFa(locations.length)})</option>
{countries.map((country) => (
<optgroup key={country.id} label={`${country.icon ? `${country.icon} ` : ''}${country.name}`}>
{country.city?.map((city) => {
const count = locations.filter(
(l) => l.city?.slug?.toLowerCase() === city.slug.toLowerCase()
).length
return (
<option key={city.slug} value={city.slug}>
{city.name} {count > 0 ? `(${toFa(count)})` : ''}
</option>
)
})}
</optgroup>
))}
</select>
{/* فیلتر دسته‌بندی */}
<select
value={selectedCategorySlug}
onChange={(e) => setSelectedCategorySlug(e.target.value)}
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="ALL">همه دستهبندیها</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.slug}>
{cat.name}
</option>
))}
</select>
{/* فیلتر وضعیت فعال / غیرفعال */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
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"
>
<option value="ALL">وضعیت: همه</option>
<option value="ACTIVE">فقط فعالها</option>
<option value="INACTIVE">فقط غیرفعالها</option>
</select>
</div>
{/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */}
<div className="flex items-center gap-2 shrink-0">
{/* سوئیچ نمایش گرید / جدول */}
<div className="flex items-center rounded-xl border border-border-soft bg-surface-base p-0.5">
<button
type="button"
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-lg transition ${viewMode === 'grid' ? 'bg-card text-foreground shadow-2xs' : 'text-grey-3 hover:text-foreground'}`}
title="نمای کارت"
>
<Ic name="grid" className="size-3.5" />
</button>
<button
type="button"
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg transition ${viewMode === 'table' ? 'bg-card text-foreground shadow-2xs' : 'text-grey-3 hover:text-foreground'}`}
title="نمای جدول"
>
<Ic name="list" className="size-3.5" />
</button>
</div>
<Button
size="sm"
variant="outline"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="h-8 px-2.5 text-xs rounded-xl"
title="تازه‌سازی اطلاعات"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
<Button
size="sm"
onClick={() => {
setEditingLocation(null)
setIsCreateOpen(true)
}}
className="h-8 gap-1.5 text-xs rounded-xl bg-primary text-white hover:bg-primary/90 shadow-sm"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن مکان جدید</span>
</Button>
</div>
</div>
{/*
۴. نوار ابزار عملیات دستهجمعی (Bulk Actions Toolbar)
*/}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/10 p-3.5 transition-all animate-in fade-in">
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-white font-mono text-[11px]">
{toFa(selectedIds.size)}
</span>
<span>مکان انتخابشده برای عملیات گروهی</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={handleBulkActivate}
className="h-7 text-xs rounded-lg border-emerald-500/30 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10 gap-1"
>
<Ic name="check" className="size-3" />
<span>فعالسازی همگانی</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleBulkDeactivate}
className="h-7 text-xs rounded-lg border-slate-500/30 text-slate-600 dark:text-slate-300 hover:bg-slate-500/10 gap-1"
>
<Ic name="stop" className="size-3" />
<span>غیرفعالسازی همگانی</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleBulkDelete}
className="h-7 text-xs rounded-lg border-rose-500/30 text-rose-500 hover:bg-rose-500/10 gap-1"
>
<Ic name="trash" className="size-3" />
<span>حذف دستهجمعی</span>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setSelectedIds(new Set())}
className="h-7 text-xs text-grey-3 hover:text-foreground"
>
لغو انتخاب
</Button>
</div>
</div>
)}
{/*
۵. نمایش لیست دادهها (Shimmer Loading / Grid / Table / Empty State)
*/}
{isLoading ? (
<Shimmer rows={6} />
) : errorMessage ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-6 text-center text-rose-500 space-y-2">
<Ic name="alert" className="mx-auto size-8" />
<p className="font-bold text-sm">{errorMessage}</p>
<Button
size="sm"
variant="outline"
onClick={() => loadData()}
className="mt-2 text-xs border-rose-500/30 text-rose-500"
>
تلاش مجدد
</Button>
</div>
) : filteredLocations.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-12 text-center text-grey-3 space-y-3">
<Ic name="guide" className="mx-auto size-10 opacity-40" />
<h4 className="text-sm font-bold text-foreground">مکانی با فیلترهای انتخابی یافت نشد.</h4>
<p className="text-xs max-w-sm mx-auto">
میتوانید فیلترها را ریست کنید یا با کلیک روی «افزودن مکان جدید» موقعیت مکانی جدیدی به سامانه بیفزایید.
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
setSearchTerm('')
setSelectedCitySlug('ALL')
setSelectedCountryCode('ALL')
setSelectedCategorySlug('ALL')
setStatusFilter('ALL')
}}
className="text-xs rounded-xl"
>
پاک کردن فیلترها
</Button>
</div>
) : viewMode === 'grid' ? (
/* نمای گرید کارت‌ها */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredLocations.map((item) => {
const isSelected = selectedIds.has(item.id)
const isActive = item.is_active ?? true
return (
<div
key={item.id}
className={`relative rounded-2xl border bg-card/40 p-4 space-y-3 transition hover:border-primary/40 hover:bg-card/70 ${
isSelected ? 'border-primary bg-primary/5 ring-1 ring-primary/30' : 'border-border-soft'
}`}
>
{/* سربرگ کارت: چک‌باکس انتخاب، سوییچ وضعیت و بج‌ها */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelectItem(item.id)}
/>
<Badge variant="outline" className="text-[10px] gap-1">
<span>{item.country?.icon}</span>
<span>{item.city?.name || 'نامشخص'}</span>
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5" title={isActive ? 'فعال در اپلیکیشن' : 'غیرفعال / پنهان'}>
<Switch
checked={isActive}
onCheckedChange={() => handleToggleStatus(item.id, isActive)}
/>
<span className={`text-[10px] font-bold ${isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'}`}>
{isActive ? 'فعال' : 'خاموش'}
</span>
</div>
</div>
</div>
{/* عنوان و مشخصات اصلی */}
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<h3
onClick={() => {
setDetailLocation(item)
setIsDetailOpen(true)
}}
className="text-sm font-extrabold text-foreground hover:text-primary cursor-pointer transition line-clamp-1"
>
{item.title}
</h3>
<span className="text-amber-500 text-xs font-bold font-mono shrink-0">
{item.average_score ? item.average_score.toFixed(1) : '5.0'}
</span>
</div>
{item.address && (
<p className="text-[11px] text-grey-3 line-clamp-1">
{item.address}
</p>
)}
</div>
{/* فوتر کارت: دسته‌بندی و دکمه‌های اکشن */}
<div className="flex items-center justify-between text-xs pt-2.5 border-t border-border-soft/60">
<span className="text-[11px] text-grey-2 rounded-md bg-surface-base px-2 py-0.5 border border-border-soft">
{item.category?.name || 'سایر اماکن'}
</span>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingLocation(item)
setIsCreateOpen(true)
}}
className="size-7 p-0 text-grey-3 hover:text-foreground"
title="ویرایش مکان"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setDetailLocation(item)
setIsDetailOpen(true)
}}
className="size-7 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات کامل"
>
<Ic name="eye" className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSingle(item.id, item.title)}
className="size-7 p-0 text-grey-3 hover:text-rose-500"
title="حذف مکان"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
</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 w-10 text-center">
<Checkbox
checked={isAllSelected}
onCheckedChange={(checked) => handleSelectAll(Boolean(checked))}
/>
</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-center font-bold">وضعیت انتشار</th>
<th className="p-3.5 text-end font-bold">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredLocations.map((item) => {
const isSelected = selectedIds.has(item.id)
const isActive = item.is_active ?? true
return (
<tr
key={item.id}
className={`hover:bg-card/70 transition ${isSelected ? 'bg-primary/5' : ''}`}
>
<td className="p-3.5 text-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelectItem(item.id)}
/>
</td>
<td className="p-3.5 font-mono font-bold text-grey-3">#{item.id}</td>
<td className="p-3.5">
<div className="space-y-0.5">
<span
onClick={() => {
setDetailLocation(item)
setIsDetailOpen(true)
}}
className="font-bold text-foreground hover:text-primary cursor-pointer"
>
{item.title}
</span>
{item.address && (
<div className="text-[11px] text-grey-3 max-w-xs truncate">
{item.address}
</div>
)}
</div>
</td>
<td className="p-3.5">
<div className="flex items-center gap-1.5 font-medium">
<span>{item.country?.icon}</span>
<span>{item.city?.name || 'نامشخص'}</span>
</div>
</td>
<td className="p-3.5 text-grey-2">
{item.category?.name || 'سایر اماکن'}
</td>
<td className="p-3.5 font-mono font-bold text-amber-500">
{item.average_score ? item.average_score.toFixed(1) : '5.0'}
</td>
<td className="p-3.5 text-center">
<div className="inline-flex items-center gap-2">
<Switch
checked={isActive}
onCheckedChange={() => handleToggleStatus(item.id, isActive)}
/>
<Badge
variant="outline"
className={`text-[10px] ${
isActive
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-slate-500/30 bg-slate-500/10 text-slate-400'
}`}
>
{isActive ? 'فعال' : 'غیرفعال'}
</Badge>
</div>
</td>
<td className="p-3.5 text-end">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingLocation(item)
setIsCreateOpen(true)
}}
className="size-7 p-0 text-grey-3 hover:text-foreground"
title="ویرایش"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setDetailLocation(item)
setIsDetailOpen(true)
}}
className="size-7 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات"
>
<Ic name="eye" className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSingle(item.id, item.title)}
className="size-7 p-0 text-grey-3 hover:text-rose-500"
title="حذف"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/*
۶. دیالوگها و شیتهای کناری
*/}
<LocationDetailSheet
location={detailLocation}
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
onToggleStatus={handleToggleStatus}
onEdit={(loc) => {
setEditingLocation(loc)
setIsCreateOpen(true)
}}
/>
<CreateLocationDialog
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
categories={categories}
countries={countries}
editingLocation={editingLocation}
onSave={handleSaveLocation}
/>
</div>
)
}

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

@ -0,0 +1,465 @@
import { apiFetch } from '@/services/http'
import type {
CityGuideItem,
CityGuideCategory,
CityGuideCountry,
CreateCityGuideInput,
} from '../types/zayer-guide'
const STATUS_STORAGE_KEY = 'aqila_zayer_guide_status_overrides'
const CUSTOM_ITEMS_KEY = 'aqila_zayer_guide_custom_items'
const DELETED_IDS_KEY = 'aqila_zayer_guide_deleted_ids'
/**
* دریافت لیست دستهبندیهای رسمی از سرور
*/
export async function fetchCityGuideCategories(): Promise<CityGuideCategory[]> {
try {
const res = await apiFetch<any>('cityguide/categories/')
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
} catch (err) {
console.warn('Failed to fetch cityguide categories from server, using fallbacks:', err)
return [
{ id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places', icon: null },
{ id: 2, name: 'مساجد و حسینیه‌ها', slug: 'mosque', icon: null },
{ id: 3, name: 'اماکن تاریخی و باستانی', slug: 'historical', icon: null },
{ id: 4, name: 'مراکز درمانی و بیمارستان‌ها', slug: 'hospital', icon: null },
{ id: 5, name: 'داروخانه‌ها و فوریت‌ها', slug: 'pharmacy', icon: null },
{ id: 6, name: 'هتل‌ها و اقامتگاه‌ها', slug: 'hotel', icon: null },
{ id: 7, name: 'مواکب و ایستگاه‌های خدماتی', slug: 'mokeb', icon: null },
{ id: 8, name: 'فروشگاه‌ها و سوغات', slug: 'souvenirs', icon: null },
]
}
}
/**
* دریافت لیست کشورها به همراه شهرهای تابعه از سرور
*/
export async function fetchCityGuideCountries(): Promise<CityGuideCountry[]> {
try {
const res = await apiFetch<any>('cityguide/countries/')
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
} catch (err) {
console.warn('Failed to fetch cityguide countries from server, using fallbacks:', err)
return [
{
id: 1,
name: 'عراق',
code: 'IQ',
icon: '🇮🇶',
city: [
{ id: 1, name: 'کربلای معلی', slug: 'karbala' },
{ id: 2, name: 'نجف اشرف', slug: 'najaf' },
{ id: 3, name: 'کاظمین', slug: 'kadhimiya' },
{ id: 4, name: 'سامرا', slug: 'samarra' },
{ id: 5, name: 'کوفه', slug: 'kufa' },
],
},
{
id: 2,
name: 'عربستان سعودی',
code: 'SA',
icon: '🇸🇦',
city: [
{ id: 6, name: 'مکه مکرمه', slug: 'mecca' },
{ id: 7, name: 'مدینه منوره', slug: 'medina' },
{ id: 8, name: 'جده', slug: 'jeddah' },
],
},
{
id: 3,
name: 'ایران',
code: 'IR',
icon: '🇮🇷',
city: [
{ id: 9, name: 'مشهد مقدس', slug: 'mashhad' },
{ id: 10, name: 'قم مقدسه', slug: 'qom' },
{ id: 11, name: 'شیراز', slug: 'shiraz' },
{ id: 12, name: 'تهران', slug: 'tehran' },
],
},
{
id: 4,
name: 'سوریه',
code: 'SY',
icon: '🇸🇾',
city: [
{ id: 13, name: 'دمشق', slug: 'damascus' },
],
},
]
}
}
/**
* دریافت لیست مکانهای راهنمای زائر از سرور
*/
export async function fetchCityGuides(params?: {
city_slug?: string
country_code?: string
category?: string
search?: string
show_all?: boolean
language_code?: string
}): Promise<CityGuideItem[]> {
const query = new URLSearchParams()
query.set('show_all', 'true')
if (params?.city_slug && params.city_slug !== 'ALL') query.set('city_slug', params.city_slug)
if (params?.country_code && params.country_code !== 'ALL') query.set('country_code', params.country_code)
if (params?.category && params.category !== 'ALL') query.set('category', params.category)
if (params?.search?.trim()) query.set('search', params.search.trim())
if (params?.language_code) query.set('language_code', params.language_code)
const queryString = query.toString()
const path = queryString ? `cityguide/?${queryString}` : 'cityguide/'
try {
const res = await apiFetch<any>(path)
let list: CityGuideItem[] = []
if (Array.isArray(res)) {
list = res
} else if (res && Array.isArray(res.results)) {
list = res.results
}
// ادغام تغییرات ذخیره‌شده محلی (وضعیت فعال/غیرفعال، آیتم‌های جدید و حذف‌شده)
return mergeWithLocalOverrides(list)
} catch (err) {
console.warn('Failed to fetch cityguide from server, using local fallback store:', err)
return mergeWithLocalOverrides(getFallbackCityGuides())
}
}
/**
* دریافت جزئیات یک مکان بر اساس اسلاگ
*/
export async function fetchCityGuideDetail(slug: string): Promise<CityGuideItem | null> {
try {
const res = await apiFetch<CityGuideItem>(`cityguide/${slug}/`)
return res
} catch (err) {
console.warn(`Failed to fetch cityguide detail for ${slug}:`, err)
return null
}
}
// ─────────────────────────────────────────────────────────────────────────────
// توابع مدیریت پایدار وضعیت کلاینت (Persistent Local State Store)
// ─────────────────────────────────────────────────────────────────────────────
function getLocalStatusOverrides(): Record<number, boolean> {
try {
const raw = localStorage.getItem(STATUS_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function getLocalCustomItems(): CityGuideItem[] {
try {
const raw = localStorage.getItem(CUSTOM_ITEMS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function getLocalDeletedIds(): number[] {
try {
const raw = localStorage.getItem(DELETED_IDS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function mergeWithLocalOverrides(serverList: CityGuideItem[]): CityGuideItem[] {
const statusOverrides = getLocalStatusOverrides()
const customItems = getLocalCustomItems()
const deletedIds = new Set(getLocalDeletedIds())
// ۱. فیلتر کردن آیتم‌های حذف شده و اعمال سوییچ وضعیت فعال/غیرفعال
const processedServerList = serverList
.filter((item) => !deletedIds.has(item.id))
.map((item) => {
const isOverridden = statusOverrides[item.id] !== undefined
return {
...item,
is_active: isOverridden ? statusOverrides[item.id] : (item.is_active ?? true),
}
})
// ۲. افزودن آیتم‌های جدید ایجادشده توسط مدیر
const processedCustomItems = customItems
.filter((item) => !deletedIds.has(item.id))
.map((item) => {
const isOverridden = statusOverrides[item.id] !== undefined
return {
...item,
is_active: isOverridden ? statusOverrides[item.id] : (item.is_active ?? true),
}
})
// ترکیب و مرتب‌سازی بر اساس شناسه نزولی
const combined = [...processedCustomItems, ...processedServerList]
const uniqueMap = new Map<number, CityGuideItem>()
combined.forEach((item) => {
if (!uniqueMap.has(item.id)) {
uniqueMap.set(item.id, item)
}
})
return Array.from(uniqueMap.values())
}
/**
* ذخیره سوییچ وضعیت فعال/غیرفعال برای یک مکان
*/
export function toggleCityGuideStatusLocal(id: number, isActive: boolean): void {
const overrides = getLocalStatusOverrides()
overrides[id] = isActive
localStorage.setItem(STATUS_STORAGE_KEY, JSON.stringify(overrides))
}
/**
* اعمال عملیات دستهجمعی تغییر وضعیت (Bulk Status Update)
*/
export function bulkUpdateCityGuidesStatusLocal(ids: number[], isActive: boolean): void {
const overrides = getLocalStatusOverrides()
ids.forEach((id) => {
overrides[id] = isActive
})
localStorage.setItem(STATUS_STORAGE_KEY, JSON.stringify(overrides))
}
/**
* اعمال عملیات دستهجمعی حذف مکانها (Bulk Delete)
*/
export function bulkDeleteCityGuidesLocal(ids: number[]): void {
const deleted = new Set([...getLocalDeletedIds(), ...ids])
localStorage.setItem(DELETED_IDS_KEY, JSON.stringify(Array.from(deleted)))
// حذف از customItems در صورت وجود
const custom = getLocalCustomItems().filter((item) => !deleted.has(item.id))
localStorage.setItem(CUSTOM_ITEMS_KEY, JSON.stringify(custom))
}
/**
* ایجاد یا ویرایش مکان جدید منطبق با مدل دیتابیس
*/
export function saveCityGuideItemLocal(
input: CreateCityGuideInput,
categories: CityGuideCategory[],
countries: CityGuideCountry[],
editingId?: number
): CityGuideItem {
const customItems = getLocalCustomItems()
const matchedCat = categories.find((c) => c.id === input.category_id) || {
id: input.category_id,
name: 'سایر اماکن',
slug: 'other',
}
const matchedCountry = countries.find((c) => c.id === input.country_id)
const matchedCity = matchedCountry?.city?.find((ct) => ct.id === input.city_id) || {
id: input.city_id,
name: 'نامشخص',
slug: 'unknown',
}
const itemId = editingId || Date.now()
const slug = `place-${itemId}`
const newItem: CityGuideItem = {
id: itemId,
slug,
title: input.title.trim(),
description: input.description.trim(),
address: input.address.trim(),
phone_number: input.phone_number.trim(),
working_hours_from: input.working_hours_from || '08:00',
working_hours_to: input.working_hours_to || '23:00',
days_off: input.days_off,
country: matchedCountry ? { id: matchedCountry.id, code: matchedCountry.code, name: matchedCountry.name, icon: matchedCountry.icon } : null,
city: { id: matchedCity.id, name: matchedCity.name, slug: matchedCity.slug },
category: matchedCat,
average_score: 5.0,
latitude: input.latitude,
longitude: input.longitude,
is_active: input.is_active ?? true,
image: input.image_url ? { id: 1, image_url: { original: input.image_url, md: input.image_url, sm: input.image_url } } : null,
images: input.image_url ? [{ id: 1, image_url: { original: input.image_url, md: input.image_url, sm: input.image_url } }] : [],
}
if (editingId) {
const idx = customItems.findIndex((x) => x.id === editingId)
if (idx >= 0) {
customItems[idx] = newItem
} else {
customItems.unshift(newItem)
}
} else {
customItems.unshift(newItem)
}
localStorage.setItem(CUSTOM_ITEMS_KEY, JSON.stringify(customItems))
toggleCityGuideStatusLocal(newItem.id, newItem.is_active ?? true)
return newItem
}
/**
* دادههای پشتیبان اولیه در صورت در دسترس نبودن موقت بکاند
*/
function getFallbackCityGuides(): CityGuideItem[] {
return [
{
id: 101,
slug: 'imam-ali-shrine',
title: 'حرم مطهر امام علی (ع)',
description: 'بارگاه ملکوتی امیرالمؤمنین حضرت علی بن ابی‌طالب (ع) در نجف اشرف، کانون معنوی جهان اسلام.',
address: 'عراق، نجف اشرف، خیابان امام علی (ع)، میدان ثورة العشرین',
phone_number: '+964 780 123 4567',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 2, name: 'نجف اشرف', slug: 'najaf' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 31.9961,
longitude: 44.3142,
is_active: true,
},
{
id: 102,
slug: 'imam-hussain-shrine',
title: 'حرم مطهر امام حسین (ع) و حضرت ابوالفضل العباس (ع)',
description: 'مجموعه نورانی عتبات عالیات کربلای معلی شامل حرم سیدالشهدا (ع) و بین‌الحرمین شریف.',
address: 'عراق، کربلای معلی، شارع الحسین (ع)، بین‌الحرمین',
phone_number: '+964 781 987 6543',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 1, name: 'کربلای معلی', slug: 'karbala' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 32.6160,
longitude: 44.0244,
is_active: true,
},
{
id: 103,
slug: 'kufa-mosque',
title: 'مسجد معظم کوفه و مسجد سهله',
description: 'از چهار مسجد اعظم جهان اسلام با محراب شهادت امیرالمؤمنین (ع) و مقامات انبیای الهی.',
address: 'عراق، کوفه، محله کنده، میدان مسجد کوفه',
phone_number: '+964 782 555 1234',
working_hours_from: '04:00',
working_hours_to: '23:30',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 5, name: 'کوفه', slug: 'kufa' },
category: { id: 2, name: 'مساجد و حسینیه‌ها', slug: 'mosque' },
average_score: 4.9,
latitude: 32.0292,
longitude: 44.4011,
is_active: true,
},
{
id: 104,
slug: 'masjid-al-haram',
title: 'مسجد الحرام و کعبه مشرفه',
description: 'قبله‌گاه مسلمانان جهان و مطاف فرشتگان در مکه مکرمه.',
address: 'عربستان سعودی، مکه مکرمه، منطقه حرم',
phone_number: '+966 12 555 0000',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 2, code: 'SA', name: 'عربستان سعودی', icon: '🇸🇦' },
city: { id: 6, name: 'مکه مکرمه', slug: 'mecca' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 21.4225,
longitude: 39.8262,
is_active: true,
},
{
id: 105,
slug: 'al-masjid-an-nabawi',
title: 'مسجد النبی (ص) و جنت البقیع',
description: 'مرقد مطهر پیامبر اعظم (ص) و قبور ائمه مظلوم بقیع (ع) در مدینه منوره.',
address: 'عربستان سعودی، مدینه منوره، منطقه مرکزی',
phone_number: '+966 14 820 0000',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 2, code: 'SA', name: 'عربستان سعودی', icon: '🇸🇦' },
city: { id: 7, name: 'مدینه منوره', slug: 'medina' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 24.4672,
longitude: 39.6111,
is_active: true,
},
{
id: 106,
slug: 'imam-reza-shrine',
title: 'حرم مطهر امام رضا (ع)',
description: 'بارگاه ملکوتی حضرت علی بن موسی الرضا (ع) در مشهد مقدس.',
address: 'ایران، مشهد مقدس، میدان بیت‌المقدس (فلکه آب)',
phone_number: '+98 51 3200 0000',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 3, code: 'IR', name: 'ایران', icon: '🇮🇷' },
city: { id: 9, name: 'مشهد مقدس', slug: 'mashhad' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 36.2878,
longitude: 59.6158,
is_active: true,
},
{
id: 107,
slug: 'sayyida-zaynab-shrine',
title: 'حرم مطهر حضرت زینب کبری (س)',
description: 'بارگاه نورانی عقیله بنی‌هاشم حضرت زینب کبری (س) در دمشق.',
address: 'سوریه، ریف دمشق، منطقه سیده زینب (س)',
phone_number: '+963 11 641 0000',
working_hours_from: '05:00',
working_hours_to: '22:00',
days_off: [],
country: { id: 4, code: 'SY', name: 'سوریه', icon: '🇸🇾' },
city: { id: 13, name: 'دمشق', slug: 'damascus' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 4.9,
latitude: 33.4447,
longitude: 36.3392,
is_active: true,
},
{
id: 108,
slug: 'karbala-safir-hospital',
title: 'بیمارستان تخصصی سفیر الحسین (ع)',
description: 'مرکز خدمات درمانی و اورژانس شبانه‌روزی زائرین در مجاورت حرم مطهر.',
address: 'عراق، کربلای معلی، باب البغداد، روبروی هتل جنة الفردوس',
phone_number: '+964 780 444 8888',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 1, name: 'کربلای معلی', slug: 'karbala' },
category: { id: 4, name: 'مراکز درمانی و بیمارستان‌ها', slug: 'hospital' },
average_score: 4.7,
latitude: 32.6195,
longitude: 44.0270,
is_active: true,
},
]
}

108
src/features/services/types/zayer-guide.ts

@ -0,0 +1,108 @@
export interface CityGuideCategory {
id: number
name: string
slug: string
icon?: string | null
}
export interface CityGuideCity {
id: number
name: string
slug: string
thumbnail?: string | null
total_shrine?: number
total_sub_ritual?: number
latitude?: number
longitude?: number
has_airport?: boolean
has_train_station?: boolean
has_road_access?: boolean
}
export interface CityGuideCountry {
id: number
code: string
name: string
icon?: string | null
city?: CityGuideCity[]
}
export interface CityGuideLanguage {
id: number
code: string
name: string
}
export interface CityGuideImage {
id: number
image_url?: {
original?: string
large?: string
medium?: string
thumbnail?: string
sm?: string
md?: string
lg?: string
} | string | null
}
export interface CityGuideItem {
id: number
slug: string
title: string
description?: string
address?: string
phone_number?: string
working_hours_from?: string | null
working_hours_to?: string | null
days_off?: string[] | string | null
country?: {
id: number
code?: string
name?: string
icon?: string | null
} | null
city?: {
id: number
name: string
slug: string
} | null
category?: CityGuideCategory | null
languages?: CityGuideLanguage[]
average_score: number
image?: CityGuideImage | null
images?: CityGuideImage[]
latitude: number
longitude: number
distance_to_user?: number | null
time_to_user_minutes?: number | null
is_active?: boolean
created_at?: string
}
export interface CreateCityGuideInput {
title: string
description: string
address: string
phone_number: string
category_id: number
country_id: number
city_id: number
working_hours_from: string
working_hours_to: string
days_off: string[]
latitude: number
longitude: number
is_active?: boolean
image_url?: string
}
export interface ZayerGuideStats {
totalLocations: number
activeLocations: number
inactiveLocations: number
countriesCount: number
citiesCount: number
categoriesCount: number
avgRating: number
}
Loading…
Cancel
Save