Browse Source
feat: add Zayer Guide service feature with location management UI and API integration
master
feat: add Zayer Guide service feature with location management UI and API integration
master
6 changed files with 2032 additions and 106 deletions
-
107src/features/services/ServicesPage.tsx
-
425src/features/services/components/CreateLocationDialog.tsx
-
251src/features/services/components/LocationDetailSheet.tsx
-
782src/features/services/components/ZayerGuideServiceView.tsx
-
465src/features/services/services/zayer-guide-api.ts
-
108src/features/services/types/zayer-guide.ts
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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, |
|||
}, |
|||
] |
|||
} |
|||
@ -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 |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue