Browse Source

feat: implement tours management feature with pages, components, API services, and routing

master
parent
commit
2809aa70d9
  1. 5
      src/app/router.tsx
  2. 105
      src/components/layout/AppSidebar.tsx
  3. 75
      src/components/ui/card.tsx
  4. 16
      src/config/navigation.ts
  5. 548
      src/features/tours/TourOrdersPage.tsx
  6. 556
      src/features/tours/ToursPage.tsx
  7. 525
      src/features/tours/components/TourDetailSheet.tsx
  8. 213
      src/features/tours/components/TourStatusBadge.tsx
  9. 632
      src/features/tours/components/TourTranslationsDialog.tsx
  10. 145
      src/features/tours/services/tour-api.ts
  11. 185
      src/features/tours/types.ts
  12. 1
      src/icons.tsx

5
src/app/router.tsx

@ -6,6 +6,8 @@ import RequireAuth from '@/features/auth/RequireAuth'
/* لود صفحات اصلی */
const LoginPage = lazy(() => import('@/features/auth/LoginPage'))
const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome'))
const ToursPage = lazy(() => import('@/features/tours/ToursPage'))
const TourOrdersPage = lazy(() => import('@/features/tours/TourOrdersPage'))
export default function AppRouter() {
return (
@ -19,6 +21,8 @@ export default function AppRouter() {
{/* تمام مسیرهای محافظت‌شده پشت گارد امنیتی */}
<Route element={<RequireAuth><Outlet /></RequireAuth>}>
<Route path="/" element={<PageReady><DashboardHome /></PageReady>} />
<Route path="/tours" element={<PageReady><ToursPage /></PageReady>} />
<Route path="/tours/orders" element={<PageReady><TourOrdersPage /></PageReady>} />
{/* هدایت مسیرهای ناشناخته به صفحه اصلی */}
<Route path="*" element={<Navigate to="/" replace />} />
@ -28,3 +32,4 @@ export default function AppRouter() {
</>
)
}

105
src/components/layout/AppSidebar.tsx

@ -18,15 +18,11 @@ import { navigationConfig } from '@/config/navigation'
import { siteConfig } from '@/config/site'
import { AqilaLogo } from '@/components/AqilaLogo'
type NavItemProps = {
to?: string
icon: IconName
label: string
badge?: string
soon?: boolean
}
import * as React from 'react'
import { cn } from '@/lib/utils'
import type { NavItemConfig } from '@/config/navigation'
function Tail({ badge, soon }: Pick<NavItemProps, 'badge' | 'soon'>) {
function Tail({ badge, soon }: { badge?: string; soon?: boolean }) {
if (soon) {
return (
<span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3">
@ -38,25 +34,90 @@ function Tail({ badge, soon }: Pick<NavItemProps, 'badge' | 'soon'>) {
return null
}
function NavItem({ to, icon, label, badge, soon }: NavItemProps) {
function NavItem({ item }: { item: NavItemConfig }) {
const { pathname } = useLocation()
const isActive = to ? (to === '/' ? pathname === '/' : pathname.startsWith(to)) : false
const hasChildren = Boolean(item.children && item.children.length > 0)
const isChildActive = hasChildren && item.children?.some((c) => pathname.startsWith(c.to))
const [isOpen, setIsOpen] = React.useState<boolean>(Boolean(isChildActive || pathname.startsWith('/tours')))
React.useEffect(() => {
if (isChildActive) {
setIsOpen(true)
}
}, [isChildActive])
if (hasChildren && item.children) {
return (
<SidebarMenuItem className="flex flex-col">
<SidebarMenuButton
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
'w-full justify-between transition-colors',
isChildActive && 'text-primary font-semibold'
)}
tooltip={item.title}
>
<div className="flex items-center gap-2 min-w-0">
<Ic name={item.icon} />
<span className="truncate">{item.title}</span>
</div>
<div className="flex items-center gap-1.5 ms-auto">
<Tail badge={item.badge} soon={item.soon} />
<Ic
name={isOpen ? 'chevUp' : 'chevDown'}
className="size-3.5 text-grey-3 transition-transform"
/>
</div>
</SidebarMenuButton>
{isOpen && (
<div className="mr-5 mt-1 flex flex-col gap-1 border-r border-border/40 pr-2">
{item.children.map((sub) => {
const isSubActive = sub.to === '/tours' ? pathname === '/tours' : pathname.startsWith(sub.to)
return (
<NavLink
key={sub.to}
to={sub.to}
className={cn(
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'bg-primary/15 text-primary font-bold shadow-xs'
: 'text-grey-3 hover:bg-white/5 hover:text-foreground'
)}
>
<Ic name={sub.icon} className="size-3.5 shrink-0" />
<span className="truncate">{sub.title}</span>
{sub.badge && (
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary">
{sub.badge}
</span>
)}
</NavLink>
)
})}
</div>
)}
</SidebarMenuItem>
)
}
const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false
const inner = (
<>
<Ic name={icon} />
<span className="truncate">{label}</span>
<Tail badge={badge} soon={soon} />
<Ic name={item.icon} />
<span className="truncate">{item.title}</span>
<Tail badge={item.badge} soon={item.soon} />
</>
)
return (
<SidebarMenuItem>
{to ? (
<SidebarMenuButton asChild isActive={isActive} tooltip={label}>
<NavLink to={to}>{inner}</NavLink>
{item.to ? (
<SidebarMenuButton asChild isActive={isActive} tooltip={item.title}>
<NavLink to={item.to}>{inner}</NavLink>
</SidebarMenuButton>
) : (
<SidebarMenuButton tooltip={label} className={soon ? 'opacity-55' : undefined}>
<SidebarMenuButton tooltip={item.title} className={item.soon ? 'opacity-55' : undefined}>
{inner}
</SidebarMenuButton>
)}
@ -64,6 +125,7 @@ function NavItem({ to, icon, label, badge, soon }: NavItemProps) {
)
}
function SidebarUserFooter() {
const { user } = useAuth()
const logout = useLogout()
@ -125,14 +187,7 @@ export default function AppSidebar() {
{group.title ? <SidebarGroupLabel>{group.title}</SidebarGroupLabel> : null}
<SidebarMenu>
{group.items.map((item) => (
<NavItem
key={item.title}
to={item.to}
icon={item.icon}
label={item.title}
badge={item.badge}
soon={item.soon}
/>
<NavItem key={item.title} item={item} />
))}
</SidebarMenu>
</SidebarGroup>

75
src/components/ui/card.tsx

@ -0,0 +1,75 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-2xl border border-border-soft bg-card text-card-foreground shadow-xs',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-5', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-base font-bold leading-none tracking-tight', className)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-xs text-grey-3', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-5 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

16
src/config/navigation.ts

@ -1,11 +1,19 @@
import type { IconName } from '@/icons'
export interface NavSubItemConfig {
title: string
to: string
icon: IconName
badge?: string
}
export interface NavItemConfig {
title: string
to?: string
icon: IconName
badge?: string
soon?: boolean
children?: NavSubItemConfig[]
}
export interface NavGroupConfig {
@ -18,6 +26,14 @@ export const navigationConfig: NavGroupConfig[] = [
title: '',
items: [
{ title: 'داشبورد', to: '/', icon: 'home' },
{
title: 'تورها',
icon: 'map',
children: [
{ title: 'فهرست تورها', to: '/tours', icon: 'list' },
{ title: 'سفارشات و رزروها', to: '/tours/orders', icon: 'clipboardCheck' },
],
},
],
},
]

548
src/features/tours/TourOrdersPage.tsx

@ -0,0 +1,548 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Ic } from '@/icons'
import { fetchTourOrders, updateTourOrder } from './services/tour-api'
import type { TourOrderItem, TourOrderStatus } from './types'
import { TourStatusBadge } from './components/TourStatusBadge'
export default function TourOrdersPage() {
const [orders, setOrders] = useState<TourOrderItem[]>([])
const [totalCount, setTotalCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [searchQuery, setSearchQuery] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<string>('')
// Receipt Modal
const [previewReceiptUrl, setPreviewReceiptUrl] = useState<string | null>(null)
const [previewReceiptOrderId, setPreviewReceiptOrderId] = useState<number | null>(null)
const [previewReceiptVerified, setPreviewReceiptVerified] = useState<boolean>(false)
// Order Details Modal
const [detailOrder, setDetailOrder] = useState<TourOrderItem | null>(null)
// Updating action state
const [updatingId, setUpdatingId] = useState<number | null>(null)
const loadOrders = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const res = await fetchTourOrders({
search: searchQuery || undefined,
status: selectedStatus || undefined,
})
setOrders(res.results || [])
setTotalCount(res.count || 0)
} catch (err: any) {
setError(err?.message || 'خطا در برقراری ارتباط با سرور و دریافت سفارشات.')
} finally {
setIsLoading(false)
}
}, [searchQuery, selectedStatus])
useEffect(() => {
loadOrders()
}, [loadOrders])
// Statistics KPI calculations
const stats = useMemo(() => {
const total = orders.length
const approved = orders.filter((o) => o.status === 'APPROVED').length
const pending = orders.filter(
(o) => o.status === 'PENDING' || o.status === 'AWAITING_PAYMENT'
).length
const totalAmount = orders.reduce((sum, o) => {
const val = parseFloat(o.total_price || '0')
return sum + (isNaN(val) ? 0 : val)
}, 0)
return { total, approved, pending, totalAmount }
}, [orders])
const handleUpdateStatus = async (orderId: number, newStatus: TourOrderStatus) => {
setUpdatingId(orderId)
try {
const updated = await updateTourOrder(orderId, { status: newStatus })
setOrders((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: updated.status } : o)))
if (detailOrder && detailOrder.id === orderId) {
setDetailOrder((prev) => (prev ? { ...prev, status: updated.status } : null))
}
} catch (err: any) {
alert(`خطا در ویرایش وضعیت سفارش: ${err?.message || 'مشکل غیرمنتظره'}`)
} finally {
setUpdatingId(null)
}
}
const handleToggleReceiptVerify = async (orderId: number, currentVerified: boolean) => {
setUpdatingId(orderId)
try {
const updated = await updateTourOrder(orderId, {
receipt_verified: !currentVerified,
status: !currentVerified ? 'APPROVED' : undefined,
})
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, receipt_verified: updated.receipt_verified, status: updated.status }
: o
)
)
setPreviewReceiptVerified(updated.receipt_verified)
} catch (err: any) {
alert(`خطا در اعتبارسنجی فیش: ${err?.message || 'خطای سرور'}`)
} finally {
setUpdatingId(null)
}
}
return (
<div className="space-y-6 pb-12">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-foreground tracking-tight">
سفارشات و رزروهای تور
</h1>
<p className="text-xs text-grey-3 mt-1">
پیگیری سفارشات، نظارت بر پرداختها و اعتبارسنجی مدارک و فیشهای واریزی مسافران
</p>
</div>
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="sm"
onClick={loadOrders}
disabled={isLoading}
className="gap-2 rounded-xl border-border-soft hover:bg-surface-2"
>
<Ic name="refresh" className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
<span>بروزرسانی دادهها</span>
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="bg-card border-border-soft shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold text-grey-3">کل سفارشات ثبتشده</p>
<h3 className="text-2xl font-black text-foreground mt-1">
{isLoading ? '...' : totalCount}
</h3>
</div>
<div className="size-11 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
<Ic name="clipboardCheck" className="size-5" />
</div>
</CardContent>
</Card>
<Card className="bg-card border-border-soft shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold text-grey-3">رزروهای تأییدشده</p>
<h3 className="text-2xl font-black text-emerald-500 mt-1">
{isLoading ? '...' : stats.approved}
</h3>
</div>
<div className="size-11 rounded-2xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
<Ic name="check" className="size-5" />
</div>
</CardContent>
</Card>
<Card className="bg-card border-border-soft shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold text-grey-3">در انتظار بررسی و پرداخت</p>
<h3 className="text-2xl font-black text-amber-500 mt-1">
{isLoading ? '...' : stats.pending}
</h3>
</div>
<div className="size-11 rounded-2xl bg-amber-500/10 text-amber-500 flex items-center justify-center">
<Ic name="clock" className="size-5" />
</div>
</CardContent>
</Card>
<Card className="bg-card border-border-soft shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold text-grey-3">مجموع ارزش مالی</p>
<h3 className="text-xl font-black text-foreground mt-1">
{isLoading ? '...' : `$${stats.totalAmount.toLocaleString()}`}
</h3>
</div>
<div className="size-11 rounded-2xl bg-blue-500/10 text-blue-500 flex items-center justify-center">
<Ic name="chart" className="size-5" />
</div>
</CardContent>
</Card>
</div>
{/* Filter and Search Bar */}
<Card className="bg-card border-border-soft shadow-xs">
<CardContent className="p-4 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Ic
name="search"
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none"
/>
<Input
type="text"
placeholder="جستجو در سفارشات (عنوان تور، کاربر...)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-9 bg-surface-2 border-border/50 text-xs rounded-xl"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-1.5 w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0">
{[
{ id: '', label: 'همه سفارشات' },
{ id: 'APPROVED', label: 'تأییدشده' },
{ id: 'PENDING', label: 'در انتظار بررسی' },
{ id: 'AWAITING_PAYMENT', label: 'منتظر پرداخت' },
{ id: 'REJECTED', label: 'ردشده' },
].map((st) => (
<button
key={st.id}
type="button"
onClick={() => setSelectedStatus(st.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedStatus === st.id
? 'bg-primary text-white shadow-xs'
: 'bg-surface-2 text-grey-3 hover:text-foreground hover:bg-surface-3'
}`}
>
{st.label}
</button>
))}
</div>
</CardContent>
</Card>
{/* Orders Table */}
<Card className="bg-card border-border-soft shadow-xs overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs border-collapse">
<thead>
<tr className="border-b border-border/50 bg-secondary/15 text-grey-2">
<th className="p-4 font-bold text-start">کد سفارش</th>
<th className="p-4 font-bold text-start">کاربر</th>
<th className="p-4 font-bold text-start">تور مقصد</th>
<th className="p-4 font-bold text-start">مبلغ کل</th>
<th className="p-4 font-bold text-start">روش پرداخت</th>
<th className="p-4 font-bold text-start">فیش واریزی</th>
<th className="p-4 font-bold text-start">وضعیت رزرو</th>
<th className="p-4 font-bold text-start">تاریخ ثبت</th>
<th className="p-4 font-bold text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td colSpan={9} className="p-4 text-center text-grey-4">
در حال دریافت لیست سفارشات از دیتابیس...
</td>
</tr>
))
) : error ? (
<tr>
<td colSpan={9} className="p-8 text-center text-rose-400 font-bold">
{error}
</td>
</tr>
) : orders.length === 0 ? (
<tr>
<td colSpan={9} className="p-12 text-center text-grey-3">
<Ic name="inbox" className="size-8 mx-auto mb-2 text-grey-4" />
<p className="font-bold">هیچ سفارشی با مشخصات انتخابی یافت نشد.</p>
</td>
</tr>
) : (
orders.map((order) => {
const isUpdating = updatingId === order.id
return (
<tr key={order.id} className="hover:bg-surface-2/40 transition-colors">
{/* ID */}
<td className="p-4 font-extrabold text-foreground">
#{order.id}
</td>
{/* User */}
<td className="p-4 font-bold text-grey-1">
{order.user || 'نامشخص'}
</td>
{/* Tour Title */}
<td className="p-4 font-semibold text-foreground max-w-[220px] truncate">
{order.tour_title || order.tour_slug || '-'}
</td>
{/* Total Price */}
<td className="p-4 font-black text-foreground">
${parseFloat(order.total_price || '0').toLocaleString()}
</td>
{/* Payment Method */}
<td className="p-4 text-grey-3 font-medium">
{order.payment_method || (order.is_paid ? 'آنلاین' : 'کارت به کارت / فیش')}
</td>
{/* Payment Receipt */}
<td className="p-4">
{order.payment_receipt ? (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setPreviewReceiptUrl(order.payment_receipt || null)
setPreviewReceiptOrderId(order.id)
setPreviewReceiptVerified(order.receipt_verified)
}}
className="size-8 rounded-lg overflow-hidden border border-border/50 hover:opacity-80 transition-opacity shrink-0"
>
<img
src={order.payment_receipt}
alt="Receipt"
className="w-full h-full object-cover"
/>
</button>
<span
className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${
order.receipt_verified
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-amber-500/15 text-amber-400'
}`}
>
{order.receipt_verified ? 'تأییدشده' : 'بررسی نشده'}
</span>
</div>
) : (
<span className="text-[11px] text-grey-4">بدون فیش</span>
)}
</td>
{/* Status */}
<td className="p-4">
<TourStatusBadge status={order.status} type="order" />
</td>
{/* Date */}
<td className="p-4 text-grey-3 font-medium text-[11px]">
{order.created ? new Date(order.created).toLocaleDateString('fa-IR') : '-'}
</td>
{/* Actions */}
<td className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5">
<Button
size="sm"
variant="ghost"
onClick={() => setDetailOrder(order)}
className="size-8 p-0 rounded-xl hover:bg-surface-3 text-grey-2"
title="مشاهده جزئیات کامل"
>
<Ic name="eye" className="size-4" />
</Button>
{order.status !== 'APPROVED' && (
<Button
size="sm"
variant="outline"
disabled={isUpdating}
onClick={() => handleUpdateStatus(order.id, 'APPROVED')}
className="h-8 px-2.5 rounded-xl border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10 text-xs font-bold gap-1"
>
<Ic name="check" className="size-3.5" />
<span>تایید</span>
</Button>
)}
{order.status !== 'REJECTED' && (
<Button
size="sm"
variant="ghost"
disabled={isUpdating}
onClick={() => handleUpdateStatus(order.id, 'REJECTED')}
className="size-8 p-0 rounded-xl hover:bg-rose-500/10 text-rose-400"
title="رد سفارش"
>
<Ic name="x" className="size-4" />
</Button>
)}
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</Card>
{/* Receipt Image Preview Dialog */}
<Dialog
open={Boolean(previewReceiptUrl)}
onOpenChange={(op) => !op && setPreviewReceiptUrl(null)}
>
<DialogContent className="max-w-[560px] p-0 overflow-hidden">
<DialogHeader className="p-5 border-b border-border/40">
<DialogTitle className="text-base font-black text-foreground">
فیش واریزی بانکی سفارش #{previewReceiptOrderId}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3">
بررسی و تطبیق شماره پیگیری، مبلغ واریزی و صحت تراکنش
</DialogDescription>
</DialogHeader>
<div className="p-5 flex justify-center bg-black/40 max-h-[60vh] overflow-auto">
{previewReceiptUrl && (
<img
src={previewReceiptUrl}
alt="فیش واریزی بانکی"
className="rounded-xl max-h-[50vh] object-contain shadow-lg"
/>
)}
</div>
<DialogFooter className="p-4 bg-secondary/15 border-t border-border/40 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-grey-3">وضعیت فیش:</span>
<span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${
previewReceiptVerified
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-amber-500/15 text-amber-400'
}`}
>
{previewReceiptVerified ? 'تأییدشده' : 'در انتظار تأیید'}
</span>
</div>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button variant="outline" size="sm" className="rounded-xl">
بستن
</Button>
</DialogClose>
{previewReceiptOrderId && (
<Button
size="sm"
onClick={() =>
handleToggleReceiptVerify(previewReceiptOrderId, previewReceiptVerified)
}
className={`rounded-xl font-bold gap-1.5 ${
previewReceiptVerified
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
>
<Ic name="check" className="size-3.5" />
<span>{previewReceiptVerified ? 'لغو تایید فیش' : 'تایید فیش و پذیرش رزرو'}</span>
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Order Detail Modal */}
<Dialog open={Boolean(detailOrder)} onOpenChange={(op) => !op && setDetailOrder(null)}>
<DialogContent className="max-w-[600px] p-0 overflow-hidden">
<DialogHeader className="p-5 border-b border-border/40">
<DialogTitle className="text-base font-black text-foreground">
جزئیات سفارش تور #{detailOrder?.id}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3">
اطلاعات کامل رزرو، خریدار و پارامترهای مالی
</DialogDescription>
</DialogHeader>
{detailOrder && (
<div className="p-6 space-y-4 text-xs">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl bg-surface-2 p-3 space-y-1">
<p className="text-grey-3 font-bold">نام کاربر ثبتکننده</p>
<p className="font-extrabold text-foreground text-sm">{detailOrder.user}</p>
</div>
<div className="rounded-xl bg-surface-2 p-3 space-y-1">
<p className="text-grey-3 font-bold">عنوان تور</p>
<p className="font-extrabold text-foreground text-sm truncate">
{detailOrder.tour_title}
</p>
</div>
<div className="rounded-xl bg-surface-2 p-3 space-y-1">
<p className="text-grey-3 font-bold">قیمت پایه هر نفر</p>
<p className="font-black text-foreground text-sm">
${parseFloat(detailOrder.price || '0').toLocaleString()}
</p>
</div>
<div className="rounded-xl bg-surface-2 p-3 space-y-1">
<p className="text-grey-3 font-bold">مبلغ کل پرداختی</p>
<p className="font-black text-emerald-400 text-sm">
${parseFloat(detailOrder.total_price || '0').toLocaleString()}
</p>
</div>
</div>
{detailOrder.request_details && (
<div className="rounded-xl bg-surface-2 p-3.5 space-y-1">
<p className="font-bold text-grey-3">یادداشت و جزئیات درخواست مسافر:</p>
<p className="text-foreground leading-relaxed">
{detailOrder.request_details}
</p>
</div>
)}
<div className="flex items-center justify-between p-3 rounded-xl bg-surface-2/60 border border-border/40">
<div className="flex items-center gap-2">
<span className="text-grey-3">وضعیت فعلی سفارش:</span>
<TourStatusBadge status={detailOrder.status} type="order" />
</div>
<div className="flex items-center gap-2">
<span className="text-grey-3">تراکنش مالی:</span>
<span
className={`font-bold ${
detailOrder.is_paid ? 'text-emerald-400' : 'text-amber-400'
}`}
>
{detailOrder.is_paid ? 'پرداخت قطعی' : 'پرداخت‌نشده / نیازمند بررسی'}
</span>
</div>
</div>
</div>
)}
<DialogFooter className="p-4 bg-secondary/15 border-t border-border/40 flex justify-end">
<DialogClose asChild>
<Button variant="outline" size="sm" className="rounded-xl">
بستن
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

556
src/features/tours/ToursPage.tsx

@ -0,0 +1,556 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { fetchTours } from './services/tour-api'
import type { TourListItem } from './types'
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import { TourDetailSheet } from './components/TourDetailSheet'
import { TourTranslationsDialog } from './components/TourTranslationsDialog'
export default function ToursPage() {
const [tours, setTours] = useState<TourListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
// فیلترها و مرتب‌سازی
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('ALL')
const [tripStatusFilter, setTripStatusFilter] = useState('ALL')
const [countryFilter, setCountryFilter] = useState('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const [languageCode, setLanguageCode] = useState<string>('')
// وضعیت شیت جزئیات تور
const [selectedTourId, setSelectedTourId] = useState<number | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
// وضعیت دیالوگ ترجمه‌ها
const [transTourId, setTransTourId] = useState<number | null>(null)
const [transTourTitle, setTransTourTitle] = useState<string>('')
const [isTransOpen, setIsTransOpen] = useState(false)
const loadData = async (showRefresh = false) => {
if (showRefresh) setIsRefreshing(true)
else setIsLoading(true)
setError(null)
try {
const res = await fetchTours({
limit: 100,
language_code: languageCode || undefined,
})
setTours(res.results || [])
} catch (err: any) {
setError(err.message || 'خطا در برقراری ارتباط با وب‌سرویس تورها')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [languageCode])
// فیلتر و جستجوی کلاینت
const filteredTours = useMemo(() => {
return tours.filter((tour) => {
const matchSearch =
!searchTerm.trim() ||
tour.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
tour.slug.toLowerCase().includes(searchTerm.toLowerCase())
const matchStatus = statusFilter === 'ALL' || tour.status === statusFilter
const matchTripStatus = tripStatusFilter === 'ALL' || tour.trip_status === tripStatusFilter
const matchCountry = countryFilter === 'ALL' || tour.destination_country === countryFilter
return matchSearch && matchStatus && matchTripStatus && matchCountry
})
}, [tours, searchTerm, statusFilter, tripStatusFilter, countryFilter])
// شاخص‌های کلیدی آماری (KPI Metrics)
const stats = useMemo(() => {
const total = tours.length
const available = tours.filter((t) => t.status === 'AVAILABLE').length
const soldOut = tours.filter((t) => t.status === 'SOLD_OUT').length
const traveling = tours.filter((t) => t.trip_status === 'TRAVELING').length
const pending = tours.filter((t) => t.trip_status === 'PENDING').length
return { total, available, soldOut, traveling, pending }
}, [tours])
const handleOpenDetail = (id: number) => {
setSelectedTourId(id)
setIsDetailOpen(true)
}
// محاسبه طول مدت سفر به روز
const calculateDays = (start: string, end: string) => {
if (!start || !end) return '-'
const s = new Date(start).getTime()
const e = new Date(end).getTime()
const diff = Math.ceil((e - s) / (1000 * 3600 * 24))
return diff > 0 ? `${diff} روز` : '۱ روز'
}
return (
<div className="space-y-6 pt-2 pb-12">
{/* سربرگ مدیریت تورها */}
<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-primary/10 text-primary">
<Ic name="map" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">مدیریت تورها</h1>
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary font-bold">
{stats.total} تور ثبتشده
</Badge>
</div>
<p className="text-xs text-grey-3">
پایش وضعیت زنده فروش، برنامههای سفر، مسافران و اطلاعات قیمتی تورهای عقیله
</p>
</div>
<div className="flex flex-wrap items-center gap-2.5">
{/* انتخاب زبان */}
<select
value={languageCode}
onChange={(e) => setLanguageCode(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary shadow-xs"
>
<option value="">همه زبانها</option>
<option value="fa">فارسی (fa)</option>
<option value="en">انگلیسی (en)</option>
<option value="ar">عربی (ar)</option>
</select>
{/* تغییر نمای شبکه‌ای / جدولی */}
<div className="flex rounded-xl border border-border-soft bg-surface-base p-1">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="sm"
className="size-8 p-0"
onClick={() => setViewMode('grid')}
title="نمای کارتی"
>
<Ic name="grid" className="size-4" />
</Button>
<Button
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
size="sm"
className="size-8 p-0"
onClick={() => setViewMode('table')}
title="نمای جدول"
>
<Ic name="list" className="size-4" />
</Button>
</div>
{/* دکمه بروزرسانی زنده */}
<Button
variant="outline"
size="sm"
onClick={() => loadData(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>
{/* کارت‌های شاخص‌های کلیدی آماری (KPI Cards) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-5">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs transition hover:border-border">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کل تورها</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-foreground/5 text-foreground">
<Ic name="map" className="size-3.5" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{stats.total}</div>
<div className="mt-1 text-[11px] text-grey-3">تورهای منتشرشده</div>
</div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4 shadow-xs transition hover:border-emerald-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">در دسترس</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
<Ic name="check" className="size-3.5" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
{stats.available}
</div>
<div className="mt-1 text-[11px] text-emerald-700/60 dark:text-emerald-300/60">آماده ثبتنام مسافر</div>
</div>
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-4 shadow-xs transition hover:border-rose-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-rose-600 dark:text-rose-400 font-medium">تکمیل ظرفیت</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-rose-500/10 text-rose-500">
<Ic name="users" className="size-3.5" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-rose-600 dark:text-rose-400 font-mono">
{stats.soldOut}
</div>
<div className="mt-1 text-[11px] text-rose-700/60 dark:text-rose-300/60">ظرفیت تکمیل شده</div>
</div>
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4 shadow-xs transition hover:border-sky-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">در حال اجرا</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500">
<Ic name="calendar" className="size-3.5" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono">
{stats.traveling}
</div>
<div className="mt-1 text-[11px] text-sky-700/60 dark:text-sky-300/60">سفرهای جاری</div>
</div>
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-xs transition hover:border-amber-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">در انتظار شروع</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
<Ic name="clock" className="size-3.5" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">
{stats.pending}
</div>
<div className="mt-1 text-[11px] text-amber-700/60 dark:text-amber-300/60">تاریخ شروع در آینده</div>
</div>
</div>
{/* نوار فیلتر و جستجو */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/30 p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative flex-1 max-w-md">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی تور با عنوان یا اسلاگ..."
className="pr-9 text-xs bg-surface-base"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* وضعیت فروش */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden"
>
<option value="ALL">همه وضعیتهای فروش</option>
<option value="AVAILABLE">فقط در دسترس (Available)</option>
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option>
<option value="NO_SHOW">عدم نمایش (No Show)</option>
</select>
{/* وضعیت اجرا */}
<select
value={tripStatusFilter}
onChange={(e) => setTripStatusFilter(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden"
>
<option value="ALL">همه وضعیتهای سفر</option>
<option value="PENDING">در انتظار اجرا (Pending)</option>
<option value="TRAVELING">در حال برگزاری (Traveling)</option>
<option value="FINISHED">پایانیافته (Finished)</option>
</select>
{/* کشور مقصد */}
<select
value={countryFilter}
onChange={(e) => setCountryFilter(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden"
>
<option value="ALL">همه مقاصد</option>
<option value="IQ">🇮🇶 عراق</option>
<option value="IR">🇮🇷 ایران</option>
<option value="SA">🇸🇦 عربستان</option>
<option value="SY">🇸🇾 سوریه</option>
</select>
{(searchTerm || statusFilter !== 'ALL' || tripStatusFilter !== 'ALL' || countryFilter !== 'ALL') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchTerm('')
setStatusFilter('ALL')
setTripStatusFilter('ALL')
setCountryFilter('ALL')
}}
className="text-xs text-rose-500 hover:bg-rose-500/10"
>
پاکسازی فیلترها
</Button>
)}
</div>
</div>
{/* وضعیت لودینگ */}
{isLoading ? (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/30 p-4 space-y-3">
<Skeleton className="h-44 w-full rounded-xl" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex justify-between pt-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-16" />
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-border-soft bg-card/30 p-6 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
)
) : 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={() => loadData()}
className="mt-5 border-rose-500/30 text-rose-500 hover:bg-rose-500/10"
>
تلاش دوباره
</Button>
</div>
) : filteredTours.length === 0 ? (
/* وضعیت خالی (Empty State) */
<div className="rounded-2xl border border-dashed border-border-soft bg-card/20 p-16 text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Ic name="inbox" className="size-6" />
</div>
<h3 className="mt-4 text-sm font-bold text-foreground">توری یافت نشد</h3>
<p className="mt-1 text-xs text-grey-3 max-w-sm mx-auto">
هیچ توری با فیلترها و عبارت جستجوی انتخابی شما همخوانی ندارد.
</p>
</div>
) : viewMode === 'grid' ? (
/* نمای شبکه‌ای (Cards Grid View) */
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredTours.map((tour) => {
const imgUrl =
tour.image?.image_url?.medium ||
tour.image?.image_url?.large ||
tour.image?.image_url?.original ||
'/placeholder.jpg'
return (
<div
key={tour.id}
className="group flex flex-col justify-between overflow-hidden rounded-2xl border border-border-soft bg-card/40 transition hover:border-primary/40 hover:bg-card/70 hover:shadow-md"
>
<div>
{/* تصویر کاور تور با نشان کشور و وضعیت */}
<div className="relative aspect-16/10 w-full overflow-hidden bg-surface-base">
<img
src={imgUrl}
alt={tour.title}
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
onError={(e) => {
;(e.target as HTMLImageElement).src =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250" fill="%2318181b"><rect width="400" height="250"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="14" font-family="sans-serif">Aqila Tour</text></svg>'
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20" />
{/* بج کشور مقصد */}
<div className="absolute top-3 right-3">
<CountryFlagBadge countryCode={tour.destination_country} />
</div>
{/* وضعیت تجاری تور */}
<div className="absolute bottom-3 right-3 flex items-center gap-1.5">
<TourStatusBadge status={tour.status} type="sale" />
<TourStatusBadge status={tour.trip_status} type="trip" />
</div>
</div>
{/* بدنه کارت */}
<div className="p-4 space-y-3">
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px] text-grey-3">
<span className="font-mono" dir="ltr">#{tour.id}</span>
<span>{calculateDays(tour.started_at, tour.ended_at)}</span>
</div>
<h3 className="line-clamp-2 text-sm font-black text-foreground leading-snug">
{tour.title}
</h3>
</div>
{/* تاریخ برگزاری */}
<div className="flex items-center gap-1.5 text-xs text-grey-3 bg-surface-base p-2 rounded-xl border border-border-soft font-mono" dir="ltr">
<Ic name="calendar" className="size-3.5 shrink-0 text-primary" />
<span className="truncate">{tour.started_at} الی {tour.ended_at}</span>
</div>
{/* قیمت پایه و رده قیمتی */}
<div className="flex items-baseline justify-between pt-1 border-t border-border-soft">
<span className="text-[11px] text-grey-3">قیمت بزرگسال:</span>
<div className="text-sm font-black text-foreground">
${parseFloat(tour.price).toLocaleString()}
</div>
</div>
</div>
</div>
{/* فوتر کارت و دکمه جزئیات */}
<div className="p-4 pt-0 flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs font-bold border-border-soft group-hover:border-primary group-hover:bg-primary group-hover:text-white transition"
onClick={() => handleOpenDetail(tour.id)}
>
<Ic name="eye" className="size-3.5 ml-1.5" />
مشاهده جزئیات کامل
</Button>
<Button
variant="ghost"
size="sm"
className="size-8 p-0 rounded-xl text-grey-3 hover:text-primary hover:bg-primary/10 transition shrink-0"
title="مدیریت ترجمه‌های ۵ زبانه"
onClick={(e) => {
e.stopPropagation()
setTransTourId(tour.id)
setTransTourTitle(tour.title)
setIsTransOpen(true)
}}
>
<Ic name="globe" className="size-4" />
</Button>
</div>
</div>
)
})}
</div>
) : (
/* نمای جدول (Data Table View) */
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<Table>
<TableHeader className="bg-surface-base">
<TableRow>
<TableHead className="text-right text-xs font-bold">شناسه</TableHead>
<TableHead className="text-right text-xs font-bold">عنوان تور</TableHead>
<TableHead className="text-right text-xs font-bold">مقصد</TableHead>
<TableHead className="text-right text-xs font-bold">تاریخ برگزاری</TableHead>
<TableHead className="text-right text-xs font-bold">قیمت بزرگسال</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت فروش</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت سفر</TableHead>
<TableHead className="text-center text-xs font-bold">عملیات</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTours.map((tour) => (
<TableRow
key={tour.id}
className="transition hover:bg-card/70 cursor-pointer"
onClick={() => handleOpenDetail(tour.id)}
>
<TableCell className="font-mono text-xs text-grey-3 font-bold">#{tour.id}</TableCell>
<TableCell className="max-w-xs">
<div className="font-bold text-xs text-foreground line-clamp-1">{tour.title}</div>
<div className="text-[10px] text-grey-3 truncate font-mono">{tour.slug}</div>
</TableCell>
<TableCell>
<CountryFlagBadge countryCode={tour.destination_country} />
</TableCell>
<TableCell className="font-mono text-xs text-grey-2" dir="ltr">
{tour.started_at} ~ {tour.ended_at}
</TableCell>
<TableCell className="font-black text-xs text-foreground font-mono">
${parseFloat(tour.price).toLocaleString()}
</TableCell>
<TableCell>
<TourStatusBadge status={tour.status} type="sale" />
</TableCell>
<TableCell>
<TourStatusBadge status={tour.trip_status} type="trip" />
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
className="size-8 p-0 text-primary hover:bg-primary/10"
title="مشاهده جزئیات کامل"
onClick={(e) => {
e.stopPropagation()
handleOpenDetail(tour.id)
}}
>
<Ic name="eye" className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="size-8 p-0 text-grey-3 hover:text-primary hover:bg-primary/10"
title="مدیریت ترجمه‌های ۵ زبانه"
onClick={(e) => {
e.stopPropagation()
setTransTourId(tour.id)
setTransTourTitle(tour.title)
setIsTransOpen(true)
}}
>
<Ic name="globe" className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* شیت جزئیات کامل تور */}
<TourDetailSheet
tourId={selectedTourId}
isOpen={isDetailOpen}
onClose={() => setIsDetailOpen(false)}
/>
{/* دیالوگ مدیریت ترجمه‌های ۵ زبانه */}
<TourTranslationsDialog
tourId={transTourId}
tourTitle={transTourTitle}
open={isTransOpen}
onOpenChange={setIsTransOpen}
onSuccess={() => loadData()}
/>
</div>
)
}

525
src/features/tours/components/TourDetailSheet.tsx

@ -0,0 +1,525 @@
import React, { useState, useEffect } from 'react'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { Ic } from '@/icons'
import {
fetchTourDetail,
fetchTourItineraries,
fetchTourPassengers,
fetchTourComments,
} from '../services/tour-api'
import type {
TourDetailItem,
TourItineraryItem,
TourPassengerGroupItem,
TourCommentItem,
} from '../types'
import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge'
import { TourTranslationsDialog } from './TourTranslationsDialog'
interface TourDetailSheetProps {
tourId: number | null
isOpen: boolean
onClose: () => void
}
export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'passengers' | 'features' | 'comments'>('overview')
const [isTransDialogOpen, setIsTransDialogOpen] = useState<boolean>(false)
const [tour, setTour] = useState<TourDetailItem | null>(null)
const [itineraries, setItineraries] = useState<TourItineraryItem[]>([])
const [passengersGroup, setPassengersGroup] = useState<TourPassengerGroupItem[]>([])
const [comments, setComments] = useState<TourCommentItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSub, setIsLoadingSub] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!tourId || !isOpen) {
setTour(null)
return
}
let isMounted = true
setIsLoading(true)
setError(null)
setActiveTab('overview')
fetchTourDetail(tourId)
.then((data) => {
if (isMounted) {
setTour(data)
setIsLoading(false)
}
})
.catch((err: any) => {
if (isMounted) {
setError(err.message || 'خطا در دریافت اطلاعات تور')
setIsLoading(false)
}
})
return () => {
isMounted = false
}
}, [tourId, isOpen])
// لود داده‌های مربوط به تب‌های فرعی هنگام انتخاب
useEffect(() => {
if (!tourId || !isOpen || !tour) return
let isMounted = true
if (activeTab === 'itinerary' && itineraries.length === 0) {
setIsLoadingSub(true)
fetchTourItineraries(tourId)
.then((res) => {
if (isMounted) {
setItineraries(res.results || [])
setIsLoadingSub(false)
}
})
.catch(() => isMounted && setIsLoadingSub(false))
} else if (activeTab === 'passengers' && passengersGroup.length === 0) {
setIsLoadingSub(true)
fetchTourPassengers(tourId)
.then((res) => {
if (isMounted) {
setPassengersGroup(res.results || [])
setIsLoadingSub(false)
}
})
.catch(() => isMounted && setIsLoadingSub(false))
} else if (activeTab === 'comments' && comments.length === 0 && tour.slug) {
setIsLoadingSub(true)
fetchTourComments(tour.slug)
.then((res) => {
if (isMounted) {
setComments(res.results || [])
setIsLoadingSub(false)
}
})
.catch(() => isMounted && setIsLoadingSub(false))
}
return () => {
isMounted = false
}
}, [activeTab, tourId, isOpen, tour])
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto p-0 border-r border-border-soft bg-background">
<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">
{tour?.title || 'جزئیات تور'}
</SheetTitle>
</div>
<SheetDescription className="text-xs text-grey-3">
کد شناسایی: #{tourId} | اسلاگ سیستم: {tour?.slug}
</SheetDescription>
</div>
{tour && (
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => setIsTransDialogOpen(true)}
className="gap-1.5 text-xs rounded-xl border-primary/40 text-primary hover:bg-primary/10 h-7 px-2.5"
>
<Ic name="globe" className="size-3.5" />
<span>مدیریت ترجمهها (۵ زبان)</span>
</Button>
<div className="flex items-center gap-1.5">
<TourStatusBadge status={tour.status} type="sale" />
<TourStatusBadge status={tour.trip_status} type="trip" />
</div>
</div>
)}
</div>
<Tabs
value={activeTab}
onValueChange={(val: any) => setActiveTab(val)}
className="mt-4 w-full"
>
<TabsList className="grid w-full grid-cols-5 bg-surface-base border border-border-soft p-1">
<TabsTrigger value="overview" className="text-xs">مشخصات</TabsTrigger>
<TabsTrigger value="itinerary" className="text-xs">برنامه سفر</TabsTrigger>
<TabsTrigger value="passengers" className="text-xs">مسافران</TabsTrigger>
<TabsTrigger value="features" className="text-xs">امکانات</TabsTrigger>
<TabsTrigger value="comments" className="text-xs">نظرات</TabsTrigger>
</TabsList>
</Tabs>
</SheetHeader>
<div className="p-6 space-y-6">
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-44 w-full rounded-2xl" />
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-20 rounded-xl" />
<Skeleton className="h-20 rounded-xl" />
</div>
<Skeleton className="h-32 w-full rounded-xl" />
</div>
) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-6 text-center text-rose-500">
<Ic name="alert" className="mx-auto size-8 mb-2" />
<p className="font-bold text-sm">{error}</p>
<Button
variant="outline"
size="sm"
className="mt-4 border-rose-500/30 text-rose-500"
onClick={() => tourId && fetchTourDetail(tourId).then(setTour)}
>
تلاش مجدد
</Button>
</div>
) : tour ? (
<>
{/* تب ۱: مشخصات کلی و مالی */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* گالری تصاویر */}
{tour.images && tour.images.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-bold text-grey-2">گالری تصاویر تور</h4>
<div className="flex gap-2.5 overflow-x-auto pb-2 no-scrollbar">
{tour.images.map((img, i) => (
<div
key={img.id || i}
className="relative size-24 shrink-0 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs"
>
<img
src={img.image_url?.medium || img.image_url?.original || '/placeholder.jpg'}
alt=""
className="h-full w-full object-cover"
/>
</div>
))}
</div>
</div>
)}
{/* توضیحات تور */}
<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">
{tour.description}
</p>
</div>
{/* بازه تاریخ و ظرفیت */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center">
<div className="text-[11px] text-grey-3">تاریخ شروع</div>
<div className="mt-1 font-bold text-xs text-foreground font-mono" dir="ltr">{tour.started_at}</div>
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center">
<div className="text-[11px] text-grey-3">تاریخ پایان</div>
<div className="mt-1 font-bold text-xs text-foreground font-mono" dir="ltr">{tour.ended_at}</div>
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center">
<div className="text-[11px] text-grey-3">ظرفیت کل</div>
<div className="mt-1 font-extrabold text-sm text-foreground">{tour.capacity} نفر</div>
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center">
<div className="text-[11px] text-grey-3">فروخته شده</div>
<div className="mt-1 font-extrabold text-sm text-primary">{tour.number_sold} نفر</div>
</div>
</div>
{/* نوار تکمیل ظرفیت */}
<div className="rounded-xl border border-border-soft bg-card/20 p-4 space-y-2">
<div className="flex justify-between text-xs font-bold">
<span>وضعیت تکمیل ظرفیت</span>
<span className="text-primary font-mono">
{Math.round((tour.number_sold / (tour.capacity || 1)) * 100)}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-border-soft">
<div
className="h-full rounded-full bg-gradient-to-r from-primary to-amber-500"
style={{
width: `${Math.min(100, Math.round((tour.number_sold / (tour.capacity || 1)) * 100))}%`,
}}
/>
</div>
<div className="flex justify-between text-[11px] text-grey-3">
<span>ظرفیت باقیمانده: {Math.max(0, tour.capacity - tour.number_sold)} صندلی</span>
<span>ظرفیت کل: {tour.capacity}</span>
</div>
</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="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">بزرگسال (Adult 12+)</span>
<div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price).toLocaleString()}
</div>
{tour.percent_off > 0 && (
<div className="mt-1 flex items-center gap-1.5 text-[11px]">
<span className="rounded bg-rose-500/10 px-1.5 py-0.2 font-bold text-rose-500">
{tour.percent_off}% تخفیف
</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400 font-mono">
نهایی: ${parseFloat(tour.final_price).toLocaleString()}
</span>
</div>
)}
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">کودک (Child 2-12)</span>
<div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price_child || '0').toLocaleString()}
</div>
<span className="text-[10px] text-grey-3">صندلی و خدمات کودک</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">نوزاد (Infant &lt;2)</span>
<div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price_infant || '0').toLocaleString()}
</div>
<span className="text-[10px] text-grey-3">بیمه و ترانسفر نوزاد</span>
</div>
</div>
</div>
</div>
)}
{/* تب ۲: مراحل سفر روزانه (Itinerary) */}
{activeTab === 'itinerary' && (
<div className="space-y-4">
{isLoadingSub ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
) : itineraries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="calendar" className="mx-auto size-8 mb-2 opacity-50" />
<p className="text-xs">برنامه زمانبندی روزانهای برای این تور ثبت نشده است.</p>
</div>
) : (
<div className="space-y-3">
{itineraries.map((step, idx) => (
<div
key={step.id || idx}
className="relative rounded-2xl border border-border-soft bg-card/40 p-4 transition hover:border-primary/40 hover:bg-card/70"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{idx + 1}
</span>
<h5 className="text-xs font-bold text-foreground">{step.title}</h5>
</div>
<div className="text-[11px] text-grey-3 font-mono" dir="ltr">
{step.started_at ? new Date(step.started_at).toLocaleDateString('fa-IR') : '-'}
</div>
</div>
<p className="mt-2 text-xs leading-relaxed text-grey-2 pr-8">
{step.summary}
</p>
</div>
))}
</div>
)}
</div>
)}
{/* تب ۳: مسافران (Passengers) */}
{activeTab === 'passengers' && (
<div className="space-y-4">
{isLoadingSub ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
) : passengersGroup.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="users" className="mx-auto size-8 mb-2 opacity-50" />
<p className="text-xs">هنوز مسافری برای این تور به ثبت نرسیده است.</p>
</div>
) : (
<div className="space-y-4">
{passengersGroup.map((group, gIdx) => (
<div
key={group.user_id || gIdx}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"
>
<div className="flex items-center justify-between border-b border-border-soft pb-2">
<div className="text-xs font-bold text-foreground">
سرپرست رزرو: {group.user}
</div>
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-[11px] font-bold text-primary">
{group.passengers?.length || 0} مسافر
</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{group.passengers?.map((p) => (
<div
key={p.id}
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1"
>
<div className="font-bold text-xs text-foreground">{p.fullname}</div>
<div className="text-[11px] text-grey-3 flex justify-between">
<span>گذرنامه:</span>
<span className="font-mono font-medium text-foreground">{p.passport_number}</span>
</div>
<div className="text-[11px] text-grey-3 flex justify-between">
<span>تولد:</span>
<span className="font-mono">{p.birthdate}</span>
</div>
<div className="text-[11px] text-grey-3 flex justify-between">
<span>تماس:</span>
<span className="font-mono" dir="ltr">{p.phone_number}</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* تب ۴: امکانات و نکات سفر */}
{activeTab === 'features' && (
<div className="space-y-6">
{/* ویژگی‌ها */}
<div className="space-y-3">
<h4 className="text-xs font-extrabold text-foreground">ویژگیها و خدمات گنجاندهشده</h4>
{tour.tour_features && tour.tour_features.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tour.tour_features.map((feat) => (
<div
key={feat.id}
className="inline-flex items-center gap-1.5 rounded-xl border border-border-soft bg-surface-base px-3 py-1.5 text-xs text-foreground shadow-2xs"
>
<Ic name="check" className="size-3.5 text-emerald-500" />
<span>{feat.title}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-grey-3">ویژگی خاصی درج نشده است.</p>
)}
</div>
{/* نکات سفر */}
<div className="space-y-3">
<h4 className="text-xs font-extrabold text-foreground">راهنما و توصیههای سفر</h4>
{tour.travel_tips && tour.travel_tips.length > 0 ? (
<div className="space-y-2.5">
{tour.travel_tips.map((tip) => (
<div
key={tip.id}
className="rounded-xl border border-border-soft bg-surface-base p-3.5 space-y-1"
>
<div className="flex items-center gap-2 font-bold text-xs text-primary">
<Ic name="sparkles" className="size-3.5" />
<span>{tip.title}</span>
</div>
<p className="text-xs leading-relaxed text-grey-2 pr-5">
{tip.description}
</p>
</div>
))}
</div>
) : (
<p className="text-xs text-grey-3">توصیه خاصی ثبت نشده است.</p>
)}
</div>
</div>
)}
{/* تب ۵: نظرات و امتیازات (Comments) */}
{activeTab === 'comments' && (
<div className="space-y-4">
{isLoadingSub ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
) : comments.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="inbox" className="mx-auto size-8 mb-2 opacity-50" />
<p className="text-xs">تاکنون نظری برای این تور ثبت نشده است.</p>
</div>
) : (
<div className="space-y-3">
{comments.map((c) => (
<div
key={c.id}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{c.user_avatar ? (
<img src={c.user_avatar} alt="" className="size-7 rounded-full object-cover" />
) : (
<div className="flex size-7 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{c.user?.[0] || 'U'}
</div>
)}
<span className="text-xs font-bold text-foreground">{c.user}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-xs font-bold text-amber-500 font-mono">{c.score}</span>
<span className="text-[10px] text-grey-3 mr-2">
({c.duration?.num} {c.duration?.type} پیش)
</span>
</div>
</div>
<p className="text-xs text-grey-2 pr-9 leading-relaxed">
{c.text || 'بدون متن'}
</p>
</div>
))}
</div>
)}
</div>
)}
</>
) : null}
</div>
</SheetContent>
<TourTranslationsDialog
tourId={tourId}
tourTitle={tour?.title}
open={isTransDialogOpen}
onOpenChange={setIsTransDialogOpen}
onSuccess={() => {
if (tourId) {
fetchTourDetail(tourId).then(setTour).catch(() => {})
}
}}
/>
</Sheet>
)
}

213
src/features/tours/components/TourStatusBadge.tsx

@ -0,0 +1,213 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
interface TourStatusBadgeProps {
status: string
type?: 'sale' | 'trip' | 'order'
className?: string
}
export function TourStatusBadge({ status, type = 'sale', className }: TourStatusBadgeProps) {
if (type === 'sale') {
switch (status?.toUpperCase()) {
case 'AVAILABLE':
return (
<Badge
variant="outline"
className={cn(
'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-emerald-500" />
در دسترس
</Badge>
)
case 'SOLD_OUT':
return (
<Badge
variant="outline"
className={cn(
'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-rose-500" />
تکمیل ظرفیت
</Badge>
)
case 'NO_SHOW':
return (
<Badge
variant="outline"
className={cn(
'border-grey-3/30 bg-grey-3/10 text-grey-2 font-medium text-[11px] gap-1.5',
className
)}
>
عدم نمایش
</Badge>
)
default:
return (
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}>
{status || 'نامشخص'}
</Badge>
)
}
}
if (type === 'trip') {
switch (status?.toUpperCase()) {
case 'PENDING':
return (
<Badge
variant="outline"
className={cn(
'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-amber-500" />
در انتظار اجرا
</Badge>
)
case 'TRAVELING':
return (
<Badge
variant="outline"
className={cn(
'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium text-[11px] gap-1.5 animate-pulse',
className
)}
>
<span className="size-1.5 rounded-full bg-sky-500" />
در حال برگزاری
</Badge>
)
case 'FINISHED':
return (
<Badge
variant="outline"
className={cn(
'border-slate-500/30 bg-slate-500/10 text-slate-500 dark:text-slate-400 font-medium text-[11px] gap-1.5',
className
)}
>
پایانیافته
</Badge>
)
default:
return (
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}>
{status || 'نامشخص'}
</Badge>
)
}
}
// نوع سفارش (Order Status)
switch (status?.toUpperCase()) {
case 'APPROVED':
return (
<Badge
variant="outline"
className={cn(
'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-emerald-500" />
تایید شده
</Badge>
)
case 'AWAITING_PAYMENT':
return (
<Badge
variant="outline"
className={cn(
'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-amber-500" />
در انتظار پرداخت
</Badge>
)
case 'PENDING':
return (
<Badge
variant="outline"
className={cn(
'border-indigo-500/30 bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-indigo-500" />
در انتظار بررسی فیش
</Badge>
)
case 'REJECTED':
return (
<Badge
variant="outline"
className={cn(
'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-medium text-[11px] gap-1.5',
className
)}
>
<span className="size-1.5 rounded-full bg-rose-500" />
رد شده
</Badge>
)
case 'EXPIRED':
return (
<Badge
variant="outline"
className={cn(
'border-zinc-500/30 bg-zinc-500/10 text-zinc-500 font-medium text-[11px] gap-1.5',
className
)}
>
منقضی شده
</Badge>
)
default:
return (
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}>
{status || 'نامشخص'}
</Badge>
)
}
}
export function CountryFlagBadge({ countryCode }: { countryCode: string | null }) {
if (!countryCode) return <span className="text-grey-3 text-xs">-</span>
const getCountryName = (code: string) => {
switch (code.toUpperCase()) {
case 'IQ':
return { name: 'عراق', flag: '🇮🇶' }
case 'IR':
return { name: 'ایران', flag: '🇮🇷' }
case 'SA':
return { name: 'عربستان', flag: '🇸🇦' }
case 'SY':
return { name: 'سوریه', flag: '🇸🇾' }
case 'OM':
return { name: 'عمان', flag: '🇴🇲' }
default:
return { name: code, flag: '🌐' }
}
}
const { name, flag } = getCountryName(countryCode)
return (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border-soft bg-surface-base px-2 py-0.5 text-xs font-semibold text-foreground shadow-xs">
<span>{flag}</span>
<span>{name}</span>
</span>
)
}

632
src/features/tours/components/TourTranslationsDialog.tsx

@ -0,0 +1,632 @@
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import {
fetchTourTranslations,
saveTourTranslations,
fetchTourItineraryTranslations,
saveTourItineraryTranslation,
} from '../services/tour-api'
import type {
TourTranslationsResponse,
TourTranslationData,
TourItineraryTranslationsResponseItem,
} from '../types'
interface TourTranslationsDialogProps {
tourId: number | null
tourTitle?: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
const SUPPORTED_LANGUAGES = [
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' },
{ code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' },
{ code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' },
{ code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' },
] as const
type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code']
export function TourTranslationsDialog({
tourId,
tourTitle,
open,
onOpenChange,
onSuccess,
}: TourTranslationsDialogProps) {
const [selectedLang, setSelectedLang] = React.useState<LangCode>('ar')
const [activeSection, setActiveSection] = React.useState<'base' | 'features' | 'tips' | 'itinerary'>('base')
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [isSaving, setIsSaving] = React.useState<boolean>(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const [successMessage, setSuccessMessage] = React.useState<string | null>(null)
// Translations data
const [transData, setTransData] = React.useState<TourTranslationsResponse | null>(null)
const [itineraries, setItineraries] = React.useState<TourItineraryTranslationsResponseItem[]>([])
// Local draft states for current selected language
const [formTitle, setFormTitle] = React.useState<string>('')
const [formDesc, setFormDesc] = React.useState<string>('')
const [formFeatures, setFormFeatures] = React.useState<string[]>([])
const [newFeatureText, setNewFeatureText] = React.useState<string>('')
const [formTips, setFormTips] = React.useState<{ title: string; desc: string }[]>([])
const [newTipTitle, setNewTipTitle] = React.useState<string>('')
const [newTipDesc, setNewTipDesc] = React.useState<string>('')
// Drafts for itineraries: { [itineraryId]: { title: string, summary: string } }
const [itinDrafts, setItinDrafts] = React.useState<Record<number, { title: string; summary: string }>>({})
// Load translations on open
React.useEffect(() => {
if (!open || !tourId) return
let isMounted = true
setIsLoading(true)
setErrorMessage(null)
setSuccessMessage(null)
Promise.all([
fetchTourTranslations(tourId),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
.then(([tourTrans, itinTrans]) => {
if (!isMounted) return
setTransData(tourTrans)
setItineraries(itinTrans || [])
// Default to a language different from primary if primary is English/Persian
const defaultLang = tourTrans.primary_language === 'fa' ? 'ar' : 'fa'
setSelectedLang(defaultLang as LangCode)
initDraftForLanguage(defaultLang as LangCode, tourTrans, itinTrans || [])
})
.catch((err) => {
if (!isMounted) return
setErrorMessage(err?.message || 'خطا در بارگذاری ترجمه‌های تور از سرور.')
})
.finally(() => {
if (isMounted) setIsLoading(false)
})
return () => {
isMounted = false
}
}, [open, tourId])
// Sync draft whenever selectedLang changes
const initDraftForLanguage = (
lang: LangCode,
tourTrans: TourTranslationsResponse | null,
itinList: TourItineraryTranslationsResponseItem[]
) => {
if (!tourTrans) return
const isPrimary = tourTrans.primary_language === lang
const existingTour = isPrimary
? {
title: tourTrans.primary_data.title || '',
description: tourTrans.primary_data.description || '',
tour_feature: tourTrans.primary_data.tour_feature || [],
travel_tips: tourTrans.primary_data.travel_tips || {},
}
: tourTrans.translations[lang] || {
title: '',
description: '',
tour_feature: [],
travel_tips: {},
}
setFormTitle(existingTour.title || '')
setFormDesc(existingTour.description || '')
const featuresList = (existingTour.tour_feature || []).map((f) => f.title)
setFormFeatures(featuresList)
const tipsList = Object.entries(existingTour.travel_tips || {}).map(([title, desc]) => ({
title,
desc: String(desc),
}))
setFormTips(tipsList)
// Init itinerary drafts for this language
const drafts: Record<number, { title: string; summary: string }> = {}
itinList.forEach((it) => {
const itTrans = it.translations[lang]
drafts[it.id] = {
title: itTrans?.title || '',
summary: itTrans?.summary || '',
}
})
setItinDrafts(drafts)
}
const handleLanguageChange = (lang: LangCode) => {
setSelectedLang(lang)
setErrorMessage(null)
setSuccessMessage(null)
initDraftForLanguage(lang, transData, itineraries)
}
const handleAddFeature = () => {
if (!newFeatureText.trim()) return
setFormFeatures((prev) => [...prev, newFeatureText.trim()])
setNewFeatureText('')
}
const handleRemoveFeature = (idx: number) => {
setFormFeatures((prev) => prev.filter((_, i) => i !== idx))
}
const handleAddTip = () => {
if (!newTipTitle.trim() || !newTipDesc.trim()) return
setFormTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }])
setNewTipTitle('')
setNewTipDesc('')
}
const handleRemoveTip = (idx: number) => {
setFormTips((prev) => prev.filter((_, i) => i !== idx))
}
const handleItineraryDraftChange = (itId: number, field: 'title' | 'summary', val: string) => {
setItinDrafts((prev) => ({
...prev,
[itId]: {
title: prev[itId]?.title || '',
summary: prev[itId]?.summary || '',
[field]: val,
},
}))
}
const handleSave = async () => {
if (!tourId || !transData) return
setIsSaving(true)
setErrorMessage(null)
setSuccessMessage(null)
try {
// Build tour_feature array
const tour_feature = formFeatures.map((title) => ({ title }))
// Build travel_tips object
const travel_tips: Record<string, string> = {}
formTips.forEach((item) => {
travel_tips[item.title] = item.desc
})
// 1. Save Tour translation
await saveTourTranslations(tourId, {
lang_code: selectedLang,
title: formTitle.trim(),
description: formDesc.trim(),
tour_feature,
travel_tips,
})
// 2. Save Itinerary translations
const itinSavePromises = Object.entries(itinDrafts).map(([itIdStr, draft]) => {
const itId = Number(itIdStr)
if (!draft.title.trim() && !draft.summary.trim()) return Promise.resolve()
return saveTourItineraryTranslation(tourId, {
itinerary_id: itId,
lang_code: selectedLang,
title: draft.title.trim(),
summary: draft.summary.trim(),
})
})
await Promise.all(itinSavePromises)
// Refresh data
const [updatedTour, updatedItin] = await Promise.all([
fetchTourTranslations(tourId),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
setTransData(updatedTour)
setItineraries(updatedItin || [])
setSuccessMessage(`ترجمه زبان ${SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang)?.label} با موفقیت در دیتابیس ذخیره شد.`)
if (onSuccess) onSuccess()
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در ذخیره ترجمه در سرور.')
} finally {
setIsSaving(false)
}
}
const activeLangConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang)
const isPrimary = transData?.primary_language === selectedLang
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[760px] p-0 overflow-hidden max-h-[90vh] flex flex-col">
{/* Dialog Header */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="globe" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
مدیریت ترجمههای ۵ زبانه تور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
{tourTitle || `تور شناسه #${tourId}`} &bull; ذخیرهسازی پویا در سرور و پایگاهداده
</DialogDescription>
</div>
</div>
{/* Primary Language Indicator */}
{transData && (
<div className="flex items-center gap-1.5 rounded-full bg-surface-2 px-3 py-1 text-xs text-grey-2 border border-border/40">
<span className="text-grey-3">زبان پایه دیتابیس:</span>
<span className="font-bold text-foreground">
{SUPPORTED_LANGUAGES.find((l) => l.code === transData.primary_language)?.label ||
transData.primary_language}
</span>
</div>
)}
</div>
{/* 5-Language Selector Bar */}
<div className="flex items-center gap-1.5 mt-4 overflow-x-auto pb-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedLang
const hasTrans = Boolean(
transData?.primary_language === lang.code ||
transData?.translations[lang.code]?.title
)
return (
<button
key={lang.code}
type="button"
onClick={() => handleLanguageChange(lang.code)}
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
isSelected
? 'bg-primary text-white shadow-md'
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30'
}`}
>
<span className="text-sm">{lang.flag}</span>
<span>{lang.label}</span>
{hasTrans && !isSelected && (
<span className="size-1.5 rounded-full bg-emerald-500" />
)}
{transData?.primary_language === lang.code && (
<span className="text-[10px] opacity-80">(اصلی)</span>
)}
</button>
)
})}
</div>
</DialogHeader>
{/* Dialog Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 text-grey-3">
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent mb-3" />
<p className="text-xs">در حال بارگذاری ترجمهها از Backend...</p>
</div>
) : (
<>
{errorMessage && (
<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-400">
<Ic name="alert" className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
{successMessage && (
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-xs text-emerald-400">
<Ic name="check" className="size-4 shrink-0" />
<span>{successMessage}</span>
</div>
)}
{/* Section Tabs */}
<div className="flex items-center gap-2 border-b border-border/40 pb-2">
<button
type="button"
onClick={() => setActiveSection('base')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'base'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
اطلاعات کلی (عنوان و شرح)
</button>
<button
type="button"
onClick={() => setActiveSection('features')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'features'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
ویژگیهای تور ({formFeatures.length})
</button>
<button
type="button"
onClick={() => setActiveSection('tips')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'tips'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
نکات و راهنمای سفر ({formTips.length})
</button>
<button
type="button"
onClick={() => setActiveSection('itinerary')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'itinerary'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
مراحل سفر ({itineraries.length})
</button>
</div>
{/* Active Section Form */}
<div dir={activeLangConfig?.dir || 'rtl'} className="space-y-4">
{/* 1. Base Info */}
{activeSection === 'base' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-grey-2 mb-1.5">
عنوان تور به زبان {activeLangConfig?.label} ({selectedLang}):
</label>
<Input
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder={`عنوان تور به زبان ${activeLangConfig?.label}...`}
className="bg-surface-2 border-border/50 text-sm"
disabled={isPrimary}
/>
{isPrimary && (
<p className="text-[11px] text-amber-400/80 mt-1">
* این زبان، زبان اصلی تور است و از فرم اصلی مدیریت میشود.
</p>
)}
</div>
<div>
<label className="block text-xs font-bold text-grey-2 mb-1.5">
شرح و توضیحات تور:
</label>
<textarea
rows={4}
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder={`توضیحات کامل تور به زبان ${activeLangConfig?.label}...`}
disabled={isPrimary}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60"
/>
</div>
</div>
)}
{/* 2. Tour Features */}
{activeSection === 'features' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
value={newFeatureText}
onChange={(e) => setNewFeatureText(e.target.value)}
placeholder={`افزودن ویژگی جدید به ${activeLangConfig?.label}...`}
className="bg-surface-2 border-border/50 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddFeature()
}
}}
/>
<Button
type="button"
size="sm"
onClick={handleAddFeature}
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white"
>
<Ic name="plus" className="size-4" />
<span>افزودن</span>
</Button>
</div>
<div className="space-y-2 mt-3">
{formFeatures.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-6">
هنوز ویژگی برای این زبان اضافه نشده است.
</p>
) : (
formFeatures.map((feat, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-xl bg-surface-2 border border-border/40 px-3.5 py-2 text-xs"
>
<span className="font-medium text-foreground">{feat}</span>
<button
type="button"
onClick={() => handleRemoveFeature(idx)}
className="text-grey-4 hover:text-rose-400 p-1"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
))
)}
</div>
</div>
)}
{/* 3. Travel Tips */}
{activeSection === 'tips' && (
<div className="space-y-3">
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-3 space-y-2">
<Input
value={newTipTitle}
onChange={(e) => setNewTipTitle(e.target.value)}
placeholder="عنوان نکته (مثلاً: لباس مناسب / Walking Shoes)..."
className="bg-surface-2 text-xs"
/>
<textarea
rows={2}
value={newTipDesc}
onChange={(e) => setNewTipDesc(e.target.value)}
placeholder="توضیح نکته سفر..."
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
/>
<Button
type="button"
size="sm"
onClick={handleAddTip}
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن نکته سفر</span>
</Button>
</div>
<div className="space-y-2 mt-3">
{formTips.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-6">
هنوز نکته سفری برای این زبان ثبت نشده است.
</p>
) : (
formTips.map((tip, idx) => (
<div
key={idx}
className="rounded-xl bg-surface-2 border border-border/40 p-3 text-xs space-y-1 relative"
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground">{tip.title}</span>
<button
type="button"
onClick={() => handleRemoveTip(idx)}
className="text-grey-4 hover:text-rose-400 p-0.5"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
<p className="text-grey-3 leading-relaxed">{tip.desc}</p>
</div>
))
)}
</div>
</div>
)}
{/* 4. Itinerary Steps Translations */}
{activeSection === 'itinerary' && (
<div className="space-y-4">
{itineraries.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-8">
هیچ مرحله سفری برای این تور ثبت نشده است.
</p>
) : (
itineraries.map((it, idx) => {
const draft = itinDrafts[it.id] || { title: '', summary: '' }
return (
<div
key={it.id}
className="rounded-xl bg-surface-2 border border-border/40 p-3.5 space-y-2.5"
>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary/20 text-primary text-[10px] font-bold">
{idx + 1}
</span>
<span className="font-bold text-foreground">
عنوان اصلی: {it.title}
</span>
</div>
<span className="text-[10px] text-grey-3">شناسه #{it.id}</span>
</div>
<Input
value={draft.title}
onChange={(e) =>
handleItineraryDraftChange(it.id, 'title', e.target.value)
}
placeholder={`عنوان مرحله به ${activeLangConfig?.label}...`}
className="bg-surface-3 border-border/50 text-xs"
/>
<textarea
rows={2}
value={draft.summary}
onChange={(e) =>
handleItineraryDraftChange(it.id, 'summary', e.target.value)
}
placeholder={`شرح مرحله به ${activeLangConfig?.label}...`}
className="w-full rounded-xl border border-border/50 bg-surface-3 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
/>
</div>
)
})
)}
</div>
)}
</div>
</>
)}
</div>
{/* Dialog Footer */}
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between">
<div className="text-xs text-grey-3">
زبان در حال ویرایش: <span className="font-bold text-primary">{activeLangConfig?.label}</span>
</div>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl">
بستن
</Button>
</DialogClose>
<Button
type="button"
size="sm"
disabled={isSaving || isPrimary || isLoading}
onClick={handleSave}
className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm"
>
{isSaving ? (
<>
<div className="size-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>در حال ذخیره...</span>
</>
) : (
<>
<Ic name="check" className="size-3.5" />
<span>ذخیره ترجمه {activeLangConfig?.label}</span>
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

145
src/features/tours/services/tour-api.ts

@ -0,0 +1,145 @@
import { apiFetch } from '@/services/http'
import type {
DrfPaginatedResponse,
TourListItem,
TourDetailItem,
TourItineraryItem,
TourPassengerGroupItem,
TourCommentItem,
TourOrderItem,
} from '../types'
/**
* دریافت فهرست تورها با قابلیت صفحهبندی و فیلتر زبان
*/
export async function fetchTours(params?: {
limit?: number
offset?: number
language_code?: string
}): Promise<DrfPaginatedResponse<TourListItem>> {
const query = new URLSearchParams()
if (params?.limit !== undefined) query.set('limit', String(params.limit))
if (params?.offset !== undefined) query.set('offset', String(params.offset))
if (params?.language_code) query.set('language_code', params.language_code)
const queryString = query.toString()
const path = queryString ? `tours/?${queryString}` : 'tours/'
return apiFetch<DrfPaginatedResponse<TourListItem>>(path)
}
/**
* دریافت جزئیات جامع یک تور با شناسه یکتا
*/
export async function fetchTourDetail(tourId: number): Promise<TourDetailItem> {
return apiFetch<TourDetailItem>(`tours/${tourId}/`)
}
/**
* دریافت مراحل و برنامه زمانبندی سفر یک تور
*/
export async function fetchTourItineraries(tourId: number): Promise<DrfPaginatedResponse<TourItineraryItem>> {
return apiFetch<DrfPaginatedResponse<TourItineraryItem>>(`tours/${tourId}/itinerary/`)
}
/**
* دریافت مسافران ثبتنامشده برای تور
*/
export async function fetchTourPassengers(tourId: number): Promise<DrfPaginatedResponse<TourPassengerGroupItem>> {
return apiFetch<DrfPaginatedResponse<TourPassengerGroupItem>>(`tours/${tourId}/passengers/`)
}
/**
* دریافت نظرات و امتیازات کاربران برای یک تور از طریق اسلاگ
*/
export async function fetchTourComments(tourSlug: string): Promise<DrfPaginatedResponse<TourCommentItem>> {
return apiFetch<DrfPaginatedResponse<TourCommentItem>>(`tours/${tourSlug}/comments/`)
}
/**
* دریافت سفارشات و رزروهای تور ویژه مدیران پنل
*/
export async function fetchTourOrders(params?: {
status?: string
search?: string
tour_id?: number
limit?: number
offset?: number
}): Promise<DrfPaginatedResponse<TourOrderItem>> {
const query = new URLSearchParams()
query.set('view', 'admin')
if (params?.status) query.set('status', params.status)
if (params?.search) query.set('search', params.search)
if (params?.tour_id) query.set('tour_id', String(params.tour_id))
if (params?.limit !== undefined) query.set('limit', String(params.limit))
if (params?.offset !== undefined) query.set('offset', String(params.offset))
return apiFetch<DrfPaginatedResponse<TourOrderItem>>(`tours/orders/?${query.toString()}`)
}
/**
* بهروزرسانی وضعیت سفارش تور و تایید فیش واریز
*/
export async function updateTourOrder(
orderId: number,
data: { status?: string; receipt_verified?: boolean }
): Promise<TourOrderItem> {
return apiFetch<TourOrderItem>(`tours/orders/${orderId}/`, {
method: 'PATCH',
body: data,
})
}
/**
* دریافت ترجمههای ۵ زبانه یک تور
*/
export async function fetchTourTranslations(tourId: number): Promise<import('../types').TourTranslationsResponse> {
return apiFetch<import('../types').TourTranslationsResponse>(`tours/${tourId}/translations/`)
}
/**
* ذخیره یا ویرایش ترجمه تور برای یک زبان یا چندین زبان
*/
export async function saveTourTranslations(
tourId: number,
payload: {
lang_code?: string
title?: string
description?: string
tour_feature?: { title: string }[]
travel_tips?: Record<string, string>
translations?: Record<string, import('../types').TourTranslationData>
}
): Promise<{ status: string; message: string }> {
return apiFetch<{ status: string; message: string }>(`tours/${tourId}/translations/`, {
method: 'POST',
body: payload,
})
}
/**
* دریافت ترجمههای ۵ زبانه مراحل سفر (Itinerary)
*/
export async function fetchTourItineraryTranslations(
tourId: number
): Promise<import('../types').TourItineraryTranslationsResponseItem[]> {
return apiFetch<import('../types').TourItineraryTranslationsResponseItem[]>(`tours/${tourId}/itinerary_translations/`)
}
/**
* ذخیره ترجمه یک مرحله سفر برای یک زبان خاص
*/
export async function saveTourItineraryTranslation(
tourId: number,
data: {
itinerary_id: number
lang_code: string
title: string
summary: string
}
): Promise<{ status: string; message: string }> {
return apiFetch<{ status: string; message: string }>(`tours/${tourId}/itinerary_translations/`, {
method: 'POST',
body: data,
})
}

185
src/features/tours/types.ts

@ -0,0 +1,185 @@
/*
* تایپهای جامع ماژول مدیریت تورها (Tours Management Types)
* منطبق با ساختار واقعی مدلها و وبسرویسهای Django REST Framework در بکاند عقیله
* */
export interface TourImageThumbnails {
original?: string
large?: string
medium?: string
small?: string
icon?: string
}
export interface TourImageItem {
id: number
image_url?: TourImageThumbnails | null
}
export type TourSaleStatus = 'AVAILABLE' | 'SOLD_OUT' | 'NO_SHOW' | string
export type TourTripStatus = 'PENDING' | 'TRAVELING' | 'FINISHED' | string
export interface TourListItem {
id: number
title: string
slug: string
started_at: string
ended_at: string
status: TourSaleStatus
trip_status: TourTripStatus
price: string
price_child: string
price_infant: string
image: TourImageItem | null
destination_country: string | null
is_access: boolean
}
export interface TourFeatureItem {
id: number
title: string
}
export interface TravelTipItem {
id: number
title: string
description: string
}
export interface TourDetailItem {
id: number
title: string
slug: string
description: string
started_at: string
ended_at: string
capacity: number
number_sold: number
tour_features: TourFeatureItem[]
travel_tips: TravelTipItem[]
status: TourSaleStatus
price: string
price_child: string
price_infant: string
percent_off: number
final_price: string
images: TourImageItem[]
trip_status: TourTripStatus
is_access: boolean
}
export interface TourItineraryItem {
id: number
title: string
summary: string
started_at: string
ended_at?: string | null
status?: string | number | null
images?: TourImageItem[]
}
export interface PassengerItem {
id: number
fullname: string
passport_number: string
birthdate: string
phone_number: string
passport_image?: string | null
}
export interface TourPassengerGroupItem {
tour: string
tour_id: number
tour_slug: string
user: string
user_id: number
passengers: PassengerItem[]
}
export interface TourCommentDuration {
num: number
type: 'minute' | 'hour' | 'day' | 'month' | 'year' | string
}
export interface TourCommentItem {
id: number
user: string
text?: string | null
score: number
duration: TourCommentDuration
user_avatar?: string | null
}
export type TourOrderStatus =
| 'AWAITING_PAYMENT'
| 'PENDING'
| 'APPROVED'
| 'REJECTED'
| 'EXPIRED'
| string
export interface TourOrderItem {
id: number
tour_id?: number
user: string
tour_slug: string
tour_title: string
price: string
total_price: string
status: TourOrderStatus
created: string
updated: string
request_details?: string | null
is_paid: boolean
payment_method?: string | null
transaction_id?: string | null
payment_receipt?: string | null
receipt_verified: boolean
}
export interface DrfPaginatedResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}
export interface TourTranslationData {
id?: number
title: string
description?: string
tour_feature?: { title: string }[]
travel_tips?: Record<string, string>
}
export interface TourTranslationsResponse {
primary_language: string
primary_data: {
title: string
description: string
tour_feature: { title: string }[]
travel_tips: Record<string, string>
}
translations: Record<string, TourTranslationData>
}
export interface ItineraryTranslationData {
id?: number
title: string
summary?: string
}
export interface TourItineraryTranslationsResponseItem {
id: number
title: string
summary: string
translations: Record<string, ItineraryTranslationData>
}
export interface TourFilterOptions {
search: string
status: string
tripStatus: string
country: string
sortBy: 'id' | 'price_asc' | 'price_desc' | 'capacity_desc' | 'started_at'
}

1
src/icons.tsx

@ -6,6 +6,7 @@ const PATHS: Record<string, string> = {
mic2: '<path d="M12 1a4 4 0 0 0-4 4v6a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4Z"/><path d="M19 11a7 7 0 0 1-14 0"/><line x1="12" y1="18" x2="12" y2="22"/>',
radio: '<circle cx="12" cy="12" r="2"/><path d="M16.2 7.8a6 6 0 0 1 0 8.4"/><path d="M7.8 16.2a6 6 0 0 1 0-8.4"/><path d="M19.1 4.9a10 10 0 0 1 0 14.2"/><path d="M4.9 19.1a10 10 0 0 1 0-14.2"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
map: '<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" x2="9" y1="3" y2="18"/><line x1="15" x2="15" y1="6" y2="21"/>',
heart: '<path d="M19 14c1.5-1.5 3-3.4 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3.4 1-4.5 2.5C10.9 4 9.3 3 7.5 3A5.5 5.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7Z"/>',
search: '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/>',
mic: '<rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="21"/>',

Loading…
Cancel
Save