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