Browse Source

feat: implement tours and billing features with supporting pages, components, and services

master
Muhammad A. Ghorbani 3 weeks ago
parent
commit
4eac2a6f8c
  1. 87
      src/components/shared/Shimmer.tsx
  2. 2
      src/components/shared/TablePagination.tsx
  3. 331
      src/components/shared/UniversalDatePicker.tsx
  4. 17
      src/components/ui/dialog.tsx
  5. 133
      src/features/billing/BillingPage.tsx
  6. 5
      src/features/billing/components/BillingDetailModal.tsx
  7. 26
      src/features/tours/OngoingToursPage.tsx
  8. 5
      src/features/tours/TourOrdersPage.tsx
  9. 182
      src/features/tours/ToursPage.tsx
  10. 957
      src/features/tours/components/CreateTourDialog.tsx
  11. 948
      src/features/tours/components/TourDetailDialog.tsx
  12. 26
      src/features/tours/components/TourDetailSheet.tsx
  13. 3
      src/features/tours/components/TourStatusBadge.tsx
  14. 37
      src/features/tours/components/TourTranslationsDialog.tsx
  15. 31
      src/features/tours/services/tour-api.ts
  16. 1
      src/features/tours/types.ts
  17. 4
      src/icons.tsx
  18. 2
      src/index.css
  19. 486
      src/lib/date-utils.ts
  20. 42
      src/lib/jalali.ts
  21. 7
      src/services/http.ts

87
src/components/shared/Shimmer.tsx

@ -21,15 +21,15 @@ export interface ShimmerProps {
cardHeaderIcon?: IconName cardHeaderIcon?: IconName
/** متن سمت چپ هدر کارت */ /** متن سمت چپ هدر کارت */
cardHeaderSub?: React.ReactNode cardHeaderSub?: React.ReactNode
/** نوع نمایش: ردیفی (پیش‌فرض) یا کارت‌های گرید */
variant?: 'list' | 'grid' | 'kpi'
/** نوع نمایش: ردیفی/جدولی (پیش‌فرض) یا کارت‌های گرید یا KPI */
variant?: 'list' | 'table' | 'grid' | 'kpi'
/** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */ /** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */
gridCols?: number gridCols?: number
} }
/** /**
* کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله * کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله
* الگوی اصلی برگرفته از صفحه صورت‌حساب‌ها (Billing)
* دارای پالت خنثی با کنتراست استاندارد و انیمیشن روان پالس (در هر دو تم دارک و لایت)
*/ */
export function Shimmer({ export function Shimmer({
rows, rows,
@ -41,7 +41,7 @@ export function Shimmer({
cardHeaderTitle, cardHeaderTitle,
cardHeaderIcon = 'list', cardHeaderIcon = 'list',
cardHeaderSub, cardHeaderSub,
variant = 'list',
variant = 'table',
gridCols = 3, gridCols = 3,
}: ShimmerProps) { }: ShimmerProps) {
const itemCount = rows !== undefined ? rows : count const itemCount = rows !== undefined ? rows : count
@ -57,12 +57,12 @@ export function Shimmer({
} }
content = ( content = (
<div className={cn('grid gap-4 p-6 sm:p-8', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}>
<div className={cn('grid gap-4 p-4 sm:p-6', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => ( {Array.from({ length: itemCount }).map((_, i) => (
<div <div
key={i} key={i}
className={cn( className={cn(
'h-36 rounded-2xl bg-muted/30 animate-pulse border border-border-soft/40',
'h-36 rounded-2xl bg-slate-200/90 dark:bg-white/10 animate-pulse border border-border-soft',
itemClassName itemClassName
)} )}
/> />
@ -71,28 +71,85 @@ export function Shimmer({
) )
} else if (variant === 'kpi') { } else if (variant === 'kpi') {
content = ( content = (
<div className={cn('grid grid-cols-2 gap-3.5 sm:grid-cols-4', className)}>
<div className={cn('grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5', className)}>
{Array.from({ length: itemCount }).map((_, i) => ( {Array.from({ length: itemCount }).map((_, i) => (
<div <div
key={i} key={i}
className={cn( className={cn(
'h-24 rounded-2xl bg-muted/30 animate-pulse border border-border-soft/40',
'h-20 rounded-2xl bg-slate-200/90 dark:bg-white/10 animate-pulse border border-border-soft p-4 flex items-center justify-between',
itemClassName itemClassName
)} )}
/>
>
<div className="space-y-2">
<div className="h-3 w-16 rounded bg-slate-300 dark:bg-white/15" />
<div className="h-5 w-10 rounded bg-slate-300 dark:bg-white/15" />
</div>
<div className="size-9 rounded-xl bg-slate-300/80 dark:bg-white/15" />
</div>
))}
</div>
)
} else if (variant === 'table') {
// رندرر شبیه جدول با ستون‌های شکیل و تفکیک‌شده
content = (
<div className={cn('divide-y divide-border-soft', className)}>
{/* ردیف هدر جدول */}
<div className="p-4 bg-muted/40 flex items-center justify-between gap-4">
<div className="h-4 w-12 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-32 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-20 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-24 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-20 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-16 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
</div>
{/* سطرهای شیمر جدول */}
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'p-4 flex items-center justify-between gap-4 hover:bg-muted/10 transition-colors',
itemClassName
)}
>
{/* شناسه */}
<div className="h-4 w-10 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* کاربر (آواتار + نام) */}
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[200px]">
<div className="size-8 rounded-full bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
<div className="space-y-1.5 flex-1">
<div className="h-3.5 w-24 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse" />
<div className="h-2.5 w-16 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse" />
</div>
</div>
{/* بج سرویس */}
<div className="h-6 w-16 rounded-lg bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* مبلغ */}
<div className="h-4 w-20 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* بج وضعیت */}
<div className="h-6 w-24 rounded-full bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* تاریخ و اکشن */}
<div className="h-3.5 w-20 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
<div className="h-8 w-16 rounded-xl bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
</div>
))} ))}
</div> </div>
) )
} else { } else {
// ردیفی استاندارد (مطابق ۱۰۰٪ با صورت‌حساب‌ها)
// ردیفی ساده
content = ( content = (
<div className={cn('p-8 space-y-3', className)}>
<div className={cn('p-6 space-y-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => ( {Array.from({ length: itemCount }).map((_, i) => (
<div <div
key={i} key={i}
className={cn( className={cn(
rowHeight, rowHeight,
'bg-muted/30 animate-pulse rounded-lg transition-all duration-300',
'bg-slate-200/90 dark:bg-white/10 animate-pulse rounded-xl transition-all duration-300 border border-border-soft/40',
itemClassName itemClassName
)} )}
/> />
@ -103,11 +160,11 @@ export function Shimmer({
if (withCard) { if (withCard) {
return ( return (
<Card className="border-border/50 shadow-xs overflow-hidden">
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden">
{(cardHeaderTitle || cardHeaderSub) && ( {(cardHeaderTitle || cardHeaderSub) && (
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between">
{cardHeaderTitle && ( {cardHeaderTitle && (
<CardTitle className="text-xs font-bold flex items-center gap-2">
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<Ic name={cardHeaderIcon} className="size-4 text-primary" /> <Ic name={cardHeaderIcon} className="size-4 text-primary" />
<span>{cardHeaderTitle}</span> <span>{cardHeaderTitle}</span>
</CardTitle> </CardTitle>

2
src/components/shared/TablePagination.tsx

@ -43,7 +43,7 @@ function pageWindow(page: number, totalPages: number): number[] {
export default function TablePagination({ export default function TablePagination({
page, page,
currentPage, currentPage,
pageSize = 10,
pageSize = 20,
total, total,
totalPages: propTotalPages, totalPages: propTotalPages,
onPageChange, onPageChange,

331
src/components/shared/UniversalDatePicker.tsx

@ -0,0 +1,331 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { Ic } from '@/icons'
import { Button } from '@/components/ui/button'
import { useTranslation } from '@/i18n'
import {
parseSafeDate,
formatDateByLanguage,
gregorianToJalali,
jalaliToGregorian,
gregorianToHijri,
hijriToGregorian,
getCalendarMonthData,
JALALI_MONTH_NAMES,
HIJRI_MONTH_NAMES,
GREGORIAN_MONTH_NAMES,
} from '@/lib/date-utils'
import { cn } from '@/lib/utils'
export interface UniversalDatePickerProps {
value?: string | null
onChange: (isoDate: string) => void
placeholder?: string
disabled?: boolean
label?: string
className?: string
minDate?: string
maxDate?: string
required?: boolean
size?: 'sm' | 'default' | 'lg'
clearable?: boolean
}
export function UniversalDatePicker({
value,
onChange,
placeholder,
disabled = false,
label,
className,
minDate,
maxDate,
required = false,
size = 'default',
clearable = true,
}: UniversalDatePickerProps) {
const { language, dir } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// تعیین نوع تقویم بر اساس زبان فعال
const calendarType: 'persian' | 'islamic' | 'gregorian' =
language === 'fa' ? 'persian' : language === 'ar' ? 'islamic' : 'gregorian'
// مقدار فعلی به صورت Date
const selectedDate = useMemo(() => parseSafeDate(value), [value])
// وضعیت ماه و سال در حال مشاهده در تقویم
const [viewYear, setViewYear] = useState<number>(() => {
const d = selectedDate || new Date()
if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).year
if (calendarType === 'islamic') return gregorianToHijri(d).year
return d.getFullYear()
})
const [viewMonth, setViewMonth] = useState<number>(() => {
const d = selectedDate || new Date()
if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).month
if (calendarType === 'islamic') return gregorianToHijri(d).month
return d.getMonth() + 1
})
// وقتی مقدار تغییر کند یا باز شود، تقویم روی تاریخ انتخابی یا امروز تنظیم شود
useEffect(() => {
if (isOpen) {
const d = selectedDate || new Date()
if (calendarType === 'persian') {
const j = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate())
setViewYear(j.year)
setViewMonth(j.month)
} else if (calendarType === 'islamic') {
const h = gregorianToHijri(d)
setViewYear(h.year)
setViewMonth(h.month)
} else {
setViewYear(d.getFullYear())
setViewMonth(d.getMonth() + 1)
}
}
}, [isOpen, selectedDate, calendarType])
// بستن پاپ‌اور هنگام کلیک به بیرون یا فشردن Escape
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen])
// رفتن به ماه قبل
const handlePrevMonth = (e: React.MouseEvent) => {
e.stopPropagation()
if (viewMonth === 1) {
setViewMonth(12)
setViewYear((y) => y - 1)
} else {
setViewMonth((m) => m - 1)
}
}
// رفتن به ماه بعد
const handleNextMonth = (e: React.MouseEvent) => {
e.stopPropagation()
if (viewMonth === 12) {
setViewMonth(1)
setViewYear((y) => y + 1)
} else {
setViewMonth((m) => m + 1)
}
}
// داده‌های ماه و ماتریس روزها
const monthData = useMemo(() => {
return getCalendarMonthData(calendarType, viewYear, viewMonth)
}, [calendarType, viewYear, viewMonth])
// بررسی وضعیت امروز و انتخاب
const todayIso = useMemo(() => {
const n = new Date()
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`
}, [])
const selectedIso = useMemo(() => {
if (!selectedDate) return ''
return `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
}, [selectedDate])
// انتخاب یک روز
const handleSelectDay = (dayNumber: number) => {
const isoStr = monthData.toIso(dayNumber)
onChange(isoStr)
setIsOpen(false)
}
// انتخاب تاریخ امروز
const handleSelectToday = () => {
onChange(todayIso)
setIsOpen(false)
}
// پاک کردن تاریخ
const handleClear = (e?: React.MouseEvent) => {
if (e) e.stopPropagation()
onChange('')
setIsOpen(false)
}
const formattedDisplayValue = value ? formatDateByLanguage(value, language, { style: 'medium' }) : ''
const defaultPlaceholder =
language === 'fa' ? 'انتخاب تاریخ...' : language === 'ar' ? 'اختر التاريخ...' : 'Select date...'
return (
<div className={cn('relative inline-flex flex-col', className)} ref={containerRef}>
{label && (
<label className="mb-1.5 text-xs font-bold text-foreground">
{label} {required && <span className="text-rose-500">*</span>}
</label>
)}
{/* دکمه اینپوت تریگر انتخاب تاریخ */}
<div
onClick={() => !disabled && setIsOpen((prev) => !prev)}
className={cn(
'flex items-center justify-between gap-2 rounded-xl border border-border-soft bg-card/60 px-3 text-xs transition cursor-pointer select-none',
size === 'sm' && 'h-8 text-[11px]',
size === 'default' && 'h-9',
size === 'lg' && 'h-11 text-sm',
disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
isOpen ? 'border-primary ring-1 ring-primary/40' : 'hover:border-primary/40',
!formattedDisplayValue && 'text-muted-foreground'
)}
>
<div className="flex items-center gap-2 truncate min-w-0">
<Ic name="calendar" className="size-4 shrink-0 text-primary" />
<span className="truncate font-medium">
{formattedDisplayValue || placeholder || defaultPlaceholder}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{clearable && Boolean(value) && !disabled && (
<button
type="button"
onClick={handleClear}
className="size-5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40 flex items-center justify-center transition"
title={language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'}
>
<Ic name="x" className="size-3.5" />
</button>
)}
<Ic name="chevronDown" className={cn('size-3.5 text-muted-foreground transition-transform', isOpen && 'rotate-180')} />
</div>
</div>
{/* پاپ‌اور تقویم ماهانه */}
{isOpen && (
<div
dir={dir}
className="absolute start-0 top-full z-50 mt-1.5 w-[280px] sm:w-[300px] rounded-2xl border border-border-soft bg-[#1e2128] p-3 shadow-xl backdrop-blur-md anim-pop"
>
{/* هدر کنترل ماه و سال */}
<div className="flex items-center justify-between gap-1 pb-2.5 border-b border-border-soft">
<Button
type="button"
variant="ghost"
size="sm"
onClick={dir === 'rtl' ? handleNextMonth : handlePrevMonth}
className="size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-card"
>
<Ic name={dir === 'rtl' ? 'chevronRight' : 'chevronLeft'} className="size-4" />
</Button>
<div className="flex items-center gap-1 font-bold text-xs text-foreground">
<span>{monthData.monthName}</span>
<span className="font-mono">{monthData.yearFormatted}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={dir === 'rtl' ? handlePrevMonth : handleNextMonth}
className="size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-card"
>
<Ic name={dir === 'rtl' ? 'chevronLeft' : 'chevronRight'} className="size-4" />
</Button>
</div>
{/* ردیف روزهای هفته */}
<div className="grid grid-cols-7 gap-1 pt-2 text-center text-[10px] font-bold text-muted-foreground">
{monthData.weekdays.map((w, idx) => (
<div key={idx} className="py-1">
{w}
</div>
))}
</div>
{/* ماتریس روزهای ماه */}
<div className="grid grid-cols-7 gap-1 pt-1">
{/* خانه‌های خالی ابتدای ماه */}
{Array.from({ length: monthData.startOffset }).map((_, idx) => (
<div key={`empty-${idx}`} className="size-8" />
))}
{/* روزهای ماه */}
{Array.from({ length: monthData.daysInMonth }).map((_, idx) => {
const dayNum = idx + 1
const dayIso = monthData.toIso(dayNum)
const isSelected = selectedIso === dayIso
const isToday = todayIso === dayIso
// بررسی minDate و maxDate
const isDisabled =
(minDate && dayIso < minDate) ||
(maxDate && dayIso > maxDate)
return (
<button
key={dayNum}
type="button"
disabled={Boolean(isDisabled)}
onClick={() => handleSelectDay(dayNum)}
className={cn(
'size-8 rounded-xl text-xs font-bold transition flex items-center justify-center',
isSelected
? 'bg-primary text-primary-foreground shadow-sm'
: isToday
? 'border border-primary/60 text-primary hover:bg-primary/10'
: 'text-foreground hover:bg-card/70',
isDisabled && 'opacity-30 cursor-not-allowed pointer-events-none'
)}
>
{monthData.formatDayNumber(dayNum)}
</button>
)
})}
</div>
{/* فوتر تقویم با دکمه‌های سریع */}
<div className="mt-3 pt-2.5 border-t border-border-soft flex items-center justify-between gap-1 text-[11px]">
<button
type="button"
onClick={handleSelectToday}
className="text-primary hover:underline font-bold px-2 py-0.5 rounded"
>
{language === 'fa' ? 'امروز' : language === 'ar' ? 'اليوم' : 'Today'}
</button>
{value && (
<button
type="button"
onClick={handleClear}
className="text-muted-foreground hover:text-rose-400 px-2 py-0.5 rounded"
>
{language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'}
</button>
)}
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground px-2 py-0.5 rounded"
>
{language === 'fa' ? 'بستن' : language === 'ar' ? 'إغلاق' : 'Close'}
</button>
</div>
</div>
)}
</div>
)
}

17
src/components/ui/dialog.tsx

@ -16,7 +16,13 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
) )
} }
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
function DialogContent({
className,
children,
onPointerDownOutside,
onInteractOutside,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
@ -25,6 +31,15 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
'fixed left-1/2 top-1/2 z-50 w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border-soft bg-card text-card-foreground shadow-[0_30px_80px_rgba(0,0,0,.35)] anim-pop outline-none', 'fixed left-1/2 top-1/2 z-50 w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border-soft bg-card text-card-foreground shadow-[0_30px_80px_rgba(0,0,0,.35)] anim-pop outline-none',
className, className,
)} )}
onPointerDownOutside={(e) => {
// Prevent closing modal when clicking outside (backdrop)
e.preventDefault()
onPointerDownOutside?.(e)
}}
onInteractOutside={(e) => {
e.preventDefault()
onInteractOutside?.(e)
}}
{...props} {...props}
> >
{children} {children}

133
src/features/billing/BillingPage.tsx

@ -25,8 +25,9 @@ import { RejectFactorDialog } from './components/RejectFactorDialog'
import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog' import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge' import { useBillingBadgeStore } from '@/stores/billing-badge'
import { formatDateByLanguage } from '@/lib/date-utils'
const PAGE_SIZE = 15
const PAGE_SIZE = 20
interface ServiceTabItem { interface ServiceTabItem {
id: string id: string
@ -296,9 +297,13 @@ export default function BillingPage() {
</h1> </h1>
<Badge <Badge
variant="outline" variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs min-w-[65px] justify-center"
> >
{totalCount} {t('billing.invoices')}
{isLoading ? (
<span className="h-3 w-8 inline-block rounded bg-primary/30 animate-pulse" />
) : (
`${totalCount} ${t('billing.invoices')}`
)}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-grey-3"> <p className="text-xs text-grey-3">
@ -360,7 +365,11 @@ export default function BillingPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-grey-3 font-medium">{t('billing.totalInvoices')}</p> <p className="text-[11px] text-grey-3 font-medium">{t('billing.totalInvoices')}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
{isLoading ? (
<div className="h-6 w-12 rounded-md bg-slate-200/90 dark:bg-white/10 animate-pulse mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
)}
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="receipt" className="size-4" /> <Ic name="receipt" className="size-4" />
@ -375,9 +384,13 @@ export default function BillingPage() {
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium"> <p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">
{t('billing.pendingReview')} {t('billing.pendingReview')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400">
{stats.pendingReview}
</p>
{isLoading ? (
<div className="h-6 w-12 rounded-md bg-blue-500/20 animate-pulse mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400">
{stats.pendingReview}
</p>
)}
</div> </div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center"> <div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<Ic name="clock" className="size-4 animate-pulse" /> <Ic name="clock" className="size-4 animate-pulse" />
@ -392,9 +405,13 @@ export default function BillingPage() {
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium"> <p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
{t('billing.awaitingPayment')} {t('billing.awaitingPayment')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.awaitingPayment}
</p>
{isLoading ? (
<div className="h-6 w-12 rounded-md bg-amber-500/20 animate-pulse mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.awaitingPayment}
</p>
)}
</div> </div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center"> <div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Ic name="creditCard" className="size-4" /> <Ic name="creditCard" className="size-4" />
@ -409,9 +426,13 @@ export default function BillingPage() {
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium"> <p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
{t('billing.approvedPayments')} {t('billing.approvedPayments')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.approved}
</p>
{isLoading ? (
<div className="h-6 w-12 rounded-md bg-emerald-500/20 animate-pulse mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.approved}
</p>
)}
</div> </div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center"> <div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" /> <Ic name="checkCircle" className="size-4" />
@ -426,9 +447,13 @@ export default function BillingPage() {
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium"> <p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium">
{t('billing.rejectedOrExpired')} {t('billing.rejectedOrExpired')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.rejectedOrExpired}
</p>
{isLoading ? (
<div className="h-6 w-12 rounded-md bg-rose-500/20 animate-pulse mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.rejectedOrExpired}
</p>
)}
</div> </div>
<div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center"> <div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<Ic name="xCircle" className="size-4" /> <Ic name="xCircle" className="size-4" />
@ -525,9 +550,7 @@ export default function BillingPage() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? (
<Shimmer rows={5} />
) : error ? (
{error ? (
<div className="p-8 text-center text-rose-500 space-y-2"> <div className="p-8 text-center text-rose-500 space-y-2">
<Ic name="alert" className="size-8 mx-auto" /> <Ic name="alert" className="size-8 mx-auto" />
<p className="text-xs font-bold">{error}</p> <p className="text-xs font-bold">{error}</p>
@ -535,7 +558,7 @@ export default function BillingPage() {
{t('common.retry')} {t('common.retry')}
</Button> </Button>
</div> </div>
) : factors.length === 0 ? (
) : !isLoading && factors.length === 0 ? (
<div className="p-12 text-center text-grey-3 space-y-3"> <div className="p-12 text-center text-grey-3 space-y-3">
<div className="size-14 rounded-2xl bg-muted/40 flex items-center justify-center mx-auto text-grey-3"> <div className="size-14 rounded-2xl bg-muted/40 flex items-center justify-center mx-auto text-grey-3">
<Ic name="inbox" className="size-7" /> <Ic name="inbox" className="size-7" />
@ -553,7 +576,13 @@ export default function BillingPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto">
<div className="overflow-x-auto relative">
{/* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */}
{isLoading && (
<div className="absolute top-0 inset-x-0 h-0.5 bg-primary/20 overflow-hidden z-10">
<div className="h-full bg-primary animate-indeterminate origin-right" />
</div>
)}
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2"> <TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2">
@ -568,7 +597,61 @@ export default function BillingPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="text-xs divide-y divide-border-soft"> <TableBody className="text-xs divide-y divide-border-soft">
{factors.map((factor) => {
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
{/* شناسه */}
<TableCell className="text-center">
<div className="h-4 w-9 rounded-md bg-slate-200 dark:bg-white/10 animate-pulse mx-auto" />
</TableCell>
{/* خریدار */}
<TableCell>
<div className="flex items-center gap-2.5">
<div className="size-8 rounded-full bg-slate-200 dark:bg-white/10 animate-pulse shrink-0" />
<div className="space-y-1.5 flex-1 min-w-0">
<div className="h-3.5 w-24 rounded bg-slate-200 dark:bg-white/10 animate-pulse" />
<div className="h-2.5 w-16 rounded bg-slate-200/70 dark:bg-white/5 animate-pulse" />
</div>
</div>
</TableCell>
{/* سرویس */}
<TableCell>
<div className="h-6 w-20 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse" />
</TableCell>
{/* مبلغ */}
<TableCell>
<div className="h-4 w-16 rounded bg-slate-200 dark:bg-white/10 animate-pulse" />
</TableCell>
{/* وضعیت */}
<TableCell>
<div className="h-6 w-24 rounded-full bg-slate-200 dark:bg-white/10 animate-pulse" />
</TableCell>
{/* تصویر فیش */}
<TableCell className="text-center">
<div className="size-8 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse mx-auto" />
</TableCell>
{/* تاریخ */}
<TableCell>
<div className="h-3.5 w-20 rounded bg-slate-200 dark:bg-white/10 animate-pulse" />
</TableCell>
{/* عملیات */}
<TableCell className="text-end pl-4">
<div className="flex items-center justify-end gap-1.5">
<div className="size-7 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse" />
<div className="size-7 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse" />
</div>
</TableCell>
</TableRow>
))
) : (
factors.map((factor) => {
const isPending = factor.status === 'pending' const isPending = factor.status === 'pending'
const hasReceipt = Boolean(factor.transaction_receipt) const hasReceipt = Boolean(factor.transaction_receipt)
const user = factor.user_info const user = factor.user_info
@ -650,9 +733,7 @@ export default function BillingPage() {
{/* تاریخ صدور */} {/* تاریخ صدور */}
<TableCell className="text-grey-3 whitespace-nowrap text-[11px] font-medium"> <TableCell className="text-grey-3 whitespace-nowrap text-[11px] font-medium">
{new Date(factor.created_at).toLocaleDateString(
language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US'
)}
{formatDateByLanguage(factor.created_at, language as any)}
</TableCell> </TableCell>
{/* دکمه‌های عملیات */} {/* دکمه‌های عملیات */}
@ -701,7 +782,7 @@ export default function BillingPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) )
})}
}))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

5
src/features/billing/components/BillingDetailModal.tsx

@ -16,6 +16,7 @@ import { BillingStatusBadge } from './BillingStatusBadge'
import { BillingServiceBadge } from './BillingServiceBadge' import { BillingServiceBadge } from './BillingServiceBadge'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils'
interface Props { interface Props {
factor: FactorItem | null factor: FactorItem | null
@ -67,7 +68,7 @@ export function BillingDetailModal({
<span>صورت‌حساب شماره #{factor.id}</span> <span>صورت‌حساب شماره #{factor.id}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
ثبت شده در: {new Date(factor.created_at).toLocaleString(language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US')}
ثبت شده در: {formatDateTimeByLanguage(factor.created_at, language as any)}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@ -107,7 +108,7 @@ export function BillingDetailModal({
<Ic name="clock" className="size-4 text-amber-500" /> <Ic name="clock" className="size-4 text-amber-500" />
<span>مهلت پرداخت (تاریخ انقضا):</span> <span>مهلت پرداخت (تاریخ انقضا):</span>
</span> </span>
<span className="font-semibold">{factor.expiration_date}</span>
<span className="font-semibold">{formatDateByLanguage(factor.expiration_date, language as any)}</span>
</div> </div>
)} )}

26
src/features/tours/OngoingToursPage.tsx

@ -12,6 +12,8 @@ import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import FilterSelect from '@/components/shared/FilterSelect' import FilterSelect from '@/components/shared/FilterSelect'
import { Shimmer } from '@/components/shared/Shimmer' import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import { formatDateByLanguage, formatDateRangeByLanguage } from '@/lib/date-utils'
interface TourWithItineraries { interface TourWithItineraries {
tour: TourListItem tour: TourListItem
@ -28,6 +30,7 @@ interface TourWithItineraries {
export default function OngoingToursPage() { export default function OngoingToursPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { language } = useTranslation()
const [tours, setTours] = useState<TourListItem[]>([]) const [tours, setTours] = useState<TourListItem[]>([])
const [itinerariesMap, setItinerariesMap] = useState<Record<number, { data: TourItineraryItem[]; loading: boolean; error: string | null }>>({}) const [itinerariesMap, setItinerariesMap] = useState<Record<number, { data: TourItineraryItem[]; loading: boolean; error: string | null }>>({})
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -245,19 +248,10 @@ export default function OngoingToursPage() {
})) }))
} }
// قالب‌بندی زمان و تاریخ به فرمت خوانا
// قالب‌بندی زمان و تاریخ به فرمت خوانا متناسب با تقویم زبان فعال
const formatDateTime = (dateStr?: string | null) => { const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return '-' if (!dateStr) return '-'
try {
const d = new Date(dateStr)
return d.toLocaleDateString('fa-IR', {
month: 'long',
day: 'numeric',
weekday: 'short',
})
} catch {
return dateStr
}
return formatDateByLanguage(dateStr, language as any, { style: 'medium' })
} }
return ( return (
@ -565,17 +559,17 @@ export default function OngoingToursPage() {
{tour.title} {tour.title}
</h2> </h2>
<div className="flex flex-wrap items-center gap-3 text-xs text-grey-3 font-mono" dir="ltr">
<div className="flex flex-wrap items-center gap-3 text-xs text-grey-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Ic name="calendar" className="size-3.5 text-primary" /> <Ic name="calendar" className="size-3.5 text-primary" />
<span> <span>
{tour.started_at} ~ {tour.ended_at}
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
</span> </span>
</div> </div>
<span>|</span> <span>|</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Ic name="tag" className="size-3.5 text-grey-3" /> <Ic name="tag" className="size-3.5 text-grey-3" />
<span>{tour.slug}</span>
<span className="font-mono">{tour.slug}</span>
</div> </div>
</div> </div>
</div> </div>
@ -772,8 +766,8 @@ export default function OngoingToursPage() {
<span>{formatDateTime(step.started_at)}</span> <span>{formatDateTime(step.started_at)}</span>
</div> </div>
{step.ended_at && ( {step.ended_at && (
<div className="text-[10px] text-grey-3 mt-0.5" dir="ltr">
الی {formatDateTime(step.ended_at)}
<div className="text-[10px] text-grey-3 mt-0.5">
{language === 'fa' ? 'تا' : language === 'ar' ? 'إلى' : 'to'} {formatDateTime(step.ended_at)}
</div> </div>
)} )}
</div> </div>

5
src/features/tours/TourOrdersPage.tsx

@ -16,8 +16,11 @@ import { fetchTourOrders, updateTourOrder } from './services/tour-api'
import type { TourOrderItem, TourOrderStatus } from './types' import type { TourOrderItem, TourOrderStatus } from './types'
import { TourStatusBadge } from './components/TourStatusBadge' import { TourStatusBadge } from './components/TourStatusBadge'
import { Shimmer } from '@/components/shared/Shimmer' import { Shimmer } from '@/components/shared/Shimmer'
import { useTranslation } from '@/i18n'
import { formatDateByLanguage } from '@/lib/date-utils'
export default function TourOrdersPage() { export default function TourOrdersPage() {
const { language } = useTranslation()
const [orders, setOrders] = useState<TourOrderItem[]>([]) const [orders, setOrders] = useState<TourOrderItem[]>([])
const [totalCount, setTotalCount] = useState<number>(0) const [totalCount, setTotalCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
@ -345,7 +348,7 @@ export default function TourOrdersPage() {
{/* Date */} {/* Date */}
<td className="p-4 text-grey-3 font-medium text-[11px]"> <td className="p-4 text-grey-3 font-medium text-[11px]">
{order.created ? new Date(order.created).toLocaleDateString('fa-IR') : '-'}
{order.created ? formatDateByLanguage(order.created, language as any) : '-'}
</td> </td>
{/* Actions */} {/* Actions */}

182
src/features/tours/ToursPage.tsx

@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
@ -16,16 +15,19 @@ import {
import { fetchTours } from './services/tour-api' import { fetchTours } from './services/tour-api'
import type { TourListItem } from './types' import type { TourListItem } from './types'
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge' import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import { TourDetailSheet } from './components/TourDetailSheet'
import { TourTranslationsDialog } from './components/TourTranslationsDialog'
import { TourDetailDialog } from './components/TourDetailDialog'
import { CreateTourDialog } from './components/CreateTourDialog'
import TablePagination from '@/components/shared/TablePagination' import TablePagination from '@/components/shared/TablePagination'
import FilterSelect from '@/components/shared/FilterSelect' import FilterSelect from '@/components/shared/FilterSelect'
import SortDropdown from '@/components/shared/SortDropdown' import SortDropdown from '@/components/shared/SortDropdown'
import FilterBar from '@/components/shared/FilterBar' import FilterBar from '@/components/shared/FilterBar'
import { Shimmer } from '@/components/shared/Shimmer' import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n'
import { formatDateRangeByLanguage } from '@/lib/date-utils'
export default function ToursPage() { export default function ToursPage() {
const { language } = useTranslation()
const [tours, setTours] = useState<TourListItem[]>([]) const [tours, setTours] = useState<TourListItem[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
@ -38,20 +40,15 @@ export default function ToursPage() {
const [countryFilter, setCountryFilter] = useState('ALL') const [countryFilter, setCountryFilter] = useState('ALL')
const [sortBy, setSortBy] = useState<'id' | 'price_asc' | 'price_desc' | 'started_at'>('id') const [sortBy, setSortBy] = useState<'id' | 'price_asc' | 'price_desc' | 'started_at'>('id')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const [languageCode, setLanguageCode] = useState<string>('ALL')
// صفحه‌بندی (Pagination) // صفحه‌بندی (Pagination)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(8) // ۸ تور در هر صفحه (۲ ردیف ۴ تایی مناسب گرید و جدول)
const [pageSize, setPageSize] = useState(20) // ۲۰ تور در هر صفحه
// وضعیت شیت جزئیات تور
// وضعیت مودال جزئیات و ترجمه‌های یکپارچه تور
const [selectedTourId, setSelectedTourId] = useState<number | null>(null) const [selectedTourId, setSelectedTourId] = useState<number | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false) const [isDetailOpen, setIsDetailOpen] = useState(false)
// وضعیت دیالوگ ترجمه‌ها
const [transTourId, setTransTourId] = useState<number | null>(null)
const [transTourTitle, setTransTourTitle] = useState<string>('')
const [isTransOpen, setIsTransOpen] = useState(false)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const loadData = async (showRefresh = false) => { const loadData = async (showRefresh = false) => {
if (showRefresh) setIsRefreshing(true) if (showRefresh) setIsRefreshing(true)
@ -61,7 +58,7 @@ export default function ToursPage() {
try { try {
const res = await fetchTours({ const res = await fetchTours({
limit: 100, limit: 100,
language_code: languageCode !== 'ALL' ? languageCode : undefined,
language_code: language,
}) })
setTours(res.results || []) setTours(res.results || [])
} catch (err: any) { } catch (err: any) {
@ -74,7 +71,7 @@ export default function ToursPage() {
useEffect(() => { useEffect(() => {
loadData() loadData()
}, [languageCode])
}, [language])
// فیلتر، جستجو و مرتب‌سازی کلاینت // فیلتر، جستجو و مرتب‌سازی کلاینت
const filteredTours = useMemo(() => { const filteredTours = useMemo(() => {
@ -107,10 +104,10 @@ export default function ToursPage() {
Boolean(searchTerm.trim()), Boolean(searchTerm.trim()),
].filter(Boolean).length ].filter(Boolean).length
// بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو، مرتب‌سازی یا زبان
// بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو یا مرتب‌سازی
useEffect(() => { useEffect(() => {
setCurrentPage(1) setCurrentPage(1)
}, [searchTerm, statusFilter, tripStatusFilter, countryFilter, languageCode, sortBy])
}, [searchTerm, statusFilter, tripStatusFilter, countryFilter, sortBy])
// محاسبات صفحه‌بندی فهرست تورها // محاسبات صفحه‌بندی فهرست تورها
const totalPages = Math.ceil(filteredTours.length / pageSize) || 1 const totalPages = Math.ceil(filteredTours.length / pageSize) || 1
@ -161,27 +158,11 @@ export default function ToursPage() {
</Badge> </Badge>
</div> </div>
<p className="text-xs text-grey-3"> <p className="text-xs text-grey-3">
پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران و اطلاعات قیمتی تورهای عقیله
پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{/* انتخاب زبان */}
<FilterSelect
value={languageCode}
onChange={setLanguageCode}
options={[
{ value: 'ALL', label: 'همه زبان‌ها' },
{ value: 'fa', label: 'فارسی (fa)' },
{ value: 'en', label: 'English (en)' },
{ value: 'ar', label: 'العربية (ar)' },
]}
allValue="ALL"
icon="globe"
size="sm"
className="w-36"
/>
{/* تغییر نمای شبکه‌ای / جدولی */} {/* تغییر نمای شبکه‌ای / جدولی */}
<div className="flex rounded-xl border border-border-soft bg-surface-base p-1"> <div className="flex rounded-xl border border-border-soft bg-surface-base p-1">
<Button <Button
@ -204,6 +185,16 @@ export default function ToursPage() {
</Button> </Button>
</div> </div>
{/* دکمه ایجاد تور جدید */}
<Button
size="sm"
onClick={() => setIsCreateOpen(true)}
className="bg-primary text-white hover:bg-primary/90 text-xs font-bold gap-1.5 shadow-sm rounded-xl h-9 px-3.5"
>
<Ic name="plus" className="size-4" />
<span>افزودن تور جدید</span>
</Button>
{/* دکمه بروزرسانی زنده */} {/* دکمه بروزرسانی زنده */}
<Button <Button
variant="outline" variant="outline"
@ -427,7 +418,8 @@ export default function ToursPage() {
return ( return (
<div <div
key={tour.id} 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"
onClick={() => handleOpenDetail(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 cursor-pointer"
> >
<div> <div>
{/* تصویر کاور تور با نشان کشور و وضعیت */} {/* تصویر کاور تور با نشان کشور و وضعیت */}
@ -468,45 +460,44 @@ export default function ToursPage() {
</div> </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">
<div className="flex items-center gap-1.5 text-xs text-grey-3 bg-surface-base p-2 rounded-xl border border-border-soft">
<Ic name="calendar" className="size-3.5 shrink-0 text-primary" /> <Ic name="calendar" className="size-3.5 shrink-0 text-primary" />
<span className="truncate">{tour.started_at} الی {tour.ended_at}</span>
<span className="truncate text-foreground font-medium text-[11px]">
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
</span>
</div> </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 className="space-y-1.5 pt-2 border-t border-border-soft text-xs">
<div className="flex items-center justify-between text-grey-2">
<span className="text-[11px] text-grey-3">بزرگسال <span className="text-[10px] opacity-80">(۷+ سال)</span>:</span>
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price || '0').toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-grey-2">
<span className="text-[11px] text-grey-3">کودک <span className="text-[10px] opacity-80">(۲ تا ۷ سال)</span>:</span>
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price_child || '0').toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-grey-2">
<span className="text-[11px] text-grey-3">نوزاد <span className="text-[10px] opacity-80">(۰ تا ۲ سال)</span>:</span>
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price_infant || '0').toLocaleString()}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* فوتر کارت و دکمه جزئیات */} {/* فوتر کارت و دکمه جزئیات */}
<div className="p-4 pt-0 flex items-center gap-2">
<div className="p-4 pt-0">
<Button <Button
variant="outline" variant="outline"
size="sm" 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="مدیریت ترجمه‌های ۵ زبانه"
className="w-full text-xs font-bold border-border-soft group-hover:border-primary group-hover:bg-primary group-hover:text-white transition"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setTransTourId(tour.id)
setTransTourTitle(tour.title)
setIsTransOpen(true)
handleOpenDetail(tour.id)
}} }}
> >
<Ic name="globe" className="size-4" />
<Ic name="eye" className="size-3.5 ml-1.5" />
مشاهده جزئیات کامل و ترجمه‌ها
</Button> </Button>
</div> </div>
</div> </div>
@ -523,7 +514,7 @@ export default function ToursPage() {
<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-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> <TableHead className="text-center text-xs font-bold">عملیات</TableHead>
@ -544,11 +535,14 @@ export default function ToursPage() {
<TableCell> <TableCell>
<CountryFlagBadge countryCode={tour.destination_country} /> <CountryFlagBadge countryCode={tour.destination_country} />
</TableCell> </TableCell>
<TableCell className="font-mono text-xs text-grey-2" dir="ltr">
{tour.started_at} ~ {tour.ended_at}
<TableCell className="text-xs text-grey-2">
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
</TableCell> </TableCell>
<TableCell className="font-black text-xs text-foreground font-mono">
${parseFloat(tour.price).toLocaleString()}
<TableCell className="text-xs font-mono">
<div className="font-black text-foreground">${parseFloat(tour.price || '0').toLocaleString()}</div>
<div className="text-[10px] text-grey-3">
کودک: ${parseFloat(tour.price_child || '0').toLocaleString()} | نوزاد: ${parseFloat(tour.price_infant || '0').toLocaleString()}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<TourStatusBadge status={tour.status} type="sale" /> <TourStatusBadge status={tour.status} type="sale" />
@ -557,34 +551,18 @@ export default function ToursPage() {
<TourStatusBadge status={tour.trip_status} type="trip" /> <TourStatusBadge status={tour.trip_status} type="trip" />
</TableCell> </TableCell>
<TableCell className="text-center"> <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>
<Button
variant="ghost"
size="sm"
className="size-8 p-0 text-primary hover:bg-primary/10 mx-auto"
title="مشاهده جزئیات کامل و ترجمه‌ها"
onClick={(e) => {
e.stopPropagation()
handleOpenDetail(tour.id)
}}
>
<Ic name="eye" className="size-4" />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -623,10 +601,10 @@ export default function ToursPage() {
setCurrentPage(1) setCurrentPage(1)
}} }}
options={[ options={[
{ value: '8', label: '۸' },
{ value: '12', label: '۱۲' },
{ value: '16', label: '۱۶' },
{ value: '24', label: '۲۴' },
{ value: '10', label: '۱۰' },
{ value: '20', label: '۲۰' },
{ value: '50', label: '۵۰' },
{ value: '100', label: '۱۰۰' },
]} ]}
size="sm" size="sm"
className="w-20" className="w-20"
@ -647,22 +625,22 @@ export default function ToursPage() {
</div> </div>
)} )}
{/* شیت جزئیات کامل تور */}
<TourDetailSheet
{/* مودال پاپ‌آپ جامع جزئیات و ترجمه‌های تور */}
<TourDetailDialog
tourId={selectedTourId} tourId={selectedTourId}
isOpen={isDetailOpen} isOpen={isDetailOpen}
onClose={() => setIsDetailOpen(false)} onClose={() => setIsDetailOpen(false)}
onUpdated={() => loadData()}
/> />
{/* دیالوگ مدیریت ترجمه‌های ۵ زبانه */}
<TourTranslationsDialog
tourId={transTourId}
tourTitle={transTourTitle}
open={isTransOpen}
onOpenChange={setIsTransOpen}
onSuccess={() => loadData()}
{/* مودال ایجاد تور جدید به همراه ترجمه‌های ۵ زبانه */}
<CreateTourDialog
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onSuccess={() => loadData(true)}
/> />
</div> </div>
) )
} }

957
src/features/tours/components/CreateTourDialog.tsx

@ -0,0 +1,957 @@
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 { createTour, type CreateTourPayload } from '../services/tour-api'
import type { TourTranslationData } from '../types'
interface CreateTourDialogProps {
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
const DESTINATION_COUNTRIES = [
{ code: 'IQ', name: 'عراق (Iraq)', flag: '🇮🇶' },
{ code: 'SA', name: 'عربستان (Saudi Arabia)', flag: '🇸🇦' },
{ code: 'IR', name: 'ایران (Iran)', flag: '🇮🇷' },
{ code: 'SY', name: 'سوریه (Syria)', flag: '🇸🇾' },
{ code: 'OM', name: 'عمان (Oman)', flag: '🇴🇲' },
] as const
type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code']
export function CreateTourDialog({
open,
onOpenChange,
onSuccess,
}: CreateTourDialogProps) {
const [activeTab, setActiveTab] = React.useState<'basic' | 'features_tips' | 'translations'>('basic')
const [selectedTransLang, setSelectedTransLang] = React.useState<LangCode>('en')
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const [successMessage, setSuccessMessage] = React.useState<string | null>(null)
// Basic info states
const [title, setTitle] = React.useState('')
const [description, setDescription] = React.useState('')
const [startedAt, setStartedAt] = React.useState('')
const [endedAt, setEndedAt] = React.useState('')
const [capacity, setCapacity] = React.useState('40')
const [destinationCountry, setDestinationCountry] = React.useState('IQ')
const [primaryLang, setPrimaryLang] = React.useState<LangCode>('fa')
const [status, setStatus] = React.useState('AVAILABLE')
// Pricing states
const [price, setPrice] = React.useState('')
const [priceChild, setPriceChild] = React.useState('')
const [priceInfant, setPriceInfant] = React.useState('')
const [percentOff, setPercentOff] = React.useState('0')
// Base features & tips
const [features, setFeatures] = React.useState<string[]>([])
const [newFeature, setNewFeature] = React.useState('')
const [tips, setTips] = React.useState<{ title: string; desc: string }[]>([])
const [newTipTitle, setNewTipTitle] = React.useState('')
const [newTipDesc, setNewTipDesc] = React.useState('')
// Multi-language translation state
const [translations, setTranslations] = React.useState<Record<string, TourTranslationData>>({
fa: { title: '', description: '', tour_feature: [], travel_tips: {} },
ar: { title: '', description: '', tour_feature: [], travel_tips: {} },
en: { title: '', description: '', tour_feature: [], travel_tips: {} },
ru: { title: '', description: '', tour_feature: [], travel_tips: {} },
id: { title: '', description: '', tour_feature: [], travel_tips: {} },
})
// Reset form when opened
React.useEffect(() => {
if (open) {
setErrorMessage(null)
setSuccessMessage(null)
setActiveTab('basic')
}
}, [open])
const handleAddFeature = () => {
if (!newFeature.trim()) return
setFeatures((prev) => [...prev, newFeature.trim()])
setNewFeature('')
}
const handleRemoveFeature = (idx: number) => {
setFeatures((prev) => prev.filter((_, i) => i !== idx))
}
const handleAddTip = () => {
if (!newTipTitle.trim() || !newTipDesc.trim()) return
setTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }])
setNewTipTitle('')
setNewTipDesc('')
}
const handleRemoveTip = (idx: number) => {
setTips((prev) => prev.filter((_, i) => i !== idx))
}
// Helper for updating translation fields for selectedTransLang
const handleTransFieldChange = (field: 'title' | 'description', val: string) => {
setTranslations((prev) => ({
...prev,
[selectedTransLang]: {
...prev[selectedTransLang],
[field]: val,
},
}))
}
const handleAddTransFeature = (text: string) => {
if (!text.trim()) return
setTranslations((prev) => {
const current = prev[selectedTransLang] || { tour_feature: [] }
const currFeats = current.tour_feature || []
return {
...prev,
[selectedTransLang]: {
...current,
tour_feature: [...currFeats, { title: text.trim() }],
},
}
})
}
const handleRemoveTransFeature = (idx: number) => {
setTranslations((prev) => {
const current = prev[selectedTransLang] || { tour_feature: [] }
const currFeats = current.tour_feature || []
return {
...prev,
[selectedTransLang]: {
...current,
tour_feature: currFeats.filter((_, i) => i !== idx),
},
}
})
}
const handleAddTransTip = (tipTitle: string, tipDesc: string) => {
if (!tipTitle.trim() || !tipDesc.trim()) return
setTranslations((prev) => {
const current = prev[selectedTransLang] || { travel_tips: {} }
const currTips = current.travel_tips || {}
return {
...prev,
[selectedTransLang]: {
...current,
travel_tips: {
...currTips,
[tipTitle.trim()]: tipDesc.trim(),
},
},
}
})
}
const handleRemoveTransTip = (tipTitle: string) => {
setTranslations((prev) => {
const current = prev[selectedTransLang] || { travel_tips: {} }
const currTips = { ...(current.travel_tips || {}) }
delete currTips[tipTitle]
return {
...prev,
[selectedTransLang]: {
...current,
travel_tips: currTips,
},
}
})
}
const [newTransFeatText, setNewTransFeatText] = React.useState('')
const [newTransTipTitle, setNewTransTipTitle] = React.useState('')
const [newTransTipDesc, setNewTransTipDesc] = React.useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrorMessage(null)
setSuccessMessage(null)
// Validation
if (!title.trim()) {
setErrorMessage('لطفاً عنوان اصلی تور را وارد نمایید.')
setActiveTab('basic')
return
}
if (!startedAt) {
setErrorMessage('لطفاً تاریخ شروع تور را مشخص نمایید.')
setActiveTab('basic')
return
}
if (!endedAt) {
setErrorMessage('لطفاً تاریخ پایان تور را مشخص نمایید.')
setActiveTab('basic')
return
}
if (new Date(endedAt) < new Date(startedAt)) {
setErrorMessage('تاریخ پایان تور نمی‌تواند قبل از تاریخ شروع باشد.')
setActiveTab('basic')
return
}
if (!price || isNaN(Number(price)) || Number(price) <= 0) {
setErrorMessage('لطفاً قیمت معتبر برای بزرگسال وارد نمایید.')
setActiveTab('basic')
return
}
if (!capacity || isNaN(Number(capacity)) || Number(capacity) <= 0) {
setErrorMessage('لطفاً ظرفیت معتبر برای تور وارد نمایید.')
setActiveTab('basic')
return
}
setIsSubmitting(true)
try {
const tour_feature = features.map((f) => ({ title: f }))
const travel_tips: Record<string, string> = {}
tips.forEach((t) => {
travel_tips[t.title] = t.desc
})
// Clean translations payload: include any language that has data
const cleanedTranslations: Record<string, TourTranslationData> = {}
Object.entries(translations).forEach(([code, data]) => {
const hasTitle = Boolean(data.title?.trim())
const hasDesc = Boolean(data.description?.trim())
const hasFeats = Boolean(data.tour_feature && data.tour_feature.length > 0)
const hasTips = Boolean(data.travel_tips && Object.keys(data.travel_tips).length > 0)
if (hasTitle || hasDesc || hasFeats || hasTips) {
cleanedTranslations[code] = {
title: data.title?.trim() || (code === primaryLang ? title.trim() : ''),
description: data.description?.trim() || '',
tour_feature: data.tour_feature || [],
travel_tips: data.travel_tips || {},
}
}
})
// Ensure primary language translation is also present if not defined
if (!cleanedTranslations[primaryLang]) {
cleanedTranslations[primaryLang] = {
title: title.trim(),
description: description.trim(),
tour_feature,
travel_tips,
}
}
const payload: CreateTourPayload = {
title: title.trim(),
description: description.trim(),
started_at: startedAt,
ended_at: endedAt,
capacity: Number(capacity),
price: String(price),
price_child: priceChild ? String(priceChild) : '0',
price_infant: priceInfant ? String(priceInfant) : '0',
percent_off: percentOff ? Number(percentOff) : 0,
destination_country_code: destinationCountry,
language_code: primaryLang,
status,
tour_feature,
travel_tips,
visibility: true,
translations: cleanedTranslations,
}
const createdTour = await createTour(payload)
setSuccessMessage(`تور "${createdTour.title}" با موفقیت ایجاد و ترجمه‌ها در دیتابیس ثبت گردید.`)
setTimeout(() => {
if (onSuccess) onSuccess()
onOpenChange(false)
}, 900)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در ثبت تور جدید در سرور.')
} finally {
setIsSubmitting(false)
}
}
const activeTransConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedTransLang)
const currentTransData = translations[selectedTransLang] || {
title: '',
description: '',
tour_feature: [],
travel_tips: {},
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
className="max-w-[840px] p-0 overflow-hidden max-h-[92vh] flex flex-col"
>
{/* Header */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="plus" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
افزودن تور زیارتی و گردشگری جدید
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
ثبت مشخصات پایه، هزینه‌ها، ظرفیت و ورودی‌های ترجمه ۵ زبانه همزمان
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
{/* Step/Tab buttons */}
<div className="flex items-center gap-1 rounded-xl bg-surface-2 p-1 border border-border/40">
<button
type="button"
onClick={() => setActiveTab('basic')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${
activeTab === 'basic'
? 'bg-primary text-white shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
۱. مشخصات و قیمت
</button>
<button
type="button"
onClick={() => setActiveTab('features_tips')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${
activeTab === 'features_tips'
? 'bg-primary text-white shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
۲. ویژگی‌ها و نکات
</button>
<button
type="button"
onClick={() => setActiveTab('translations')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 cursor-pointer ${
activeTab === 'translations'
? 'bg-primary text-white shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
<Ic name="globe" className="size-3.5" />
<span>۳. ترجمه‌های ۵ زبانه</span>
</button>
</div>
{/* دکمه علامت ضربدر بالای مودال */}
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
</div>
</DialogHeader>
{/* Form Body */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-6 py-5 space-y-5 flex flex-col justify-between">
<div className="space-y-4">
{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>
)}
{/* TAB 1: Basic Info & Pricing */}
{activeTab === 'basic' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-xs font-bold text-foreground mb-1.5">
عنوان اصلی تور <span className="text-rose-500">*</span>:
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="مثال: تور زیارتی عتبات عالیات نجف و کربلا (ویژه اربعین)..."
className="bg-surface-2 border-border/50 text-sm font-medium"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
کشور مقصد <span className="text-rose-500">*</span>:
</label>
<select
value={destinationCountry}
onChange={(e) => setDestinationCountry(e.target.value)}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none"
>
{DESTINATION_COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.flag} {c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
زبان اصلی ثبت داده:
</label>
<select
value={primaryLang}
onChange={(e) => setPrimaryLang(e.target.value as LangCode)}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none"
>
{SUPPORTED_LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.flag} {l.label} ({l.code})
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
تاریخ شروع تور <span className="text-rose-500">*</span>:
</label>
<Input
type="date"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="bg-surface-2 border-border/50 text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
تاریخ پایان تور <span className="text-rose-500">*</span>:
</label>
<Input
type="date"
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
className="bg-surface-2 border-border/50 text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
ظرفیت کل مسافران <span className="text-rose-500">*</span>:
</label>
<Input
type="number"
min="1"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="۴۰"
className="bg-surface-2 border-border/50 text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
وضعیت فروش اولیه:
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none"
>
<option value="AVAILABLE">در دسترس (Available)</option>
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option>
</select>
</div>
</div>
{/* Pricing Sub-card */}
<div className="rounded-2xl bg-surface-2/70 border border-border/50 p-4 space-y-3">
<h4 className="text-xs font-extrabold text-foreground flex items-center gap-1.5">
<Ic name="dollar" className="size-4 text-emerald-500" />
<span>قیمت‌گذاری و تخفیفات (دلار)</span>
</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label className="block text-[11px] font-bold text-grey-2 mb-1">
بزرگسال ($) <span className="text-rose-500">*</span>:
</label>
<Input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="520"
className="bg-surface-3 text-xs font-mono"
required
/>
</div>
<div>
<label className="block text-[11px] font-bold text-grey-2 mb-1">
کودک (۲ تا ۷ سال):
</label>
<Input
type="number"
step="0.01"
value={priceChild}
onChange={(e) => setPriceChild(e.target.value)}
placeholder="280"
className="bg-surface-3 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-grey-2 mb-1">
نوزاد (زیر ۲ سال):
</label>
<Input
type="number"
step="0.01"
value={priceInfant}
onChange={(e) => setPriceInfant(e.target.value)}
placeholder="80"
className="bg-surface-3 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-grey-2 mb-1">
درصد تخفیف (%):
</label>
<Input
type="number"
min="0"
max="100"
value={percentOff}
onChange={(e) => setPercentOff(e.target.value)}
placeholder="0"
className="bg-surface-3 text-xs font-mono"
/>
</div>
</div>
</div>
{/* Description */}
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
شرح و توضیحات تور:
</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="شرح کامل خدمات، برنامه‌های زیارتی و تسهیلات اقامتی تور..."
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"
/>
</div>
</div>
)}
{/* TAB 2: Features and Tips */}
{activeTab === 'features_tips' && (
<div className="space-y-5">
{/* Features Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Ic name="check" className="size-4 text-primary" />
<span>ویژگی‌ها و خدمات شاخص تور ({features.length})</span>
</label>
</div>
<div className="flex items-center gap-2">
<Input
value={newFeature}
onChange={(e) => setNewFeature(e.target.value)}
placeholder="مثال: اقامت در هتل ۵ ستاره نزدیک حرم..."
className="bg-surface-2 text-xs"
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 text-xs h-9 rounded-xl"
>
<Ic name="plus" className="size-4" />
<span>افزودن</span>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
{features.length === 0 ? (
<p className="text-xs text-grey-4 col-span-2 text-center py-4 bg-surface-2/30 rounded-xl">
هنوز ویژگی برای این تور اضافه نشده است.
</p>
) : (
features.map((feat, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-2 rounded-xl bg-surface-2 border border-border/40 px-3 py-2 text-xs"
>
<span className="font-medium text-foreground truncate">{feat}</span>
<button
type="button"
onClick={() => handleRemoveFeature(idx)}
className="text-grey-4 hover:text-rose-400 p-1 shrink-0"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Travel Tips Section */}
<div className="space-y-3 pt-3 border-t border-border/40">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Ic name="info" className="size-4 text-amber-500" />
<span>نکات و راهنمای سفر ({tips.length})</span>
</label>
<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="عنوان نکته (مثال: مدارک لازم / ارز مسافرتی)..."
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 rounded-xl"
>
<Ic name="plus" className="size-3.5 ml-1" />
<span>افزودن نکته سفر</span>
</Button>
</div>
<div className="space-y-2 mt-2">
{tips.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-4 bg-surface-2/30 rounded-xl">
نکته سفری ثبت نشده است.
</p>
) : (
tips.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>
</div>
)}
{/* TAB 3: Multi-Language Translations */}
{activeTab === 'translations' && (
<div className="space-y-4">
{/* 5-Language Selector */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 border-b border-border/40">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedTransLang
const transObj = translations[lang.code]
const hasData = Boolean(
transObj?.title?.trim() ||
transObj?.description?.trim() ||
(transObj?.tour_feature && transObj.tour_feature.length > 0)
)
return (
<button
key={lang.code}
type="button"
onClick={() => setSelectedTransLang(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>
{hasData && !isSelected && (
<span className="size-1.5 rounded-full bg-emerald-500" />
)}
{lang.code === primaryLang && (
<span className="text-[10px] opacity-80">(اصلی)</span>
)}
</button>
)
})}
</div>
<div dir={activeTransConfig?.dir || 'rtl'} className="space-y-4">
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
عنوان تور به زبان {activeTransConfig?.label} ({selectedTransLang}):
</label>
<Input
value={currentTransData.title || ''}
onChange={(e) => handleTransFieldChange('title', e.target.value)}
placeholder={`عنوان تور به زبان ${activeTransConfig?.label}...`}
className="bg-surface-2 border-border/50 text-sm"
/>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
شرح و توضیحات به زبان {activeTransConfig?.label}:
</label>
<textarea
rows={3}
value={currentTransData.description || ''}
onChange={(e) => handleTransFieldChange('description', e.target.value)}
placeholder={`شرح تور به زبان ${activeTransConfig?.label}...`}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
/>
</div>
{/* Features for this language */}
<div className="space-y-2">
<label className="block text-xs font-bold text-foreground">
ویژگی‌ها به زبان {activeTransConfig?.label} ({(currentTransData.tour_feature || []).length}):
</label>
<div className="flex items-center gap-2">
<Input
value={newTransFeatText}
onChange={(e) => setNewTransFeatText(e.target.value)}
placeholder={`افزودن ویژگی به ${activeTransConfig?.label}...`}
className="bg-surface-2 text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTransFeature(newTransFeatText)
setNewTransFeatText('')
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => {
handleAddTransFeature(newTransFeatText)
setNewTransFeatText('')
}}
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl"
>
<Ic name="plus" className="size-4" />
<span>افزودن</span>
</Button>
</div>
<div className="space-y-1.5">
{(currentTransData.tour_feature || []).map((feat, fIdx) => (
<div
key={fIdx}
className="flex items-center justify-between rounded-xl bg-surface-2 border border-border/40 px-3 py-1.5 text-xs"
>
<span className="font-medium text-foreground">{feat.title}</span>
<button
type="button"
onClick={() => handleRemoveTransFeature(fIdx)}
className="text-grey-4 hover:text-rose-400 p-0.5"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
))}
</div>
</div>
{/* Travel Tips for this language */}
<div className="space-y-2 pt-2 border-t border-border/40">
<label className="block text-xs font-bold text-foreground">
نکات سفر به زبان {activeTransConfig?.label} ({Object.keys(currentTransData.travel_tips || {}).length}):
</label>
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-2.5 space-y-2">
<Input
value={newTransTipTitle}
onChange={(e) => setNewTransTipTitle(e.target.value)}
placeholder={`عنوان نکته به ${activeTransConfig?.label}...`}
className="bg-surface-2 text-xs"
/>
<textarea
rows={2}
value={newTransTipDesc}
onChange={(e) => setNewTransTipDesc(e.target.value)}
placeholder={`شرح نکته به ${activeTransConfig?.label}...`}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-1.5 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
/>
<Button
type="button"
size="sm"
onClick={() => {
handleAddTransTip(newTransTipTitle, newTransTipDesc)
setNewTransTipTitle('')
setNewTransTipDesc('')
}}
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8 rounded-xl"
>
<Ic name="plus" className="size-3.5 ml-1" />
<span>افزودن نکته به زبان {activeTransConfig?.label}</span>
</Button>
</div>
<div className="space-y-1.5">
{Object.entries(currentTransData.travel_tips || {}).map(([tTitle, tDesc], tIdx) => (
<div
key={tIdx}
className="rounded-xl bg-surface-2 border border-border/40 p-2.5 text-xs space-y-1"
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground">{tTitle}</span>
<button
type="button"
onClick={() => handleRemoveTransTip(tTitle)}
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">{tDesc}</p>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between -mx-6 -mb-5 mt-6">
<div className="flex items-center gap-2">
<span className="text-xs text-grey-3">
{activeTab === 'basic' && 'مرحله ۱ از ۳: اطلاعات پایه و قیمت'}
{activeTab === 'features_tips' && 'مرحله ۲ از ۳: ویژگی‌ها و نکات'}
{activeTab === 'translations' && 'مرحله ۳ از ۳: ترجمه‌های چندزبانه'}
</span>
</div>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
className="rounded-xl text-xs cursor-pointer"
>
انصراف
</Button>
</DialogClose>
{activeTab === 'basic' && (
<Button
type="button"
size="sm"
onClick={() => setActiveTab('features_tips')}
className="rounded-xl bg-primary text-white hover:bg-primary/90 text-xs gap-1.5"
>
<span>مرحله بعد: ویژگی‌ها</span>
<Ic name="arrow-left" className="size-3.5" />
</Button>
)}
{activeTab === 'features_tips' && (
<Button
type="button"
size="sm"
onClick={() => setActiveTab('translations')}
className="rounded-xl bg-primary text-white hover:bg-primary/90 text-xs gap-1.5"
>
<span>مرحله بعد: ترجمه‌ها</span>
<Ic name="arrow-left" className="size-3.5" />
</Button>
)}
<Button
type="submit"
size="sm"
disabled={isSubmitting}
className="rounded-xl bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold gap-1.5 shadow-sm"
>
{isSubmitting ? (
<>
<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>ایجاد و ثبت نهایی تور</span>
</>
)}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

948
src/features/tours/components/TourDetailDialog.tsx

@ -0,0 +1,948 @@
import React, { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import {
fetchTourDetail,
fetchTourItineraries,
fetchTourPassengers,
fetchTourComments,
fetchTourTranslations,
saveTourTranslations,
fetchTourItineraryTranslations,
saveTourItineraryTranslation,
} from '../services/tour-api'
import type {
TourDetailItem,
TourItineraryItem,
TourPassengerGroupItem,
TourCommentItem,
TourTranslationsResponse,
TourItineraryTranslationsResponseItem,
} from '../types'
import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge'
interface TourDetailDialogProps {
tourId: number | null
isOpen: boolean
onClose: () => void
onUpdated?: () => 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 TourDetailDialog({
tourId,
isOpen,
onClose,
onUpdated,
}: TourDetailDialogProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'>('overview')
const [selectedLang, setSelectedLang] = useState<LangCode>('fa')
// Main tour data
const [tour, setTour] = useState<TourDetailItem | null>(null)
const [itineraries, setItineraries] = useState<TourItineraryItem[]>([])
const [passengersGroup, setPassengersGroup] = useState<TourPassengerGroupItem[]>([])
const [comments, setComments] = useState<TourCommentItem[]>([])
// Translation data from server
const [transData, setTransData] = useState<TourTranslationsResponse | null>(null)
const [itinTransList, setItinTransList] = useState<TourItineraryTranslationsResponseItem[]>([])
// Translation local form drafts
const [draftTitle, setDraftTitle] = useState<string>('')
const [draftDesc, setDraftDesc] = useState<string>('')
const [draftFeatures, setDraftFeatures] = useState<string[]>([])
const [newFeatureInput, setNewFeatureInput] = useState<string>('')
const [draftTips, setDraftTips] = useState<{ title: string; desc: string }[]>([])
const [newTipTitle, setNewTipTitle] = useState<string>('')
const [newTipDesc, setNewTipDesc] = useState<string>('')
const [itinDrafts, setItinDrafts] = useState<Record<number, { title: string; summary: string }>>({})
// Statuses
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSub, setIsLoadingSub] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [savedItinId, setSavedItinId] = useState<number | null>(null)
// Load tour detail and all translations on open
useEffect(() => {
if (!tourId || !isOpen) {
setTour(null)
setTransData(null)
return
}
let isMounted = true
setIsLoading(true)
setError(null)
setSuccessMessage(null)
setActiveTab('overview')
Promise.all([
fetchTourDetail(tourId),
fetchTourTranslations(tourId).catch(() => null),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
.then(([detail, trans, itinTrans]) => {
if (!isMounted) return
setTour(detail)
setTransData(trans)
setItinTransList(itinTrans || [])
const primaryLang = (trans?.primary_language || 'fa') as LangCode
setSelectedLang(primaryLang)
initDraftForLanguage(primaryLang, trans, itinTrans || [], detail)
setIsLoading(false)
})
.catch((err: any) => {
if (isMounted) {
setError(err?.message || 'خطا در بارگذاری اطلاعات کامل تور')
setIsLoading(false)
}
})
return () => {
isMounted = false
}
}, [tourId, isOpen])
// Sync draft fields when language changes
const initDraftForLanguage = (
lang: LangCode,
translations: TourTranslationsResponse | null,
itinList: TourItineraryTranslationsResponseItem[],
tourDetail: TourDetailItem | null
) => {
if (translations) {
const isPrimary = translations.primary_language === lang
const existing = isPrimary
? {
title: translations.primary_data.title || tourDetail?.title || '',
description: translations.primary_data.description || tourDetail?.description || '',
tour_feature: translations.primary_data.tour_feature || [],
travel_tips: translations.primary_data.travel_tips || {},
}
: translations.translations[lang] || {
title: '',
description: '',
tour_feature: [],
travel_tips: {},
}
setDraftTitle(existing.title || '')
setDraftDesc(existing.description || '')
setDraftFeatures((existing.tour_feature || []).map((f) => f.title))
setDraftTips(
Object.entries(existing.travel_tips || {}).map(([title, desc]) => ({
title,
desc: String(desc),
}))
)
} else if (tourDetail) {
setDraftTitle(tourDetail.title || '')
setDraftDesc(tourDetail.description || '')
setDraftFeatures((tourDetail.tour_features || []).map((f) => f.title))
setDraftTips(
(tourDetail.travel_tips || []).map((t) => ({
title: t.title,
desc: t.description,
}))
)
}
// 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 || (lang === translations?.primary_language ? it.title : ''),
summary: itTrans?.summary || (lang === translations?.primary_language ? it.summary : ''),
}
})
setItinDrafts(drafts)
}
const handleLanguageSwitch = (lang: LangCode) => {
setSelectedLang(lang)
setSuccessMessage(null)
initDraftForLanguage(lang, transData, itinTransList, tour)
}
// Load sub-tabs (passengers, comments, itineraries) on demand
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])
// Save tour translation for base overview
const handleSaveBaseTranslation = async () => {
if (!tourId) return
setIsSaving(true)
setSuccessMessage(null)
try {
await saveTourTranslations(tourId, {
lang_code: selectedLang,
title: draftTitle.trim(),
description: draftDesc.trim(),
})
const updatedTrans = await fetchTourTranslations(tourId)
setTransData(updatedTrans)
setSuccessMessage(`ترجمه مشخصات تور برای زبان ${activeLangConfig.label} با موفقیت ذخیره شد.`)
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در ذخیره ترجمه مشخصات تور')
} finally {
setIsSaving(false)
}
}
// Save specific itinerary step translation
const handleSaveItineraryStep = async (itineraryId: number) => {
if (!tourId) return
const draft = itinDrafts[itineraryId]
if (!draft) return
setIsSaving(true)
setSavedItinId(itineraryId)
try {
await saveTourItineraryTranslation(tourId, {
itinerary_id: itineraryId,
lang_code: selectedLang,
title: draft.title.trim(),
summary: draft.summary.trim(),
})
const updatedItinTrans = await fetchTourItineraryTranslations(tourId)
setItinTransList(updatedItinTrans)
setSuccessMessage(`ترجمه مرحله سفر (${draft.title || itineraryId}) برای زبان ${activeLangConfig.label} ذخیره شد.`)
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در ذخیره ترجمه مرحله سفر')
} finally {
setIsSaving(false)
setSavedItinId(null)
}
}
// Save features & tips translation
const handleSaveFeaturesAndTips = async () => {
if (!tourId) return
setIsSaving(true)
setSuccessMessage(null)
try {
const tour_feature = draftFeatures.map((title) => ({ title }))
const travel_tips: Record<string, string> = {}
draftTips.forEach((t) => {
travel_tips[t.title] = t.desc
})
await saveTourTranslations(tourId, {
lang_code: selectedLang,
tour_feature,
travel_tips,
})
const updatedTrans = await fetchTourTranslations(tourId)
setTransData(updatedTrans)
setSuccessMessage(`امکانات و نکات راهنما برای زبان ${activeLangConfig.label} با موفقیت ذخیره شد.`)
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در ذخیره امکانات و نکات راهنما')
} finally {
setIsSaving(false)
}
}
const activeLangConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang) || SUPPORTED_LANGUAGES[0]
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl w-full p-0 overflow-hidden max-h-[90vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-xl">
{/* Header Modal */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border-soft bg-card/40 backdrop-blur-sm shrink-0">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="map" className="size-5" />
</div>
<div className="space-y-0.5 text-start">
<div className="flex items-center gap-2">
<DialogTitle className="text-base font-extrabold text-foreground">
{tour?.title || 'جزئیات و ترجمه‌های تور'}
</DialogTitle>
</div>
<DialogDescription className="text-xs text-grey-3 font-mono" dir="ltr">
#{tourId} &bull; {tour?.slug}
</DialogDescription>
</div>
</div>
{/* Badges & Close */}
<div className="flex items-center gap-2 self-start sm:self-auto">
{tour && (
<>
<CountryFlagBadge countryCode={tour.destination_country} />
<TourStatusBadge status={tour.status} type="sale" />
<TourStatusBadge status={tour.trip_status} type="trip" />
</>
)}
<button
type="button"
onClick={onClose}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer mr-1"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
</div>
{/* Integrated 5-Language Selector Bar */}
<div className="mt-4 flex items-center justify-between gap-2 overflow-x-auto pt-2 border-t border-border-soft/60">
<div className="flex items-center gap-1.5 shrink-0 text-xs text-grey-3">
<Ic name="globe" className="size-3.5 text-primary" />
<span className="font-bold">زبان فعال محتوا:</span>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar pb-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedLang
const isPrimary = transData?.primary_language === lang.code
const hasTrans = Boolean(
isPrimary ||
transData?.translations[lang.code]?.title ||
transData?.translations[lang.code]?.description
)
return (
<button
key={lang.code}
type="button"
onClick={() => handleLanguageSwitch(lang.code)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer border ${
isSelected
? 'bg-primary text-white border-primary shadow-xs'
: hasTrans
? 'bg-card/70 border-border-soft text-foreground hover:border-primary/40'
: 'bg-surface-base border-dashed border-border-soft text-grey-3 hover:text-foreground'
}`}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
{isPrimary && (
<span className={`text-[9px] px-1 py-0.2 rounded-full ${isSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
پایه
</span>
)}
</button>
)
})}
</div>
</div>
{/* Main Navigation Tabs */}
<Tabs
value={activeTab}
onValueChange={(val: any) => setActiveTab(val)}
className="mt-3 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 gap-1.5">
<Ic name="file" className="size-3.5" />
<span>مشخصات و متون</span>
</TabsTrigger>
<TabsTrigger value="itinerary" className="text-xs gap-1.5">
<Ic name="calendar" className="size-3.5" />
<span>برنامه سفر</span>
</TabsTrigger>
<TabsTrigger value="features" className="text-xs gap-1.5">
<Ic name="star" className="size-3.5" />
<span>امکانات و نکات</span>
</TabsTrigger>
<TabsTrigger value="passengers" className="text-xs gap-1.5">
<Ic name="users" className="size-3.5" />
<span>مسافران</span>
</TabsTrigger>
<TabsTrigger value="comments" className="text-xs gap-1.5">
<Ic name="message" className="size-3.5" />
<span>نظرات</span>
</TabsTrigger>
</TabsList>
</Tabs>
</DialogHeader>
{/* Modal Scrollable Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{successMessage && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-3 text-xs font-bold text-emerald-600 dark:text-emerald-400 flex items-center justify-between">
<div className="flex items-center gap-2">
<Ic name="check" className="size-4" />
<span>{successMessage}</span>
</div>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-emerald-600"
onClick={() => setSuccessMessage(null)}
>
&times;
</Button>
</div>
)}
{isLoading ? (
<Shimmer rows={6} withCard={false} />
) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-8 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">
{/* بخش ترجمه پویا برای عنوان و توضیحات تور */}
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<span>{activeLangConfig.flag}</span>
<span>محتوای متنی تور به زبان {activeLangConfig.label} ({activeLangConfig.code})</span>
</div>
<span className="text-[11px] text-grey-3">
جهت متن: <span className="font-bold font-mono">{activeLangConfig.dir.toUpperCase()}</span>
</span>
</div>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">عنوان تور ({activeLangConfig.label}):</label>
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder={`عنوان تور را به زبان ${activeLangConfig.label} وارد نمایید...`}
dir={activeLangConfig.dir}
className="bg-card font-medium text-xs rounded-xl border-border-soft"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">توضیحات کامل تور ({activeLangConfig.label}):</label>
<textarea
rows={4}
value={draftDesc}
onChange={(e) => setDraftDesc(e.target.value)}
placeholder={`توضیحات تفصیلی تور به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-3 text-xs leading-relaxed text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex justify-end pt-1">
<Button
size="sm"
disabled={isSaving}
onClick={handleSaveBaseTranslation}
className="gap-1.5 text-xs font-bold rounded-xl"
>
<Ic name="save" className={`size-3.5 ${isSaving ? 'animate-spin' : ''}`} />
<span>ذخیره تغییرات متنی ({activeLangConfig.label})</span>
</Button>
</div>
</div>
</div>
{/* گالری تصاویر تور */}
{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?.md || img.image_url?.medium || img.image_url?.lg || img.image_url?.large || img.image_url?.original || '/placeholder.jpg'}
alt=""
className="h-full w-full object-cover"
/>
</div>
))}
</div>
</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 7+)</span>
<div className="mt-1 text-sm font-black text-foreground font-mono">
${parseFloat(tour.price || '0').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 || '0').toLocaleString()}
</span>
</div>
)}
<span className="text-[10px] text-grey-3 block mt-1">خدمات کامل بزرگسال (۷ سال به بالا)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">کودک (۲ تا ۷ سال / Child 2-7)</span>
<div className="mt-1 text-sm font-black text-foreground font-mono">
${parseFloat(tour.price_child || '0').toLocaleString()}
</div>
<span className="text-[10px] text-grey-3 block mt-1">صندلی و خدمات کودک (۲ تا ۷ سال کامل)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">نوزاد (۰ تا ۲ سال / Infant 0-2)</span>
<div className="mt-1 text-sm font-black text-foreground font-mono">
${parseFloat(tour.price_infant || '0').toLocaleString()}
</div>
<span className="text-[10px] text-grey-3 block mt-1">بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل)</span>
</div>
</div>
</div>
</div>
)}
{/* تب ۲: برنامه سفر روزانه و ترجمه‌های هر مرحله */}
{activeTab === 'itinerary' && (
<div className="space-y-4">
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft text-xs">
<div className="flex items-center gap-2">
<span>{activeLangConfig.flag}</span>
<span className="font-bold text-foreground">
ترجمه مراحل برنامه سفر به زبان {activeLangConfig.label} ({activeLangConfig.code})
</span>
</div>
<span className="text-grey-3 text-[11px]">
تعداد مراحل: {itinTransList.length || itineraries.length}
</span>
</div>
{isLoadingSub ? (
<Shimmer rows={3} withCard={false} />
) : (itinTransList.length === 0 && 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-4">
{(itinTransList.length > 0 ? itinTransList : itineraries).map((item, idx) => {
const itId = item.id
const draft = itinDrafts[itId] || { title: item.title || '', summary: (item as any).summary || '' }
const isThisSaving = isSaving && savedItinId === itId
return (
<div
key={itId || idx}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3 transition hover:border-primary/40"
>
<div className="flex items-center justify-between">
<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 font-mono">
{idx + 1}
</span>
<span className="text-xs font-bold text-grey-3">
مرحله #{itId} &bull; {(item as any).started_at ? new Date((item as any).started_at).toLocaleDateString('fa-IR') : `روز ${idx + 1}`}
</span>
</div>
<Button
size="sm"
disabled={isThisSaving}
onClick={() => handleSaveItineraryStep(itId)}
className="h-7 px-3 text-[11px] font-bold rounded-xl gap-1"
>
<Ic name="save" className={`size-3 ${isThisSaving ? 'animate-spin' : ''}`} />
<span>ذخیره مرحله</span>
</Button>
</div>
<div className="space-y-2">
<Input
value={draft.title}
onChange={(e) => {
const val = e.target.value
setItinDrafts((prev) => ({
...prev,
[itId]: { ...prev[itId], title: val, summary: prev[itId]?.summary || '' },
}))
}}
placeholder={`عنوان مرحله ${idx + 1} به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs font-bold rounded-xl border-border-soft"
/>
<textarea
rows={2}
value={draft.summary}
onChange={(e) => {
const val = e.target.value
setItinDrafts((prev) => ({
...prev,
[itId]: { ...prev[itId], title: prev[itId]?.title || '', summary: val },
}))
}}
placeholder={`خلاصه برنامه این روز به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-xs leading-relaxed text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary"
/>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* تب ۳: امکانات و نکات راهنما و ترجمه‌های مربوطه */}
{activeTab === 'features' && (
<div className="space-y-6">
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft text-xs">
<div className="flex items-center gap-2">
<span>{activeLangConfig.flag}</span>
<span className="font-bold text-foreground">
امکانات و راهنمای سفر به زبان {activeLangConfig.label} ({activeLangConfig.code})
</span>
</div>
<Button
size="sm"
disabled={isSaving}
onClick={handleSaveFeaturesAndTips}
className="gap-1.5 text-xs font-bold rounded-xl h-7 px-3"
>
<Ic name="save" className={`size-3.5 ${isSaving ? 'animate-spin' : ''}`} />
<span>ذخیره امکانات و نکات</span>
</Button>
</div>
{/* ویژگی‌ها و امکانات (Features) */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<h4 className="text-xs font-extrabold text-foreground">
امکانات و خدمات تور ({activeLangConfig.label})
</h4>
<div className="flex flex-wrap gap-2">
{draftFeatures.map((feat, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 rounded-xl border border-border-soft bg-surface-base px-3 py-1.5 text-xs font-bold text-foreground"
dir={activeLangConfig.dir}
>
<span>{feat}</span>
<button
type="button"
onClick={() => setDraftFeatures((prev) => prev.filter((_, i) => i !== idx))}
className="text-grey-3 hover:text-rose-500 cursor-pointer"
>
&times;
</button>
</div>
))}
</div>
<div className="flex items-center gap-2 pt-2">
<Input
value={newFeatureInput}
onChange={(e) => setNewFeatureInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (newFeatureInput.trim()) {
setDraftFeatures((prev) => [...prev, newFeatureInput.trim()])
setNewFeatureInput('')
}
}
}}
placeholder={`افزودن امکان جدید به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs rounded-xl border-border-soft"
/>
<Button
size="sm"
variant="outline"
onClick={() => {
if (newFeatureInput.trim()) {
setDraftFeatures((prev) => [...prev, newFeatureInput.trim()])
setNewFeatureInput('')
}
}}
className="shrink-0 text-xs rounded-xl"
>
+ افزودن
</Button>
</div>
</div>
{/* راهنمای سفر و نکات (Travel Tips) */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<h4 className="text-xs font-extrabold text-foreground">
نکات و راهنمای سفر ({activeLangConfig.label})
</h4>
<div className="space-y-2">
{draftTips.map((tip, idx) => (
<div
key={idx}
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1"
dir={activeLangConfig.dir}
>
<div className="flex items-center justify-between">
<span className="font-bold text-xs text-foreground">{tip.title}</span>
<button
type="button"
onClick={() => setDraftTips((prev) => prev.filter((_, i) => i !== idx))}
className="text-grey-3 hover:text-rose-500 cursor-pointer text-xs"
>
حذف
</button>
</div>
<p className="text-xs text-grey-2 leading-relaxed">{tip.desc}</p>
</div>
))}
</div>
<div className="rounded-xl border border-dashed border-border-soft bg-card/20 p-3 space-y-2 pt-3">
<span className="text-[11px] font-bold text-grey-3">افزودن نکته جدید:</span>
<Input
value={newTipTitle}
onChange={(e) => setNewTipTitle(e.target.value)}
placeholder={`عنوان نکته به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs rounded-xl border-border-soft"
/>
<textarea
rows={2}
value={newTipDesc}
onChange={(e) => setNewTipDesc(e.target.value)}
placeholder={`شرح راهنما به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-2 text-xs text-foreground focus:outline-hidden"
/>
<Button
size="sm"
variant="outline"
onClick={() => {
if (newTipTitle.trim() && newTipDesc.trim()) {
setDraftTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }])
setNewTipTitle('')
setNewTipDesc('')
}
}}
className="text-xs rounded-xl"
>
+ ثبت نکته
</Button>
</div>
</div>
</div>
)}
{/* تب ۴: مسافران (Passengers) */}
{activeTab === 'passengers' && (
<div className="space-y-4">
{isLoadingSub ? (
<Shimmer rows={2} withCard={false} />
) : 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-3">
{passengersGroup.map((group, idx) => (
<div
key={group.user_id || idx}
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 text-xs">
<span className="font-bold text-foreground">
سرگروه رزرو: {group.user}
</span>
<span className="text-grey-3 font-mono">
{group.passengers?.length || 0} مسافر
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{group.passengers?.map((p) => (
<div
key={p.id}
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1 text-xs"
>
<div className="font-bold text-foreground">{p.fullname}</div>
<div className="flex justify-between text-grey-3 font-mono text-[11px]" dir="ltr">
<span>Passport: {p.passport_number || '-'}</span>
<span>DOB: {p.birthdate || '-'}</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* تب ۵: نظرات کاربران (Comments) */}
{activeTab === 'comments' && (
<div className="space-y-4">
{isLoadingSub ? (
<Shimmer rows={2} withCard={false} />
) : comments.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="message" className="mx-auto size-8 mb-2 opacity-50" />
<p className="text-xs">تاکنون نظری برای این تور ثبت نشده است.</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2"
>
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-foreground">{comment.user}</span>
<div className="flex items-center gap-1 text-amber-500 font-mono font-bold">
<span>★</span>
<span>{comment.score}</span>
</div>
</div>
<p className="text-xs text-grey-2 leading-relaxed">{comment.text}</p>
</div>
))}
</div>
)}
</div>
)}
</>
) : null}
</div>
</DialogContent>
</Dialog>
)
}

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

@ -24,6 +24,8 @@ import type {
} from '../types' } from '../types'
import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge' import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge'
import { TourTranslationsDialog } from './TourTranslationsDialog' import { TourTranslationsDialog } from './TourTranslationsDialog'
import { useTranslation } from '@/i18n'
import { formatDateByLanguage } from '@/lib/date-utils'
interface TourDetailSheetProps { interface TourDetailSheetProps {
tourId: number | null tourId: number | null
@ -32,6 +34,7 @@ interface TourDetailSheetProps {
} }
export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProps) { export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProps) {
const { language } = useTranslation()
const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'passengers' | 'features' | 'comments'>('overview') const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'passengers' | 'features' | 'comments'>('overview')
const [isTransDialogOpen, setIsTransDialogOpen] = useState<boolean>(false) const [isTransDialogOpen, setIsTransDialogOpen] = useState<boolean>(false)
@ -220,11 +223,15 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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="rounded-xl border border-border-soft bg-card/30 p-3 text-center">
<div className="text-[11px] text-grey-3">تاریخ شروع</div> <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 className="mt-1 font-bold text-xs text-foreground">
{formatDateByLanguage(tour.started_at, language as any)}
</div>
</div> </div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> <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="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 className="mt-1 font-bold text-xs text-foreground">
{formatDateByLanguage(tour.ended_at, language as any)}
</div>
</div> </div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> <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="text-[11px] text-grey-3">ظرفیت کل</div>
@ -263,7 +270,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<h4 className="text-xs font-extrabold text-foreground">جدول نرخ‌های مصوب</h4> <h4 className="text-xs font-extrabold text-foreground">جدول نرخ‌های مصوب</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <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"> <div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">بزرگسال (Adult 12+)</span>
<span className="text-[11px] text-grey-3">بزرگسال (۷+ سال / Adult 7+)</span>
<div className="mt-1 text-sm font-black text-foreground"> <div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price).toLocaleString()} ${parseFloat(tour.price).toLocaleString()}
</div> </div>
@ -277,22 +284,23 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
</span> </span>
</div> </div>
)} )}
<span className="text-[10px] text-grey-3 block mt-1">خدمات کامل بزرگسال (۷ سال به بالا)</span>
</div> </div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> <div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">کودک (Child 2-12)</span>
<span className="text-[11px] text-grey-3">کودک (۲ تا ۷ سال / Child 2-7)</span>
<div className="mt-1 text-sm font-black text-foreground"> <div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price_child || '0').toLocaleString()} ${parseFloat(tour.price_child || '0').toLocaleString()}
</div> </div>
<span className="text-[10px] text-grey-3">صندلی و خدمات کودک</span>
<span className="text-[10px] text-grey-3 block mt-1">صندلی و خدمات کودک (۲ تا ۷ سال کامل)</span>
</div> </div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> <div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">نوزاد (Infant &lt;2)</span>
<span className="text-[11px] text-grey-3">نوزاد (۰ تا ۲ سال / Infant 0-2)</span>
<div className="mt-1 text-sm font-black text-foreground"> <div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price_infant || '0').toLocaleString()} ${parseFloat(tour.price_infant || '0').toLocaleString()}
</div> </div>
<span className="text-[10px] text-grey-3">بیمه و ترانسفر نوزاد</span>
<span className="text-[10px] text-grey-3 block mt-1">بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل)</span>
</div> </div>
</div> </div>
</div> </div>
@ -323,8 +331,8 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
</span> </span>
<h5 className="text-xs font-bold text-foreground">{step.title}</h5> <h5 className="text-xs font-bold text-foreground">{step.title}</h5>
</div> </div>
<div className="text-[11px] text-grey-3 font-mono" dir="ltr">
{step.started_at ? new Date(step.started_at).toLocaleDateString('fa-IR') : '-'}
<div className="text-[11px] text-grey-3 font-medium">
{step.started_at ? formatDateByLanguage(step.started_at, language as any) : '-'}
</div> </div>
</div> </div>
<p className="mt-2 text-xs leading-relaxed text-grey-2 pr-8"> <p className="mt-2 text-xs leading-relaxed text-grey-2 pr-8">

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

@ -183,9 +183,10 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
} }
} }
export function CountryFlagBadge({ countryCode }: { countryCode: string | null }) {
export function CountryFlagBadge({ countryCode }: { countryCode?: string | null }) {
if (!countryCode) return <span className="text-grey-3 text-xs">-</span> if (!countryCode) return <span className="text-grey-3 text-xs">-</span>
const getCountryName = (code: string) => { const getCountryName = (code: string) => {
switch (code.toUpperCase()) { switch (code.toUpperCase()) {
case 'IQ': case 'IQ':

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

@ -196,6 +196,10 @@ export function TourTranslationsDialog({
const handleSave = async () => { const handleSave = async () => {
if (!tourId || !transData) return if (!tourId || !transData) return
if (!formTitle.trim() && !isPrimary) {
setErrorMessage(`وارد کردن عنوان تور برای زبان ${activeLangConfig?.label || selectedLang} اجباری است.`)
return
}
setIsSaving(true) setIsSaving(true)
setErrorMessage(null) setErrorMessage(null)
setSuccessMessage(null) setSuccessMessage(null)
@ -273,16 +277,29 @@ export function TourTranslationsDialog({
</div> </div>
</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 className="flex items-center gap-2">
{/* 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>
)}
{/* دکمه ضربدر بالای مودال */}
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer mr-1"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
</div> </div>
{/* 5-Language Selector Bar */} {/* 5-Language Selector Bar */}

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

@ -143,3 +143,34 @@ export async function saveTourItineraryTranslation(
}) })
} }
export interface CreateTourPayload {
title: string
description?: string
started_at: string
ended_at: string
capacity: number
price: string
price_child?: string
price_infant?: string
percent_off?: number
destination_country_code?: string
origin_country_code?: string
language_code?: string
status?: string
trip_status?: string
tour_feature?: { title: string }[]
travel_tips?: Record<string, string>
visibility?: boolean
translations?: Record<string, import('../types').TourTranslationData>
}
/**
* ایجاد تور جدید به همراه ترجمه‌های چندزبانه
*/
export async function createTour(payload: CreateTourPayload): Promise<TourDetailItem> {
return apiFetch<TourDetailItem>('tours/', {
method: 'POST',
body: payload,
})
}

1
src/features/tours/types.ts

@ -68,6 +68,7 @@ export interface TourDetailItem {
final_price: string final_price: string
images: TourImageItem[] images: TourImageItem[]
trip_status: TourTripStatus trip_status: TourTripStatus
destination_country?: string | null
is_access: boolean is_access: boolean
} }

4
src/icons.tsx

@ -92,10 +92,14 @@ const PATHS: Record<string, string> = {
book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/><path d="M6 6h10"/><path d="M6 10h10"/>', book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/><path d="M6 6h10"/><path d="M6 10h10"/>',
shrine: '<path d="M12 2a4 4 0 0 0-4 4c0 3 4 5 4 5s4-2 4-5a4 4 0 0 0-4-4Z"/><path d="M4 22h16"/><path d="M6 22V13a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v9"/><path d="M10 22v-5a2 2 0 0 1 4 0v5"/>', shrine: '<path d="M12 2a4 4 0 0 0-4 4c0 3 4 5 4 5s4-2 4-5a4 4 0 0 0-4-4Z"/><path d="M4 22h16"/><path d="M6 22V13a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v9"/><path d="M10 22v-5a2 2 0 0 1 4 0v5"/>',
helpCircle: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>', helpCircle: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
message: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
} }
export type IconName = keyof typeof PATHS export type IconName = keyof typeof PATHS
export type IcProps = React.ComponentProps<'svg'> & { name: IconName } export type IcProps = React.ComponentProps<'svg'> & { name: IconName }
export function Ic({ name, className, ...rest }: IcProps) { export function Ic({ name, className, ...rest }: IcProps) {

2
src/index.css

@ -14,7 +14,7 @@
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--secondary: #2c303b; --secondary: #2c303b;
--secondary-foreground: #ebebeb; --secondary-foreground: #ebebeb;
--muted: #22252c;
--muted: #2c303b;
--muted-foreground: #8b8b8b; --muted-foreground: #8b8b8b;
--accent: #373c49; --accent: #373c49;
--accent-foreground: #ffffff; --accent-foreground: #ffffff;

486
src/lib/date-utils.ts

@ -0,0 +1,486 @@
import { useLanguageStore, type SupportedLanguage } from '@/i18n'
import { toFa } from './utils'
/* ──────────────────────────────────────────────────────────────────────────
* سیستم جامع تبدیل و نمایش تاریخ‌های چندزبانه در پنل مدیریت عقیله
*
* منطق تبدیل تقویم‌ها:
* ۱. زبان فارسی ('fa') → هجری شمسی (جلالی / Solar Hijri)
* ۲. زبان عربی ('ar') → هجری قمری (تقویم اسلامی أم القری / Islamic Hijri)
* ۳. انگلیسی ('en') و سایر → میلادی (گریگوری / Gregorian)
*
* داده‌های پایه بک‌اند و مقادیر فرم‌ها همواره در استاندارد ISO YYYY-MM-DD ذخیره می‌شوند.
* ────────────────────────────────────────────────────────────────────────── */
export type DateFormatStyle = 'short' | 'medium' | 'long' | 'numeric'
export interface FormatDateOptions {
style?: DateFormatStyle
withTime?: boolean
withSeconds?: boolean
showEra?: boolean
}
/**
* پارس امن تاریخ بدون مشکل شیفت منطقه زمانی (Timezone Offset)
*/
export function parseSafeDate(input: string | number | Date | null | undefined): Date | null {
if (!input) return null
if (input instanceof Date) return isNaN(input.getTime()) ? null : input
const str = String(input).trim()
if (!str) return null
// الگوی YYYY-MM-DD یا YYYY/MM/DD
const matchOnlyDate = str.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/)
if (matchOnlyDate) {
const y = parseInt(matchOnlyDate[1], 10)
const m = parseInt(matchOnlyDate[2], 10) - 1
const d = parseInt(matchOnlyDate[3], 10)
// استفاده از ساعت ۱۲ ظهر برای جلوگیری از تغییر روز در شیفت‌های تابستانی/زمستانی
return new Date(y, m, d, 12, 0, 0)
}
const parsed = new Date(str)
return isNaN(parsed.getTime()) ? null : parsed
}
/**
* قالب‌بندی یک تاریخ مشخص بر اساس زبان انتخابی (یا پیش‌فرض سامانه)
*/
export function formatDateByLanguage(
dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa',
options: FormatDateOptions = {}
): string {
const d = parseSafeDate(dateInput)
if (!d) return '—'
const style = options.style || 'medium'
try {
if (lang === 'fa') {
if (style === 'numeric') {
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: style === 'short' ? 'short' : 'long',
day: 'numeric',
}).format(d)
}
if (lang === 'ar') {
if (style === 'numeric') {
return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura', {
year: 'numeric',
month: style === 'short' ? 'short' : 'long',
day: 'numeric',
}).format(d)
}
// انگلیسی و سایر زبان‌ها (میلادی)
if (style === 'numeric') {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: style === 'short' ? 'short' : 'long',
day: 'numeric',
}).format(d)
} catch {
return d.toISOString().split('T')[0]
}
}
/**
* قالب‌بندی بازه زمانی تاریخ (شروع تا پایان) بدون باگ جهت متن (BiDi)
*/
export function formatDateRangeByLanguage(
startDateInput: string | number | Date | null | undefined,
endDateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa',
options: FormatDateOptions = {}
): string {
const s = parseSafeDate(startDateInput)
const e = parseSafeDate(endDateInput)
if (!s && !e) return '—'
if (s && !e) return formatDateByLanguage(s, lang, options)
if (!s && e) return formatDateByLanguage(e, lang, options)
const startFormatted = formatDateByLanguage(s, lang, options)
const endFormatted = formatDateByLanguage(e, lang, options)
if (lang === 'fa') {
return `${startFormatted} تا ${endFormatted}`
}
if (lang === 'ar') {
return `${startFormatted} إلى ${endFormatted}`
}
return `${startFormatted} – ${endFormatted}`
}
/**
* قالب‌بندی تاریخ و ساعت به صورت همزمان
*/
export function formatDateTimeByLanguage(
dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa',
options: FormatDateOptions = {}
): string {
const d = parseSafeDate(dateInput)
if (!d) return '—'
const dateStr = formatDateByLanguage(d, lang, { ...options, style: options.style || 'numeric' })
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const timeStr = `${hours}:${minutes}`
if (lang === 'fa') {
return `${dateStr}، ${toFa(timeStr)}`
}
if (lang === 'ar') {
return `${dateStr}، ${timeStr}`
}
return `${dateStr} ${timeStr}`
}
/* ──────────────────────────────────────────────────────────────────────────
* React Hooks برای اشتراک خودکار با زبان فعال سیستم (Reactive Localization)
* ────────────────────────────────────────────────────────────────────────── */
/**
* هوک نمایش تاریخ منطبق بر زبان فعال پنل
*/
export function useFormattedDate(
dateInput: string | number | Date | null | undefined,
options: FormatDateOptions = {}
): string {
const currentLang = useLanguageStore((s) => s.language)
return formatDateByLanguage(dateInput, currentLang, options)
}
/**
* هوک نمایش بازه تاریخ (شروع و پایان) منطبق بر زبان فعال پنل
*/
export function useFormattedDateRange(
startDateInput: string | number | Date | null | undefined,
endDateInput: string | number | Date | null | undefined,
options: FormatDateOptions = {}
): string {
const currentLang = useLanguageStore((s) => s.language)
return formatDateRangeByLanguage(startDateInput, endDateInput, currentLang, options)
}
/**
* هوک نمایش تاریخ + ساعت منطبق بر زبان فعال پنل
*/
export function useFormattedDateTime(
dateInput: string | number | Date | null | undefined,
options: FormatDateOptions = {}
): string {
const currentLang = useLanguageStore((s) => s.language)
return formatDateTimeByLanguage(dateInput, currentLang, options)
}
/* ──────────────────────────────────────────────────────────────────────────
* الگوریتم‌های دقیق تبدیل تقویم‌ها جهت استفاده در کامپوننت انتخابگر تاریخ (Calendar Picker)
* ────────────────────────────────────────────────────────────────────────── */
// ۱. بررسی سال کبیسه جلالی
export function isJalaliLeapYear(jy: number): boolean {
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]
const bl = breaks.length
let jp = breaks[0]
if (jy < jp || jy >= breaks[bl - 1]) return false
let jump = 0
for (let i = 1; i < bl; i++) {
const jm = breaks[i]
jump = jm - jp
if (jy < jm) break
jp = jm
}
let n = jy - jp
if (jump - n < 6) n = n - jump + Math.floor((jump + 4) / 33) * 33
let leap = (((n + 1) % 33) - 1) % 4
if (leap === -1) leap = 4
return leap === 0
}
export function getJalaliDaysInMonth(jy: number, jm: number): number {
if (jm <= 6) return 31
if (jm <= 11) return 30
return isJalaliLeapYear(jy) ? 30 : 29
}
export function jalaliToGregorian(jy: number, jm: number, jd: number): { year: number; month: number; day: number } {
let gy = jy <= 979 ? 621 : 1600
jy -= jy <= 979 ? 0 : 979
let days =
365 * jy +
Math.floor(jy / 33) * 8 +
Math.floor(((jy % 33) + 3) / 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186)
gy += 400 * Math.floor(days / 146097)
days %= 146097
if (days > 36524) {
gy += 100 * Math.floor(--days / 36524)
days %= 36524
if (days >= 365) days++
}
gy += 4 * Math.floor(days / 1461)
days %= 1461
if (days > 365) {
gy += Math.floor((days - 1) / 365)
days = (days - 1) % 365
}
const sal_a = [0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
let gm = 0
while (gm < 13 && days >= sal_a[gm]) {
days -= sal_a[gm]
gm++
}
const gd = days + 1
return { year: gy, month: gm, day: gd }
}
export function gregorianToJalali(gy: number, gm: number, gd: number): { year: number; month: number; day: number } {
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
let jy = gy <= 1600 ? 0 : 979
gy -= gy <= 1600 ? 621 : 1600
const gy2 = gm > 2 ? gy + 1 : gy
let days =
365 * gy +
Math.floor((gy2 + 3) / 4) -
Math.floor((gy2 + 99) / 100) +
Math.floor((gy2 + 399) / 400) -
80 +
gd +
g_d_m[gm - 1]
jy += 33 * Math.floor(days / 12053)
days %= 12053
jy += 4 * Math.floor(days / 1461)
days %= 1461
if (days > 365) {
jy += Math.floor((days - 1) / 365)
days = (days - 1) % 365
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30)
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30)
return { year: jy, month: jm, day: jd }
}
// ۲. تبدیل هجری قمری به میلادی و برعکس
export function gregorianToHijri(date: Date): { year: number; month: number; day: number } {
try {
const fmt = new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
})
const parts = fmt.formatToParts(date)
const m: Record<string, string> = {}
for (const p of parts) m[p.type] = p.value
return {
year: parseInt(m.year, 10) || 1448,
month: parseInt(m.month, 10) || 1,
day: parseInt(m.day, 10) || 1,
}
} catch {
return { year: 1448, month: 1, day: 1 }
}
}
export function hijriToGregorian(hy: number, hm: number, hd: number): { year: number; month: number; day: number } {
const fmt = new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
})
const estJulianDay = Math.floor((hy - 1) * 354.367056) + Math.floor((hm - 1) * 29.5) + hd + 1948439
const estGregMs = (estJulianDay - 2440587.5) * 86400000
const candidate = new Date(estGregMs)
for (let offset = -10; offset <= 10; offset++) {
const testDate = new Date(candidate.getTime() + offset * 86400000)
const parts = fmt.formatToParts(testDate)
const m: Record<string, string> = {}
for (const p of parts) m[p.type] = p.value
if (parseInt(m.year, 10) === hy && parseInt(m.month, 10) === hm && parseInt(m.day, 10) === hd) {
return {
year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1,
day: testDate.getUTCDate(),
}
}
}
return {
year: candidate.getUTCFullYear(),
month: candidate.getUTCMonth() + 1,
day: candidate.getUTCDate(),
}
}
/* ──────────────────────────────────────────────────────────────────────────
* اطلاعات متای تقویم‌ها برای رندر در ادیتورها (Calendar Meta & Names)
* ────────────────────────────────────────────────────────────────────────── */
export const JALALI_MONTH_NAMES = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
]
export const HIJRI_MONTH_NAMES = [
'محرم',
'صفر',
'ربيع الأول',
'ربيع الآخر',
'جمادى الأولى',
'جمادى الآخرة',
'رجب',
'شعبان',
'رمضان',
'شوال',
'ذو القعدة',
'ذو الحجة',
]
export const GREGORIAN_MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
export const WEEKDAY_NAMES_FA = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج']
export const WEEKDAY_NAMES_AR = ['س', 'ح', 'ن', 'ث', 'ر', 'خ', 'ج']
export const WEEKDAY_NAMES_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
/**
* دریافت اطلاعات و روزهای ماه جهت ساخت ماتریس گرید تقویم در ادیتور
*/
export function getCalendarMonthData(
calendar: 'persian' | 'islamic' | 'gregorian',
year: number,
month: number // 1-based (1..12)
) {
if (calendar === 'persian') {
const daysInMonth = getJalaliDaysInMonth(year, month)
const gFirst = jalaliToGregorian(year, month, 1)
const dFirst = new Date(gFirst.year, gFirst.month - 1, gFirst.day)
// 0 = Sunday, 6 = Saturday in JS
const jsDay = dFirst.getDay()
const startOffset = (jsDay + 1) % 7 // 0 = شنبه
return {
daysInMonth,
startOffset,
monthName: JALALI_MONTH_NAMES[month - 1] || '',
weekdays: WEEKDAY_NAMES_FA,
yearFormatted: toFa(year),
toIso: (d: number) => {
const g = jalaliToGregorian(year, month, d)
return `${g.year}-${String(g.month).padStart(2, '0')}-${String(g.day).padStart(2, '0')}`
},
formatDayNumber: (d: number) => toFa(d),
}
}
if (calendar === 'islamic') {
const gFirst = hijriToGregorian(year, month, 1)
const dFirst = new Date(Date.UTC(gFirst.year, gFirst.month - 1, gFirst.day))
const jsDay = dFirst.getUTCDay()
const startOffset = (jsDay + 1) % 7 // 0 = السبت
// محاسبه تعداد روزهای ماه هجری (۲۹ یا ۳۰ روز)
const fmt = new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
})
let daysInMonth = 29
for (let d = 1; d <= 31; d++) {
const testDate = new Date(dFirst.getTime() + (d - 1) * 86400000)
const parts = fmt.formatToParts(testDate)
const m: Record<string, string> = {}
for (const p of parts) m[p.type] = p.value
if (parseInt(m.month, 10) === month && parseInt(m.year, 10) === year) {
daysInMonth = parseInt(m.day, 10)
} else {
break
}
}
return {
daysInMonth,
startOffset,
monthName: HIJRI_MONTH_NAMES[month - 1] || '',
weekdays: WEEKDAY_NAMES_AR,
yearFormatted: `${year} هـ`,
toIso: (d: number) => {
const g = hijriToGregorian(year, month, d)
return `${g.year}-${String(g.month).padStart(2, '0')}-${String(g.day).padStart(2, '0')}`
},
formatDayNumber: (d: number) => String(d),
}
}
// میلادی (Gregorian)
const dFirst = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = dFirst.getDay() // 0 = Sunday
return {
daysInMonth,
startOffset,
monthName: GREGORIAN_MONTH_NAMES[month - 1] || '',
weekdays: WEEKDAY_NAMES_EN,
yearFormatted: String(year),
toIso: (d: number) => {
return `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
},
formatDayNumber: (d: number) => String(d),
}
}

42
src/lib/jalali.ts

@ -1,37 +1,25 @@
/* ────────────────────────────────────────────────────────────────────────── /* ──────────────────────────────────────────────────────────────────────────
* تبدیل تاریخ‌های میلادیِ بک‌اند (ISO datetime) به نمایشِ جلالی (هجری شمسی).
*
* بک‌اند فیلدهای updated_at/created_at را به‌صورت ISO (UTC) می‌فرستد؛ کاربر باید
* تاریخ را به تقویم جلالی ببیند. از `Intl.DateTimeFormat` با تقویم `persian`
* استفاده می‌کنیم (بدون وابستگیِ خارجی) و ارقام را فارسی نمایش می‌دهیم.
* تبدیل تاریخ‌های میلادیِ بک‌اند به نمایش تقویمی متناسب با زبان.
* این ماژول جهت حفظ سازگاری کدهای قبلی حفظ شده و از موتور اصلی date-utils ارث‌بری می‌کند.
* ────────────────────────────────────────────────────────────────────────── */ * ────────────────────────────────────────────────────────────────────────── */
const DATE_FMT = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
import { formatDateByLanguage, formatDateTimeByLanguage } from './date-utils'
const DATE_TIME_FMT = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
export {
formatDateByLanguage,
formatDateRangeByLanguage,
formatDateTimeByLanguage,
useFormattedDate,
useFormattedDateRange,
useFormattedDateTime,
} from './date-utils'
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی). ورودی خالی/نامعتبر → '—'. */
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی در فارسی، هجری در عربی، میلادی در انگلیسی). */
export function toJalali(iso: string | null | undefined): string { export function toJalali(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return DATE_FMT.format(d)
return formatDateByLanguage(iso, 'fa', { style: 'numeric' })
} }
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵، ۱۴:۳۰» (جلالی + ساعت). ورودی نامعتبر → '—'. */
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵، ۱۴:۳۰» (جلالی + ساعت). */
export function toJalaliDateTime(iso: string | null | undefined): string { export function toJalaliDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return DATE_TIME_FMT.format(d)
return formatDateTimeByLanguage(iso, 'fa')
} }

7
src/services/http.ts

@ -1,4 +1,5 @@
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useLanguageStore } from '@/i18n'
import { getEnv } from '@/lib/runtime-config' import { getEnv } from '@/lib/runtime-config'
/* ────────────────────────────────────────────────────────────────────────── /* ──────────────────────────────────────────────────────────────────────────
@ -7,6 +8,7 @@ import { getEnv } from '@/lib/runtime-config'
* • base از VITE_API_BASE_URL ?? '/api' (در dev، '/api' را پراکسی Vite به * • base از VITE_API_BASE_URL ?? '/api' (در dev، '/api' را پراکسی Vite به
* بک‌اند هدایت می‌کند؛ نگاه کنید به vite.config.ts). * بک‌اند هدایت می‌کند؛ نگاه کنید به vite.config.ts).
* • توکن DRF را به‌صورت «Authorization: Token <key>» تزریق می‌کند. * • توکن DRF را به‌صورت «Authorization: Token <key>» تزریق می‌کند.
* • هدر Accept-Language را بر اساس زبان فعال پنل تزریق می‌کند.
* • خطاها را به ApiError استانداردِ قابل‌نمایش تبدیل می‌کند (با پیام فارسیِ * • خطاها را به ApiError استانداردِ قابل‌نمایش تبدیل می‌کند (با پیام فارسیِ
* قابل‌فهم استخراج‌شده از شکل خطای DRF). * قابل‌فهم استخراج‌شده از شکل خطای DRF).
* • روی 401 به‌صورت خودکار استور احراز هویت را پاک می‌کند (خروج). * • روی 401 به‌صورت خودکار استور احراز هویت را پاک می‌کند (خروج).
@ -92,6 +94,11 @@ export async function apiFetch<T = unknown>(path: string, options: ApiFetchOptio
finalHeaders.set('Content-Type', 'application/json') finalHeaders.set('Content-Type', 'application/json')
} }
if (!finalHeaders.has('Accept-Language')) {
const currentLang = useLanguageStore.getState().language || 'fa'
finalHeaders.set('Accept-Language', currentLang)
}
if (!skipAuth) { if (!skipAuth) {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
const authScheme = getEnv('VITE_AUTH_SCHEME') ?? 'Bearer' const authScheme = getEnv('VITE_AUTH_SCHEME') ?? 'Bearer'

Loading…
Cancel
Save