You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
825 lines
41 KiB
825 lines
41 KiB
import React, { useState, useEffect, useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
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 { Skeleton } from '@/components/ui/skeleton'
|
|
import { fetchTours, fetchTourItineraries } from './services/tour-api'
|
|
import type { TourListItem, TourItineraryItem } from './types'
|
|
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
|
|
import FilterSelect from '@/components/shared/FilterSelect'
|
|
import { Shimmer } from '@/components/shared/Shimmer'
|
|
import { toFa } from '@/lib/utils'
|
|
import { useTranslation } from '@/i18n'
|
|
import { formatDateByLanguage, formatDateRangeByLanguage } from '@/lib/date-utils'
|
|
|
|
interface TourWithItineraries {
|
|
tour: TourListItem
|
|
itineraries: TourItineraryItem[]
|
|
isLoadingItinerary: boolean
|
|
itineraryError: string | null
|
|
hoursToStart: number
|
|
isCurrentlyRunning: boolean
|
|
isImminent: boolean
|
|
currentStepIndex: number
|
|
progressPercent: number
|
|
timelineStatus: 'TRAVELING' | 'IMMINENT' | 'UPCOMING' | 'FINISHED'
|
|
}
|
|
|
|
export default function OngoingToursPage() {
|
|
const navigate = useNavigate()
|
|
const { language } = useTranslation()
|
|
const [tours, setTours] = useState<TourListItem[]>([])
|
|
const [itinerariesMap, setItinerariesMap] = useState<Record<number, { data: TourItineraryItem[]; loading: boolean; error: string | null }>>({})
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// فیلترها و جستجو
|
|
const [activeTab, setActiveTab] = useState<'ALL' | 'TRAVELING' | 'IMMINENT'>('ALL')
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [countryFilter, setCountryFilter] = useState('ALL')
|
|
const [expandedTourIds, setExpandedTourIds] = useState<Record<number, boolean>>({})
|
|
|
|
// لود فهرست همه تورها
|
|
const loadToursData = async (showRefresh = false) => {
|
|
if (showRefresh) setIsRefreshing(true)
|
|
else setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetchTours({ limit: 100 })
|
|
const rawTours = res.results || []
|
|
setTours(rawTours)
|
|
|
|
// باز کردن پیشفرض کارت اولین تورهای در حال اجرا
|
|
const initialExpanded: Record<number, boolean> = {}
|
|
rawTours.forEach((t) => {
|
|
initialExpanded[t.id] = true
|
|
})
|
|
setExpandedTourIds(initialExpanded)
|
|
} catch (err: any) {
|
|
setError(err.message || 'خطا در برقراری ارتباط با وبسرویس تورها')
|
|
} finally {
|
|
setIsLoading(false)
|
|
setIsRefreshing(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadToursData()
|
|
}, [])
|
|
|
|
// محاسبه وضعیت زمانی و تطبیق منطق ۲۴ تا ۴۸ ساعت مانده به شروع و تورهای در حال اجرا
|
|
const processedTours: TourWithItineraries[] = useMemo(() => {
|
|
const now = new Date()
|
|
const nowMs = now.getTime()
|
|
|
|
return tours
|
|
.map((tour) => {
|
|
// ساعت شروع: ابتدای روز؛ ساعت پایان: انتهای روز
|
|
const startMs = new Date(tour.started_at).setHours(0, 0, 0, 0)
|
|
const endMs = new Date(tour.ended_at).setHours(23, 59, 59, 999)
|
|
|
|
const hoursToStart = Math.round((startMs - nowMs) / (1000 * 3600))
|
|
const isCurrentlyRunning = (nowMs >= startMs && nowMs <= endMs) || tour.trip_status === 'TRAVELING'
|
|
// نزدیک به شروع: بین ۰ تا ۴۸ ساعت مانده به شروع
|
|
const isImminent = !isCurrentlyRunning && hoursToStart > 0 && hoursToStart <= 48
|
|
|
|
let timelineStatus: 'TRAVELING' | 'IMMINENT' | 'UPCOMING' | 'FINISHED' = 'UPCOMING'
|
|
if (isCurrentlyRunning) {
|
|
timelineStatus = 'TRAVELING'
|
|
} else if (isImminent) {
|
|
timelineStatus = 'IMMINENT'
|
|
} else if (nowMs > endMs) {
|
|
timelineStatus = 'FINISHED'
|
|
}
|
|
|
|
const itState = itinerariesMap[tour.id]
|
|
const itineraries = itState?.data || []
|
|
const isLoadingItinerary = itState?.loading || false
|
|
const itineraryError = itState?.error || null
|
|
|
|
// تعیین مرحله جاری سفر (Current Itinerary Step)
|
|
let currentStepIndex = -1
|
|
if (itineraries.length > 0) {
|
|
// بررسی اول: آیا مرحلهای صراحتاً وضعیت 'In Progress' یا ۲ دارد؟
|
|
const explicitCurrent = itineraries.findIndex(
|
|
(it) => it.status === 'In Progress' || it.status === 2 || it.status === '2'
|
|
)
|
|
if (explicitCurrent !== -1) {
|
|
currentStepIndex = explicitCurrent
|
|
} else {
|
|
// بررسی دوم: محاسبه بر اساس زمان حال و تاریخ هر مرحله
|
|
let foundByTime = -1
|
|
for (let i = 0; i < itineraries.length; i++) {
|
|
const itStart = itineraries[i].started_at ? new Date(itineraries[i].started_at).getTime() : 0
|
|
const itEnd = itineraries[i].ended_at
|
|
? new Date(itineraries[i].ended_at!).getTime()
|
|
: itStart + 24 * 3600 * 1000
|
|
|
|
if (nowMs >= itStart && nowMs <= itEnd) {
|
|
foundByTime = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if (foundByTime !== -1) {
|
|
currentStepIndex = foundByTime
|
|
} else if (isCurrentlyRunning) {
|
|
// اگر در حال سفر است ولی زمان دقیق ثبت نشده، اولین مرحله در انتظار یا ابتدا را انتخاب کن
|
|
const firstNotStarted = itineraries.findIndex(
|
|
(it) => it.status === 'Not Started' || it.status === 3 || it.status === '3'
|
|
)
|
|
currentStepIndex = firstNotStarted !== -1 ? firstNotStarted : 0
|
|
} else if (isImminent) {
|
|
// تور به زودی شروع میشود، مرحله ۱ مرحله ورودی است
|
|
currentStepIndex = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// درصد پیشرفت تور
|
|
let progressPercent = 0
|
|
if (itineraries.length > 0) {
|
|
if (timelineStatus === 'FINISHED') {
|
|
progressPercent = 100
|
|
} else if (currentStepIndex >= 0) {
|
|
progressPercent = Math.round(((currentStepIndex + 1) / itineraries.length) * 100)
|
|
}
|
|
} else if (isCurrentlyRunning) {
|
|
const totalDuration = Math.max(1, endMs - startMs)
|
|
const elapsed = Math.max(0, nowMs - startMs)
|
|
progressPercent = Math.min(100, Math.round((elapsed / totalDuration) * 100))
|
|
}
|
|
|
|
return {
|
|
tour,
|
|
itineraries,
|
|
isLoadingItinerary,
|
|
itineraryError,
|
|
hoursToStart,
|
|
isCurrentlyRunning,
|
|
isImminent,
|
|
currentStepIndex,
|
|
progressPercent,
|
|
timelineStatus,
|
|
}
|
|
})
|
|
.filter((item) => {
|
|
// معیار قرار گرفتن در این صفحه:
|
|
// ۱. تور در حال حاضر TRAVELING باشد یا تاریخ امروز در بازه شروع و پایان باشد
|
|
// ۲. یا زمان شروع آن در بازه نزدیک (۲۴ الی ۴۸ ساعت آینده) باشد
|
|
return item.isCurrentlyRunning || item.isImminent || item.tour.trip_status === 'TRAVELING'
|
|
})
|
|
}, [tours, itinerariesMap])
|
|
|
|
// لود خودکار Itineraryهای تورهای در حال برگزاری
|
|
useEffect(() => {
|
|
if (processedTours.length === 0) return
|
|
|
|
processedTours.forEach((item) => {
|
|
const tourId = item.tour.id
|
|
if (!itinerariesMap[tourId]) {
|
|
setItinerariesMap((prev) => ({
|
|
...prev,
|
|
[tourId]: { data: [], loading: true, error: null },
|
|
}))
|
|
|
|
fetchTourItineraries(tourId)
|
|
.then((res) => {
|
|
setItinerariesMap((prev) => ({
|
|
...prev,
|
|
[tourId]: { data: res.results || [], loading: false, error: null },
|
|
}))
|
|
})
|
|
.catch((err: any) => {
|
|
setItinerariesMap((prev) => ({
|
|
...prev,
|
|
[tourId]: {
|
|
data: [],
|
|
loading: false,
|
|
error: err.message || 'خطا در دریافت برنامه سفر',
|
|
},
|
|
}))
|
|
})
|
|
}
|
|
})
|
|
}, [processedTours])
|
|
|
|
// فیلتر و جستجو
|
|
const filteredList = useMemo(() => {
|
|
return processedTours.filter((item) => {
|
|
// فیلتر تب وضعیت زمانی
|
|
if (activeTab === 'TRAVELING' && !item.isCurrentlyRunning) return false
|
|
if (activeTab === 'IMMINENT' && !item.isImminent) return false
|
|
|
|
// فیلتر کشور
|
|
if (countryFilter !== 'ALL' && item.tour.destination_country !== countryFilter) return false
|
|
|
|
// جستجو
|
|
if (searchTerm.trim()) {
|
|
const query = searchTerm.toLowerCase()
|
|
const matchTitle = item.tour.title.toLowerCase().includes(query)
|
|
const matchSlug = item.tour.slug.toLowerCase().includes(query)
|
|
if (!matchTitle && !matchSlug) return false
|
|
}
|
|
|
|
return true
|
|
})
|
|
}, [processedTours, activeTab, countryFilter, searchTerm])
|
|
|
|
// شاخصهای آماری بالای صفحه
|
|
const stats = useMemo(() => {
|
|
const totalOngoing = processedTours.length
|
|
const currentlyTraveling = processedTours.filter((t) => t.isCurrentlyRunning).length
|
|
const startingSoon = processedTours.filter((t) => t.isImminent).length
|
|
const distinctCountries = new Set(processedTours.map((t) => t.tour.destination_country).filter(Boolean)).size
|
|
|
|
return { totalOngoing, currentlyTraveling, startingSoon, distinctCountries }
|
|
}, [processedTours])
|
|
|
|
const toggleExpand = (tourId: number) => {
|
|
setExpandedTourIds((prev) => ({
|
|
...prev,
|
|
[tourId]: !prev[tourId],
|
|
}))
|
|
}
|
|
|
|
// قالببندی زمان و تاریخ به فرمت خوانا متناسب با تقویم زبان فعال
|
|
const formatDateTime = (dateStr?: string | null) => {
|
|
if (!dateStr) return '-'
|
|
return formatDateByLanguage(dateStr, language as any, { style: 'medium' })
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 pt-2 pb-14">
|
|
{/* سربرگ صفحه تورهای در حال برگزاری */}
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-amber-500/10 text-amber-500">
|
|
<Ic name="calendar" className="size-5" />
|
|
</div>
|
|
<h1 className="text-xl font-black text-foreground">تورهای در حال برگزاری</h1>
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold"
|
|
>
|
|
{stats.totalOngoing} تور فعال و در جریان
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-grey-3">
|
|
پایش زنده وضعیت سفرهای جاری، زمانبندی روزانه (Itinerary)، موقعیت مرحله فعلی و وضعیت مسافران
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
{/* لینک سریع به لیست همه تورها */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate('/tours')}
|
|
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
|
|
>
|
|
<Ic name="map" className="size-3.5 text-primary" />
|
|
مدیریت تمام تورها
|
|
</Button>
|
|
|
|
{/* دکمه بروزرسانی زنده */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadToursData(true)}
|
|
disabled={isRefreshing}
|
|
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
|
|
>
|
|
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
بروزرسانی دادهها
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* کارتهای شاخصهای کلیدی آماری (KPIs) */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">کل تورهای در جریان</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.totalOngoing)}</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
|
|
<Ic name="calendar" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">هماکنون در حال سفر</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-sky-500">
|
|
{toFa(stats.currentlyTraveling)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-sky-500/10 text-sky-500 flex items-center justify-center">
|
|
<span className="relative flex size-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">نزدیک به شروع (۲۴-۴۸ ساعت)</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
|
|
{toFa(stats.startingSoon)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
|
|
<Ic name="clock" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">مقاصد در حال میزبانی</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.distinctCountries)}</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
|
|
<Ic name="globe" className="size-4" />
|
|
</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 shadow-xs">
|
|
{/* تبهای وضعیت زمانی */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Button
|
|
variant={activeTab === 'ALL' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setActiveTab('ALL')}
|
|
className={`text-xs font-bold rounded-xl h-8 px-3 ${
|
|
activeTab === 'ALL' ? 'bg-primary/15 text-primary border border-primary/20' : 'text-grey-3'
|
|
}`}
|
|
>
|
|
همه تورهای در جریان ({toFa(processedTours.length)})
|
|
</Button>
|
|
|
|
<Button
|
|
variant={activeTab === 'TRAVELING' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setActiveTab('TRAVELING')}
|
|
className={`text-xs font-bold rounded-xl h-8 px-3 ${
|
|
activeTab === 'TRAVELING' ? 'bg-sky-500/15 text-sky-500 border border-sky-500/20' : 'text-grey-3'
|
|
}`}
|
|
>
|
|
هماکنون در حال سفر ({toFa(stats.currentlyTraveling)})
|
|
</Button>
|
|
|
|
<Button
|
|
variant={activeTab === 'IMMINENT' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setActiveTab('IMMINENT')}
|
|
className={`text-xs font-bold rounded-xl h-8 px-3 ${
|
|
activeTab === 'IMMINENT'
|
|
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
|
|
: 'text-grey-3'
|
|
}`}
|
|
>
|
|
نزدیک به شروع ۲۴-۴۸ ساعت ({toFa(stats.startingSoon)})
|
|
</Button>
|
|
</div>
|
|
|
|
{/* فیلتر مقصد و جستجو */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="relative min-w-[200px] flex-1 sm:w-64 sm:flex-none">
|
|
<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 border-border-soft"
|
|
/>
|
|
</div>
|
|
|
|
<FilterSelect
|
|
value={countryFilter}
|
|
onChange={setCountryFilter}
|
|
options={[
|
|
{ value: 'ALL', label: 'همه مقاصد' },
|
|
{ value: 'IQ', label: '🇮🇶 عراق' },
|
|
{ value: 'IR', label: '🇮🇷 ایران' },
|
|
{ value: 'SA', label: '🇸🇦 عربستان' },
|
|
{ value: 'SY', label: '🇸🇾 سوریه' },
|
|
]}
|
|
allValue="ALL"
|
|
size="sm"
|
|
className="w-32"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* وضعیت لودینگ استاندارد Shimmer */}
|
|
{isLoading ? (
|
|
<Shimmer withCard cardHeaderTitle="تورهای در حال برگزاری" cardHeaderIcon="calendar" rows={6} />
|
|
) : error ? (
|
|
/* وضعیت خطا */
|
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-12 text-center text-rose-500">
|
|
<Ic name="alert" className="mx-auto size-10 mb-3" />
|
|
<h3 className="text-base font-bold">خطا در بارگذاری تورهای در حال برگزاری</h3>
|
|
<p className="mt-1 text-xs text-grey-3 max-w-md mx-auto">{error}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadToursData()}
|
|
className="mt-5 border-rose-500/30 text-rose-500 hover:bg-rose-500/10"
|
|
>
|
|
تلاش مجدد
|
|
</Button>
|
|
</div>
|
|
) : filteredList.length === 0 ? (
|
|
/* وضعیت خالی */
|
|
<div className="rounded-2xl border border-dashed border-border-soft bg-card/20 p-16 text-center">
|
|
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<Ic name="calendar" className="size-7" />
|
|
</div>
|
|
<h3 className="mt-4 text-sm font-bold text-foreground">توری در حال برگزاری یا نزدیک به شروع یافت نشد</h3>
|
|
<p className="mt-1 text-xs text-grey-3 max-w-md mx-auto">
|
|
در بازه زمانی جاری (هماکنون یا ۲۴ تا ۴۸ ساعت آینده)، هیچ توری مطابق با فیلترهای انتخابی شما در حال
|
|
برگزاری نیست.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate('/tours')}
|
|
className="mt-5 border-border-soft text-xs font-bold"
|
|
>
|
|
مشاهده فهرست همه تورها
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
/* فهرست کارتهای تورهای در حال برگزاری با برنامههای سفر گامبهگام (Itinerary Stepper) */
|
|
<div className="space-y-6">
|
|
{filteredList.map((item) => {
|
|
const {
|
|
tour,
|
|
itineraries,
|
|
isLoadingItinerary,
|
|
itineraryError,
|
|
isCurrentlyRunning,
|
|
isImminent,
|
|
hoursToStart,
|
|
currentStepIndex,
|
|
progressPercent,
|
|
} = item
|
|
|
|
const isExpanded = Boolean(expandedTourIds[tour.id])
|
|
|
|
const imgUrl =
|
|
tour.image?.image_url?.md ||
|
|
tour.image?.image_url?.medium ||
|
|
tour.image?.image_url?.lg ||
|
|
tour.image?.image_url?.large ||
|
|
tour.image?.image_url?.original ||
|
|
'/placeholder.jpg'
|
|
|
|
return (
|
|
<div
|
|
key={tour.id}
|
|
className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs transition hover:border-primary/40 backdrop-blur-xs"
|
|
>
|
|
{/* بخش ۱: سربرگ کارت تور و مشخصات کلیدی */}
|
|
<div className="p-5 sm:p-6 border-b border-border-soft bg-card/60">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
{/* تصویر، عنوان و کشور */}
|
|
<div className="flex items-start gap-4 min-w-0">
|
|
<div className="relative size-20 sm:size-24 shrink-0 overflow-hidden rounded-2xl border border-border-soft bg-surface-base shadow-2xs">
|
|
<img
|
|
src={imgUrl}
|
|
alt={tour.title}
|
|
className="h-full w-full object-cover"
|
|
onError={(e) => {
|
|
;(e.target as HTMLImageElement).src =
|
|
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="%2318181b"><rect width="200" height="200"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="12" font-family="sans-serif">Aqila</text></svg>'
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 space-y-1.5 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xs font-mono font-bold text-grey-3">#{tour.id}</span>
|
|
<CountryFlagBadge countryCode={tour.destination_country} />
|
|
|
|
{/* وضعیت زمانی زنده */}
|
|
{isCurrentlyRunning ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-sky-500/15 px-2.5 py-0.5 text-xs font-bold text-sky-500">
|
|
<span className="relative flex size-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
|
|
</span>
|
|
هماکنون در حال برگزاری
|
|
</span>
|
|
) : isImminent ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/15 px-2.5 py-0.5 text-xs font-bold text-amber-600 dark:text-amber-400">
|
|
<Ic name="clock" className="size-3" />
|
|
آمادهسازی شروع ({toFa(Math.max(1, hoursToStart))} ساعت مانده)
|
|
</span>
|
|
) : (
|
|
<TourStatusBadge status={tour.trip_status} type="trip" />
|
|
)}
|
|
</div>
|
|
|
|
<h2 className="text-base sm:text-lg font-black text-foreground leading-snug">
|
|
{tour.title}
|
|
</h2>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 text-xs text-grey-3">
|
|
<div className="flex items-center gap-1">
|
|
<Ic name="calendar" className="size-3.5 text-primary" />
|
|
<span>
|
|
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
|
|
</span>
|
|
</div>
|
|
<span>|</span>
|
|
<div className="flex items-center gap-1">
|
|
<Ic name="tag" className="size-3.5 text-grey-3" />
|
|
<span className="font-mono">{tour.slug}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* دکمهها و خلاصه پیشرفت مرحله */}
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 lg:self-center shrink-0">
|
|
{/* نوار کوچک پیشرفت مرحله */}
|
|
{itineraries.length > 0 && (
|
|
<div className="rounded-xl border border-border-soft bg-surface-base p-2.5 min-w-[200px] space-y-1.5">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-grey-3 text-[11px]">وضعیت اجرای برنامه:</span>
|
|
<span className="font-bold text-primary font-mono">{toFa(progressPercent)}%</span>
|
|
</div>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-border-soft">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-primary to-amber-500 transition-all duration-500"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] text-grey-3 truncate">
|
|
{currentStepIndex >= 0 ? (
|
|
<>
|
|
مرحله فعلی: <strong className="text-foreground">{toFa(currentStepIndex + 1)}</strong> از{' '}
|
|
<strong className="text-foreground">{toFa(itineraries.length)}</strong>
|
|
</>
|
|
) : (
|
|
`${toFa(itineraries.length)} مرحله ثبتشده`
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* دکمه باز/بسته کردن تایملاین Itinerary */}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => toggleExpand(tour.id)}
|
|
className="rounded-xl text-xs font-bold gap-1.5 h-10 px-4"
|
|
>
|
|
<Ic name="list" className="size-4 text-primary" />
|
|
<span>{isExpanded ? 'بستن برنامه سفر' : 'مشاهده برنامه سفر (Itinerary)'}</span>
|
|
<Ic
|
|
name={isExpanded ? 'chevUp' : 'chevDown'}
|
|
className="size-3.5 text-grey-3 transition-transform"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* بخش ۲: بدنه Itineraryها و تایملاین زنده مراحل سفر */}
|
|
{isExpanded && (
|
|
<div className="p-5 sm:p-6 space-y-6">
|
|
{/* اطلاعات مرحله جاری (Current Active Step Callout) */}
|
|
{itineraries.length > 0 && currentStepIndex >= 0 && (
|
|
<div className="relative overflow-hidden rounded-2xl border border-primary/30 bg-gradient-to-r from-primary/10 via-amber-500/5 to-transparent p-4 sm:p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative flex size-2.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full size-2.5 bg-primary"></span>
|
|
</span>
|
|
<span className="text-xs font-black text-primary">
|
|
مرحله جاری تور (هماکنون در حال برگزاری):
|
|
</span>
|
|
</div>
|
|
<h3 className="text-sm sm:text-base font-black text-foreground">
|
|
{toFa(currentStepIndex + 1)}. {itineraries[currentStepIndex]?.title}
|
|
</h3>
|
|
<p className="text-xs text-grey-2 leading-relaxed max-w-3xl">
|
|
{itineraries[currentStepIndex]?.summary}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="text-start sm:text-end shrink-0 text-xs font-mono text-grey-3">
|
|
<div className="font-bold text-foreground">
|
|
{formatDateTime(itineraries[currentStepIndex]?.started_at)}
|
|
</div>
|
|
{itineraries[currentStepIndex]?.ended_at && (
|
|
<div className="text-[11px]">
|
|
تا {formatDateTime(itineraries[currentStepIndex]?.ended_at)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* وضعیت لودینگ Itinerary */}
|
|
{isLoadingItinerary ? (
|
|
<Shimmer rows={3} withCard={false} />
|
|
) : itineraryError ? (
|
|
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 text-center text-xs text-rose-500">
|
|
{itineraryError}
|
|
</div>
|
|
) : itineraries.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-border-soft p-8 text-center text-grey-3">
|
|
<Ic name="calendar" className="mx-auto size-7 mb-2 opacity-50" />
|
|
<p className="text-xs">برنامه زمانبندی روزانهای (Itinerary) برای این تور ثبت نشده است.</p>
|
|
</div>
|
|
) : (
|
|
/* تایملاین عمودی مراحل سفر (Vertical Stepper Timeline) */
|
|
<div className="relative space-y-6 before:absolute before:right-4 before:top-3 before:bottom-3 before:w-0.5 before:bg-border-soft">
|
|
{itineraries.map((step, idx) => {
|
|
const isCurrent = idx === currentStepIndex
|
|
const isPast = currentStepIndex >= 0 ? idx < currentStepIndex : false
|
|
|
|
// وضعیت این مرحله: Completed / In Progress / Not Started
|
|
const isCompleted =
|
|
step.status === 'Completed' ||
|
|
step.status === 1 ||
|
|
step.status === '1' ||
|
|
isPast
|
|
|
|
const isInProgress =
|
|
step.status === 'In Progress' ||
|
|
step.status === 2 ||
|
|
step.status === '2' ||
|
|
isCurrent
|
|
|
|
return (
|
|
<div key={step.id || idx} className="relative flex items-start gap-4 sm:gap-6 pr-0">
|
|
{/* آیکون / نشانگر دایرهای تایملاین */}
|
|
<div className="relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border-2 transition-all">
|
|
{isCompleted ? (
|
|
<div className="flex size-8 items-center justify-center rounded-full bg-emerald-500 text-white shadow-xs">
|
|
<Ic name="check" className="size-4" />
|
|
</div>
|
|
) : isInProgress ? (
|
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-white ring-4 ring-primary/20 shadow-md">
|
|
<span className="text-xs font-black font-mono">{toFa(idx + 1)}</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex size-8 items-center justify-center rounded-full bg-surface-base border-border-soft text-grey-3">
|
|
<span className="text-xs font-bold font-mono">{toFa(idx + 1)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* کارت محتوای مرحله سفر */}
|
|
<div
|
|
className={`flex-1 rounded-2xl border p-4 sm:p-5 transition ${
|
|
isInProgress
|
|
? 'border-primary/40 bg-card/90 shadow-md ring-1 ring-primary/15'
|
|
: isCompleted
|
|
? 'border-border-soft bg-card/40 opacity-95'
|
|
: 'border-border-soft bg-card/20 opacity-80'
|
|
}`}
|
|
>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xs font-bold text-grey-3">
|
|
گام {toFa(idx + 1)} از {toFa(itineraries.length)}
|
|
</span>
|
|
|
|
{/* برچسب وضعیت مرحله */}
|
|
{isInProgress ? (
|
|
<Badge className="bg-primary text-white font-bold text-[11px] gap-1 shadow-2xs">
|
|
<span className="relative flex size-1.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full size-1.5 bg-white"></span>
|
|
</span>
|
|
مرحله جاری (In Progress)
|
|
</Badge>
|
|
) : isCompleted ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold text-[11px]"
|
|
>
|
|
تکمیل شده (Completed)
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-border-soft bg-surface-base text-grey-3 font-medium text-[11px]"
|
|
>
|
|
در انتظار شروع (Not Started)
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<h4 className="text-sm sm:text-base font-black text-foreground">
|
|
{step.title}
|
|
</h4>
|
|
</div>
|
|
|
|
{/* تاریخ و زمان مرحله */}
|
|
<div className="text-start sm:text-end text-xs font-mono text-grey-3 bg-surface-base px-2.5 py-1.5 rounded-xl border border-border-soft shrink-0">
|
|
<div className="flex items-center gap-1 font-bold text-foreground">
|
|
<Ic name="calendar" className="size-3.5 text-primary" />
|
|
<span>{formatDateTime(step.started_at)}</span>
|
|
</div>
|
|
{step.ended_at && (
|
|
<div className="text-[10px] text-grey-3 mt-0.5">
|
|
{language === 'fa' ? 'تا' : language === 'ar' ? 'إلى' : 'to'} {formatDateTime(step.ended_at)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* خلاصه و توضیحات مرحله */}
|
|
{step.summary && (
|
|
<p className="mt-3 text-xs leading-relaxed text-grey-2 whitespace-pre-line">
|
|
{step.summary}
|
|
</p>
|
|
)}
|
|
|
|
{/* تصاویر مرحله */}
|
|
{step.images && step.images.length > 0 && (
|
|
<div className="mt-4 space-y-1.5">
|
|
<span className="text-[11px] font-bold text-grey-3">تصاویر این مرحله:</span>
|
|
<div className="flex gap-2.5 overflow-x-auto pb-1 no-scrollbar">
|
|
{step.images.map((img, imgIdx) => (
|
|
<div
|
|
key={img.id || imgIdx}
|
|
className="relative size-20 shrink-0 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs"
|
|
>
|
|
<img
|
|
src={
|
|
img.image_url?.sm ||
|
|
img.image_url?.small ||
|
|
img.image_url?.md ||
|
|
img.image_url?.medium ||
|
|
img.image_url?.original ||
|
|
'/placeholder.jpg'
|
|
}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|