Browse Source
feat: implement tours management feature with pages, components, API services, and routing
master
feat: implement tours management feature with pages, components, API services, and routing
master
12 changed files with 2981 additions and 25 deletions
-
5src/app/router.tsx
-
105src/components/layout/AppSidebar.tsx
-
75src/components/ui/card.tsx
-
16src/config/navigation.ts
-
548src/features/tours/TourOrdersPage.tsx
-
556src/features/tours/ToursPage.tsx
-
525src/features/tours/components/TourDetailSheet.tsx
-
213src/features/tours/components/TourStatusBadge.tsx
-
632src/features/tours/components/TourTranslationsDialog.tsx
-
145src/features/tours/services/tour-api.ts
-
185src/features/tours/types.ts
-
1src/icons.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 } |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
|
|||
@ -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 <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> |
|||
) |
|||
} |
|||
|
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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}`} • ذخیرهسازی پویا در سرور و پایگاهداده |
|||
</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> |
|||
) |
|||
} |
|||
@ -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, |
|||
}) |
|||
} |
|||
|
|||
@ -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' |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue