Browse Source
feat: implement tours and billing features with supporting pages, components, and services
master
feat: implement tours and billing features with supporting pages, components, and services
master
21 changed files with 3120 additions and 212 deletions
-
87src/components/shared/Shimmer.tsx
-
2src/components/shared/TablePagination.tsx
-
331src/components/shared/UniversalDatePicker.tsx
-
17src/components/ui/dialog.tsx
-
133src/features/billing/BillingPage.tsx
-
5src/features/billing/components/BillingDetailModal.tsx
-
26src/features/tours/OngoingToursPage.tsx
-
5src/features/tours/TourOrdersPage.tsx
-
182src/features/tours/ToursPage.tsx
-
957src/features/tours/components/CreateTourDialog.tsx
-
948src/features/tours/components/TourDetailDialog.tsx
-
26src/features/tours/components/TourDetailSheet.tsx
-
3src/features/tours/components/TourStatusBadge.tsx
-
37src/features/tours/components/TourTranslationsDialog.tsx
-
31src/features/tours/services/tour-api.ts
-
1src/features/tours/types.ts
-
4src/icons.tsx
-
2src/index.css
-
486src/lib/date-utils.ts
-
42src/lib/jalali.ts
-
7src/services/http.ts
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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} • {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)} |
|||
> |
|||
× |
|||
</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} • {(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" |
|||
> |
|||
× |
|||
</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> |
|||
) |
|||
} |
|||
@ -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), |
|||
} |
|||
} |
|||
@ -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 { |
|||
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 { |
|||
if (!iso) return '—' |
|||
const d = new Date(iso) |
|||
if (Number.isNaN(d.getTime())) return '—' |
|||
return DATE_TIME_FMT.format(d) |
|||
return formatDateTimeByLanguage(iso, 'fa') |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue