diff --git a/.agents/rules/iconography_standard.md b/.agents/rules/iconography_standard.md new file mode 100644 index 0000000..9ef6aa8 --- /dev/null +++ b/.agents/rules/iconography_standard.md @@ -0,0 +1,16 @@ +# Iconography & Visual Assets Standard - Aqila Admin Panel + +این سند استاندارد و قوانین قطعی استفاده از آیکون‌ها در پنل مدیریت ادمین (`aqila_panel`) را تبیین می‌کند. + +--- + +## ۱. اصل منبع واحد برای آیکون‌ها (Single Source of Truth) +- کلیه آیکون‌های سایدبار، نوار بالایی، جداول و فرم‌ها باید از کتابخانه آیکون رسمی و مصوب پنل (نظیر Heroicons یا Lucide متناسب با معماری پنل) به صورت یکدست ایمپورت شوند. +- استفاده از آیکون‌های پراکنده با ضخامت‌های خط (stroke) متفاوت اکیداً ممنوع است. + +--- + +## ۲. سایزبندی و ترازبندی استاندارد +- آیکون‌های سایدبار و نوار ناوبری: `w-5 h-5` (۲۰ پیکسل). +- آیکون‌های داخل دکمه‌های جدول و اکشن‌ها: `w-4 h-4` (۱۶ پیکسل). +- رنگ و استیت‌های فعال، هاور و غیرفعال باید کاملاً منطبق بر سیستم رنگ سازمانی پنل باشند. diff --git a/.agents/rules/post_code_change_verification.md b/.agents/rules/post_code_change_verification.md index f505766..ea53708 100644 --- a/.agents/rules/post_code_change_verification.md +++ b/.agents/rules/post_code_change_verification.md @@ -11,7 +11,7 @@ --- ## ۲. چک‌لیست اعتبارسنجی در پنل مدیریت -1. **کامپایل تایپ‌اسکریپت و بیلد Vite:** تغییرات نباید باعث خطای تایپ یا کرش HMR در سرور در حال اجرا شوند. +1. **کامپایل تایپ‌اسکریپت و بیلد Vite:** تغییرات نباید باعث خطای تایپ یا کرش HMR در سرور در حال اجرا شوند. همچنین از اجرای چند سرور توسعه موازی روی یک محیط کاری یا ایمپورت Barrelهای سنگین که موجب اختلال در بارگذاری چانک‌ها می‌شوند خودداری گردد. 2. **سلامت جریان داده‌ها در جداول و فرم‌ها:** بررسی صحت مدیریت مقادیر نال/آندفایند در کامپوننت‌های داده‌محور (Data Tables, Dropdowns, Forms) تا در مواجهه با دیتای دریافتی کرش نکنند. 3. **سازگاری با سرویس‌های API:** تطابق فراخوانی متدهای رکوئست با مدل‌ها و پاسخ‌های بک‌اند. diff --git a/AGENTS.md b/AGENTS.md index 5c78831..9f5c077 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,6 @@ - **شرط اختصاصی اعمال (صرفاً پس از تغییر کد - عدم اعمال در چت‌های عادی):** این بررسی و هرگونه اعتبارسنجی سلامت ران‌تایم، صرفاً و منحصراً در پیام‌ها و تسک‌هایی که کدی در آن ویرایش یا ایجاد شده باشد اعمال می‌شود. در چت‌ها و پیام‌های عادی که تغییری در کدها داده نشده است، اجرای این بررسی‌ها اکیداً لازم نیست. - **بررسی سریع و بلادرنگ ران بودن پنل مدیریت (Immediate Runtime & Health Check):** - بلافاصله بررسی شود که تغییرات در کامپوننت‌ها، هوک‌ها، تایپ‌ها یا استایل‌ها باعث خطای کامپایل TypeScript، خطای بیلد Vite یا کرش داشبورد نشده باشد. - - سلامت ران بودن با بررسی تایپ‌ها (`npm run typecheck` / `tsc --noEmit`) یا وضعیت سرور Vite تضمین شود. + - سلامت ران بودن با بررسی تایپ‌ها (`npm run typecheck` / `tsc --noEmit`) یا وضعیت سرور Vite تضمین شود و از اجرای همزمان چند سرور توسعه موازی یا ایمپورت پکیج‌های Barrel سنگین که باعث کندی HMR یا خطای بارگذاری چانک می‌شوند خودداری گردد. - **اصل بازیابی فوری و خودکار (Instant Self-Healing):** - در صورتی که تغییر کد باعث بروز هرگونه خطا، شکست کامپایل یا توقف ران بودن پنل شود، ایجنت موظف است فوراً منشأ ارور را شناسایی و اصلاح کند تا پنل مدیریت مجدداً در حالت ران، کاملاً سالم و پایدار قرار گیرد. diff --git a/src/components/shared/ServiceTranslation/ServiceTranslation.tsx b/src/components/shared/ServiceTranslation/ServiceTranslation.tsx index a3bfb98..fbc81e2 100644 --- a/src/components/shared/ServiceTranslation/ServiceTranslation.tsx +++ b/src/components/shared/ServiceTranslation/ServiceTranslation.tsx @@ -40,6 +40,76 @@ export function ServiceTranslation({ return Boolean(dirtyLangs[code]) } + if (variant === 'inline') { + return ( +
+ {/* آیکون یا عنوان زبان */} + {showGlobeIcon && ( +
+ + {title && {title}:} + {required && *} +
+ )} + + {/* دکمه‌های پیل زبان */} +
+ {SERVICE_LANGUAGES.map((lang) => { + const isSelected = activeLang === lang.code + const hasData = isCompleted(lang.code) + const dirty = isDirty(lang.code) + const displayName = lang.code === 'id' ? 'Indonesia' : lang.name + + return ( + + ) + })} +
+ + {/* دایرکشن متن */} + {showDirectionBadge && ( +
+ {currentLang.dir.toUpperCase()} +
+ )} + + {children} +
+ ) + } + return (
{/* هدر انتخاب زبان و نمایش دایرکشن */} diff --git a/src/components/shared/ServiceTranslation/types.ts b/src/components/shared/ServiceTranslation/types.ts index 3b24eb7..5926d26 100644 --- a/src/components/shared/ServiceTranslation/types.ts +++ b/src/components/shared/ServiceTranslation/types.ts @@ -69,6 +69,6 @@ export interface ServiceTranslationProps { showGlobeIcon?: boolean required?: boolean className?: string - variant?: 'grid' | 'pills' + variant?: 'grid' | 'pills' | 'inline' children?: React.ReactNode } diff --git a/src/components/shared/TourFeatureIconPicker.tsx b/src/components/shared/TourFeatureIconPicker.tsx new file mode 100644 index 0000000..9643d5a --- /dev/null +++ b/src/components/shared/TourFeatureIconPicker.tsx @@ -0,0 +1,247 @@ +import React, { useState, useMemo } from 'react' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Input } from '@/components/ui/input' +import { Ic, type IconName } from '@/icons' +import { cn } from '@/lib/utils' + +export interface FeatureIconDef { + name: IconName + labelFa: string + category: 'stay' | 'transport' | 'food' | 'ziyarat' | 'staff' | 'finance' | 'gear' | 'leisure' + keywords: string[] +} + +export const TOUR_FEATURE_ICONS: FeatureIconDef[] = [ + // ۱. اقامت و هتل + { name: 'hotel', labelFa: 'هتل و اقامتگاه', category: 'stay', keywords: ['هتل', 'hotel', 'اقامت', 'residence'] }, + { name: 'hotelStar', labelFa: 'هتل ۵ ستاره', category: 'stay', keywords: ['ستاره', '5star', 'ممتاز', 'لوکس'] }, + { name: 'bed', labelFa: 'تخت‌خواب / اتاق', category: 'stay', keywords: ['تخت', 'bed', 'اتاق', 'room'] }, + { name: 'twinBed', labelFa: 'اتاق دوتخته', category: 'stay', keywords: ['دوتخته', 'twin', 'سوئیت'] }, + { name: 'roomKey', labelFa: 'کلید سوئیت', category: 'stay', keywords: ['کلید', 'key', 'سوئیت', 'تحویل'] }, + { name: 'roomService', labelFa: 'خدمات اتاق و پذیرش', category: 'stay', keywords: ['خدمات', 'room service', 'بل بوی', 'پذیرش'] }, + { name: 'airCondition', labelFa: 'تهویه و سرمایش/گرمایش', category: 'stay', keywords: ['کولر', 'اسپلیت', 'تهویه', 'air condition'] }, + { name: 'view', labelFa: 'ویو و چشم‌انداز حرم', category: 'stay', keywords: ['ویو', 'دید', 'چشم انداز', 'حرم'] }, + { name: 'safeBox', labelFa: 'صندوق امانات اتاق', category: 'stay', keywords: ['گاوصندوق', 'صندوق', 'safe'] }, + { name: 'building', labelFa: 'برج و ساختمان هتل', category: 'stay', keywords: ['ساختمان', 'برج', 'هتل'] }, + + // ۲. حمل‌ونقل و ترانسفر + { name: 'busVip', labelFa: 'اتوبوس VIP توریستی', category: 'transport', keywords: ['اتوبوس', 'bus', 'vip', 'ترانسفر'] }, + { name: 'bus', labelFa: 'اتوبوس استاندارد', category: 'transport', keywords: ['اتوبوس', 'سفر زمینی', 'bus'] }, + { name: 'van', labelFa: 'ون تشریفاتی', category: 'transport', keywords: ['ون', 'van', 'مینی بوس'] }, + { name: 'airplane', labelFa: 'پرواز و هواپیما', category: 'transport', keywords: ['پرواز', 'هواپیما', 'plane', 'flight'] }, + { name: 'plane', labelFa: 'بلیط پرواز', category: 'transport', keywords: ['هواپیما', 'پرواز'] }, + { name: 'airportShuttle', labelFa: 'ترانسفر فرودگاهی', category: 'transport', keywords: ['فرودگاه', 'ترانسفر', 'shuttle'] }, + { name: 'train', labelFa: 'قطار بین‌شهری', category: 'transport', keywords: ['قطار', 'train', 'راه آهن'] }, + { name: 'taxi', labelFa: 'تاکسی دربستی', category: 'transport', keywords: ['تاکسی', 'taxi', 'خودرو'] }, + { name: 'car', labelFa: 'ماشین اختصاصی', category: 'transport', keywords: ['خودرو', 'ماشین', 'car'] }, + { name: 'ship', labelFa: 'ترانسفر دریایی / کشتی', category: 'transport', keywords: ['کشتی', 'دریایی', 'قایق', 'ship'] }, + + // ۳. پذیرایی و غذا + { name: 'buffet', labelFa: 'بوفه باز سلف سرویس', category: 'food', keywords: ['بوفه', 'سلف', 'buffet', 'پذیرایی'] }, + { name: 'meal', labelFa: 'سه وعده غذا', category: 'food', keywords: ['غذا', 'رستوران', 'meal', 'خوراک'] }, + { name: 'breakfast', labelFa: 'صبحانه هتل', category: 'food', keywords: ['صبحانه', 'breakfast', 'چای', 'قهوه'] }, + { name: 'hotMeal', labelFa: 'ناهار و شام گرم', category: 'food', keywords: ['ناهار', 'شام', 'غذای گرم'] }, + { name: 'beverage', labelFa: 'نوشیدنی و چای‌خانه', category: 'food', keywords: ['چای', 'نوشیدنی', 'drink', 'قهوه'] }, + { name: 'halalFood', labelFa: 'غذای حلال ایرانی', category: 'food', keywords: ['حلال', 'halal', 'ایرانی'] }, + { name: 'snack', labelFa: 'میان‌وعده و پذیرایی', category: 'food', keywords: ['میان وعده', 'میوه', 'snack'] }, + + // ۴. زیارت و اماکن متبرکه + { name: 'dome', labelFa: 'بارگاه و گنبد مقدس', category: 'ziyarat', keywords: ['گنبد', 'بارگاه', 'حرم', 'زیارت'] }, + { name: 'shrine', labelFa: 'حرم مطهر و ضریح', category: 'ziyarat', keywords: ['حرم', 'ضریح', 'زیارت', 'shrine'] }, + { name: 'kaaba', labelFa: 'کعبه مشرفه / حج و عمره', category: 'ziyarat', keywords: ['کعبه', 'مکه', 'حج', 'عمره', 'kaaba'] }, + { name: 'mosque', labelFa: 'مسجد و نماز جماعت', category: 'ziyarat', keywords: ['مسجد', 'نماز', 'mosque'] }, + { name: 'quran', labelFa: 'قرآن و ادعیه', category: 'ziyarat', keywords: ['قرآن', 'دعا', 'زیارت نامه', 'کتاب'] }, + { name: 'eulogy', labelFa: 'مداحی و مراسم مذهبی', category: 'ziyarat', keywords: ['مداح', 'نوحه', 'هیئت', 'مراسم'] }, + { name: 'holyTour', labelFa: 'زیارت دوره جامع', category: 'ziyarat', keywords: ['زیارت دوره', 'اماکن مقدسه', 'قبور شهدا'] }, + + // ۵. کادر، راهنما و لیدر + { name: 'tourGuide', labelFa: 'مدیر کاروان و تورلیدر', category: 'staff', keywords: ['لیدر', 'راهنما', 'مدیر', 'guide'] }, + { name: 'scholar', labelFa: 'روحانی کاروان', category: 'staff', keywords: ['روحانی', 'شیخ', 'احکام', 'عالم'] }, + { name: 'translator', labelFa: 'مترجم بومی مسلط', category: 'staff', keywords: ['مترجم', 'عربی', 'انگلیسی', 'زبان'] }, + { name: 'doctor', labelFa: 'پزشک همراه کاروان', category: 'staff', keywords: ['پزشک', 'دکتر', 'درمان', 'کادر پزشکی'] }, + { name: 'medical', labelFa: 'خدمات امدادی و دارویی', category: 'staff', keywords: ['دارو', 'پزشکی', 'اورژانس'] }, + { name: 'camera', labelFa: 'عکاس و مستندساز', category: 'staff', keywords: ['عکاس', 'فیلمبردار', 'مستند'] }, + { name: 'support24', labelFa: 'پشتیبانی ۲۴ ساعته', category: 'staff', keywords: ['پشتیبانی', 'شبانه روزی', '۲۴ ساعته'] }, + + // ۶. مدارک، ویزا و امور مالی + { name: 'passport', labelFa: 'گذرنامه و پاسپورت', category: 'finance', keywords: ['پاسپورت', 'گذرنامه', 'passport'] }, + { name: 'visa', labelFa: 'روادید و اخذ ویزا', category: 'finance', keywords: ['ویزا', 'روادید', 'visa'] }, + { name: 'insurance', labelFa: 'بیمه مسافرتی کامل', category: 'finance', keywords: ['بیمه', 'حوادث', 'درمان', 'insurance'] }, + { name: 'currency', labelFa: 'صرافی و ارز مسافرتی', category: 'finance', keywords: ['ارز', 'دلار', 'دینار', 'صرافی'] }, + { name: 'ticket', labelFa: 'بلیط ورودی و الکترونیک', category: 'finance', keywords: ['بلیط', 'بلیت', 'کارت', 'ticket'] }, + { name: 'tax', labelFa: 'عوارض خروج و مالیات', category: 'finance', keywords: ['عوارض', 'مالیات', 'فیش'] }, + + // ۷. ارتباطات و تجهیزات مسافر + { name: 'wifi', labelFa: 'اینترنت وای‌فای رایگان', category: 'gear', keywords: ['وای فای', 'اینترنت', 'wifi'] }, + { name: 'simcard', labelFa: 'سیم‌کارت محلی فعال', category: 'gear', keywords: ['سیمکارت', 'شارژ', 'تماس', 'simcard'] }, + { name: 'luggage', labelFa: 'چمدان و ساک زیارتی', category: 'gear', keywords: ['چمدان', 'ساک', 'بار', 'luggage'] }, + { name: 'charger', labelFa: 'شارژر و ایستگاه برق', category: 'gear', keywords: ['شارژر', 'پاوربانک', 'پریز'] }, + { name: 'waterBottle', labelFa: 'آب معدنی بسته‌بندی', category: 'gear', keywords: ['آب', 'بطری', 'نوشیدنی'] }, + { name: 'umbrella', labelFa: 'چتر و سایبان', category: 'gear', keywords: ['چتر', 'سایبان', 'آفتاب'] }, + + // ۸. تفریح و خرید + { name: 'shopping', labelFa: 'گشت بازار و خرید', category: 'leisure', keywords: ['بازار', 'خرید', 'مرکز خرید', 'shopping'] }, + { name: 'museum', labelFa: 'موزه و ابنیه تاریخی', category: 'leisure', keywords: ['موزه', 'تاریخی', 'آثار'] }, + { name: 'gift', labelFa: 'بسته هدایای تبرکی', category: 'leisure', keywords: ['هدیه', 'تبرکی', 'سوغات'] }, + { name: 'palmTour', labelFa: 'گشت نخلستان و طبیعت', category: 'leisure', keywords: ['نخلستان', 'طبیعت', 'گشت'] }, + + // شاخص و تیک پیش‌فرض + { name: 'check', labelFa: 'تیک استاندارد', category: 'gear', keywords: ['تیک', 'شاخص', 'پیش فرض'] }, + { name: 'sparkles', labelFa: 'ویژه و ستاره‌دار', category: 'gear', keywords: ['ویژه', 'ستاره', 'جدید'] }, +] + +const CATEGORIES = [ + { id: 'all', label: 'همه' }, + { id: 'stay', label: 'اقامت' }, + { id: 'transport', label: 'ترانسفر' }, + { id: 'food', label: 'پذیرایی' }, + { id: 'ziyarat', label: 'زیارت' }, + { id: 'staff', label: 'کادر' }, + { id: 'finance', label: 'مدارک' }, + { id: 'gear', label: 'تجهیزات' }, + { id: 'leisure', label: 'گشت' }, +] as const + +interface TourFeatureIconPickerProps { + selectedIcon: string + onSelectIcon: (iconName: string) => void + disabled?: boolean + className?: string + buttonSize?: 'sm' | 'md' + size?: 'sm' | 'md' +} + +export function TourFeatureIconPicker({ + selectedIcon, + onSelectIcon, + disabled = false, + className, + buttonSize, + size, +}: TourFeatureIconPickerProps) { + const effectiveSize = size || buttonSize || 'md' + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [activeCategory, setActiveCategory] = useState('all') + + const validSelected = selectedIcon && TOUR_FEATURE_ICONS.some((i) => i.name === selectedIcon) + ? selectedIcon + : 'check' + + const filteredIcons = useMemo(() => { + return TOUR_FEATURE_ICONS.filter((item) => { + const matchCategory = activeCategory === 'all' || item.category === activeCategory + const query = search.trim().toLowerCase() + const matchSearch = + !query || + item.name.toLowerCase().includes(query) || + item.labelFa.toLowerCase().includes(query) || + item.keywords.some((k) => k.toLowerCase().includes(query)) + + return matchCategory && matchSearch + }) + }, [search, activeCategory]) + + return ( + + + + + + + {/* جستجوی آیکون */} +
+ setSearch(e.target.value)} + placeholder="جستجوی آیکون (هتل، اتوبوس، غذا، زیارت...)" + className="h-8 text-xs bg-surface-base pr-8 rounded-xl border-border-soft" + autoFocus + /> +
+ +
+
+ + {/* دسته‌بندی‌ها */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {/* شبکه آیکون‌ها */} +
+ {filteredIcons.length === 0 ? ( +
+ آیکونی یافت نشد +
+ ) : ( + filteredIcons.map((item) => { + const isSelected = item.name === validSelected + return ( + + ) + }) + )} +
+ + {/* راهنما */} +
+ {filteredIcons.length} آیکون تخصصی گردشگری و زیارت +
+
+
+ ) +} diff --git a/src/features/tours/components/CreateTourDialog.tsx b/src/features/tours/components/CreateTourDialog.tsx index dce9311..2540c24 100644 --- a/src/features/tours/components/CreateTourDialog.tsx +++ b/src/features/tours/components/CreateTourDialog.tsx @@ -16,6 +16,7 @@ import { ServiceTranslation, type ServiceLanguageCode, } from '@/components/shared/ServiceTranslation' +import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker' import type { TourTranslationData } from '../types' interface CreateTourDialogProps { @@ -70,8 +71,10 @@ export function CreateTourDialog({ const [percentOff, setPercentOff] = React.useState('0') // Base features & tips - const [features, setFeatures] = React.useState([]) + const [features, setFeatures] = React.useState<{ title: string; icon: string }[]>([]) const [newFeature, setNewFeature] = React.useState('') + const [newFeatureIcon, setNewFeatureIcon] = React.useState('check') + const [editingBaseFeature, setEditingBaseFeature] = React.useState<{ idx: number; title: string; icon: string } | null>(null) const [tips, setTips] = React.useState<{ title: string; desc: string }[]>([]) const [newTipTitle, setNewTipTitle] = React.useState('') const [newTipDesc, setNewTipDesc] = React.useState('') @@ -95,6 +98,16 @@ export function CreateTourDialog({ setImageUrl(null) setImageName(null) setImageUploadError(null) + setFeatures([]) + setNewFeature('') + setNewFeatureIcon('check') + setEditingBaseFeature(null) + setTips([]) + setNewTipTitle('') + setNewTipDesc('') + setNewTransFeatText('') + setNewTransFeatIcon('check') + setEditingTransFeature(null) } }, [open]) @@ -140,11 +153,35 @@ export function CreateTourDialog({ const handleAddFeature = () => { if (!newFeature.trim()) return - setFeatures((prev) => [...prev, newFeature.trim()]) + setFeatures((prev) => [...prev, { title: newFeature.trim(), icon: newFeatureIcon || 'check' }]) setNewFeature('') + setNewFeatureIcon('check') + } + + const handleStartEditBaseFeature = (idx: number, feat: { title: string; icon: string }) => { + setEditingBaseFeature({ idx, title: feat.title, icon: feat.icon || 'check' }) + } + + const handleSaveEditBaseFeature = () => { + if (!editingBaseFeature || !editingBaseFeature.title.trim()) return + setFeatures((prev) => + prev.map((f, i) => + i === editingBaseFeature.idx + ? { title: editingBaseFeature.title.trim(), icon: editingBaseFeature.icon || 'check' } + : f + ) + ) + setEditingBaseFeature(null) + } + + const handleCancelEditBaseFeature = () => { + setEditingBaseFeature(null) } const handleRemoveFeature = (idx: number) => { + if (editingBaseFeature?.idx === idx) { + setEditingBaseFeature(null) + } setFeatures((prev) => prev.filter((_, i) => i !== idx)) } @@ -170,7 +207,7 @@ export function CreateTourDialog({ })) } - const handleAddTransFeature = (text: string) => { + const handleAddTransFeature = (text: string, icon = 'check') => { if (!text.trim()) return setTranslations((prev) => { const current = prev[selectedTransLang] || { tour_feature: [] } @@ -179,13 +216,46 @@ export function CreateTourDialog({ ...prev, [selectedTransLang]: { ...current, - tour_feature: [...currFeats, { title: text.trim() }], + tour_feature: [...currFeats, { title: text.trim(), icon: icon || 'check' }], + }, + } + }) + setNewTransFeatText('') + setNewTransFeatIcon('check') + } + + const handleStartEditTransFeature = (idx: number, feat: { title: string; icon?: string }) => { + setEditingTransFeature({ idx, title: feat.title, icon: feat.icon || 'check' }) + } + + const handleSaveEditTransFeature = () => { + if (!editingTransFeature || !editingTransFeature.title.trim()) return + setTranslations((prev) => { + const current = prev[selectedTransLang] || { tour_feature: [] } + const currFeats = current.tour_feature || [] + return { + ...prev, + [selectedTransLang]: { + ...current, + tour_feature: currFeats.map((f, i) => + i === editingTransFeature.idx + ? { title: editingTransFeature.title.trim(), icon: editingTransFeature.icon || 'check' } + : f + ), }, } }) + setEditingTransFeature(null) + } + + const handleCancelEditTransFeature = () => { + setEditingTransFeature(null) } const handleRemoveTransFeature = (idx: number) => { + if (editingTransFeature?.idx === idx) { + setEditingTransFeature(null) + } setTranslations((prev) => { const current = prev[selectedTransLang] || { tour_feature: [] } const currFeats = current.tour_feature || [] @@ -233,6 +303,8 @@ export function CreateTourDialog({ } const [newTransFeatText, setNewTransFeatText] = React.useState('') + const [newTransFeatIcon, setNewTransFeatIcon] = React.useState('check') + const [editingTransFeature, setEditingTransFeature] = React.useState<{ idx: number; title: string; icon: string } | null>(null) const [newTransTipTitle, setNewTransTipTitle] = React.useState('') const [newTransTipDesc, setNewTransTipDesc] = React.useState('') @@ -281,7 +353,7 @@ export function CreateTourDialog({ setIsSubmitting(true) try { - const tour_feature = features.map((f) => ({ title: f })) + const tour_feature = features.map((f) => ({ title: f.title, icon: f.icon || 'check' })) const travel_tips: Record = {} tips.forEach((t) => { travel_tips[t.title] = t.desc @@ -780,6 +852,10 @@ export function CreateTourDialog({
+ setNewFeature(e.target.value)} @@ -796,7 +872,7 @@ export function CreateTourDialog({ 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" + className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl cursor-pointer" > افزودن @@ -809,21 +885,80 @@ export function CreateTourDialog({ هنوز ویژگی برای این تور اضافه نشده است.

) : ( - features.map((feat, idx) => ( -
- {feat} - -
- )) + setEditingBaseFeature({ ...editingBaseFeature, icon })} + size="sm" + /> + setEditingBaseFeature({ ...editingBaseFeature, title: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveEditBaseFeature() + } else if (e.key === 'Escape') { + handleCancelEditBaseFeature() + } + }} + autoFocus + className="h-8 bg-surface-base text-xs rounded-lg px-2 border-border-soft flex-1" + /> + + +
+ ) : ( +
+
+ + + + {feat.title} +
+
+ + +
+
+ ) + ) )} @@ -944,6 +1079,10 @@ export function CreateTourDialog({ ویژگی‌ها به زبان {activeTransConfig?.label} ({(currentTransData.tour_feature || []).length}):
+ setNewTransFeatText(e.target.value)} @@ -952,8 +1091,7 @@ export function CreateTourDialog({ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() - handleAddTransFeature(newTransFeatText) - setNewTransFeatText('') + handleAddTransFeature(newTransFeatText, newTransFeatIcon) } }} /> @@ -961,10 +1099,9 @@ export function CreateTourDialog({ type="button" size="sm" onClick={() => { - handleAddTransFeature(newTransFeatText) - setNewTransFeatText('') + handleAddTransFeature(newTransFeatText, newTransFeatIcon) }} - className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl" + className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl cursor-pointer" > افزودن @@ -972,21 +1109,80 @@ export function CreateTourDialog({
- {(currentTransData.tour_feature || []).map((feat, fIdx) => ( -
- {feat.title} - -
- ))} + setEditingTransFeature({ ...editingTransFeature, icon })} + size="sm" + /> + setEditingTransFeature({ ...editingTransFeature, title: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveEditTransFeature() + } else if (e.key === 'Escape') { + handleCancelEditTransFeature() + } + }} + autoFocus + className="h-8 bg-surface-base text-xs rounded-lg px-2 border-border-soft flex-1" + /> + + +
+ ) : ( +
+
+ + + + {feat.title} +
+
+ + +
+
+ ) + )} diff --git a/src/features/tours/components/TourDetailDialog.tsx b/src/features/tours/components/TourDetailDialog.tsx index fbc73cb..b4f69b6 100644 --- a/src/features/tours/components/TourDetailDialog.tsx +++ b/src/features/tours/components/TourDetailDialog.tsx @@ -50,6 +50,7 @@ import type { } from '../types' import { TourStatusBadge } from './TourStatusBadge' import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' +import { TourFeatureIconPicker } from '@/components/shared/TourFeatureIconPicker' interface TourDetailDialogProps { tourId: number | null @@ -68,10 +69,15 @@ const SUPPORTED_LANGUAGES = [ type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code'] +export interface FeatureDraftItem { + title: string + icon?: string +} + interface LangDraft { title: string description: string - features: string[] + features: FeatureDraftItem[] tips: { title: string; desc: string }[] } @@ -173,9 +179,14 @@ export function TourDetailDialog({ // Temporary local inputs for adding feature / tip const [newFeatureInput, setNewFeatureInput] = useState('') + const [newFeatureIcon, setNewFeatureIcon] = useState('check') const [newTipTitle, setNewTipTitle] = useState('') const [newTipDesc, setNewTipDesc] = useState('') + // State for inline editing an existing travel tip & feature + const [editingTip, setEditingTip] = useState<{ idx: number; title: string; desc: string } | null>(null) + const [editingFeature, setEditingFeature] = useState<{ idx: number; title: string; icon: string } | null>(null) + // Add new itinerary step state const [showAddItinForm, setShowAddItinForm] = useState(false) const [newItinTitle, setNewItinTitle] = useState('') @@ -213,6 +224,8 @@ export function TourDetailDialog({ setDeletedTourImageIds([]) setNewFilerImageIds([]) setImageUploadError(null) + setEditingTip(null) + setEditingFeature(null) return } @@ -223,6 +236,8 @@ export function TourDetailDialog({ setActiveTab('overview') setIsEditMode(false) setShowAddItinForm(false) + setEditingTip(null) + setEditingFeature(null) Promise.all([ fetchTourDetail(tourId), @@ -281,7 +296,11 @@ export function TourDetailDialog({ initialDrafts[lang.code] = { title: langTrans.title ?? '', description: langTrans.description ?? '', - features: (langTrans.tour_feature || []).map((f: any) => (typeof f === 'string' ? f : f.title)), + features: (langTrans.tour_feature || []).map((f: any) => + typeof f === 'string' + ? { title: f, icon: 'check' } + : { title: f?.title || '', icon: f?.icon || 'check' } + ), tips: Object.entries(langTrans.travel_tips || {}).map(([tTitle, tDesc]) => ({ title: tTitle, desc: String(tDesc), @@ -291,7 +310,11 @@ export function TourDetailDialog({ initialDrafts[lang.code] = { title: trans.primary_data.title ?? '', description: trans.primary_data.description ?? '', - features: (trans.primary_data.tour_feature || []).map((f: any) => (typeof f === 'string' ? f : f.title)), + features: (trans.primary_data.tour_feature || []).map((f: any) => + typeof f === 'string' + ? { title: f, icon: 'check' } + : { title: f?.title || '', icon: f?.icon || 'check' } + ), tips: Object.entries(trans.primary_data.travel_tips || {}).map(([tTitle, tDesc]) => ({ title: tTitle, desc: String(tDesc), @@ -301,7 +324,11 @@ export function TourDetailDialog({ initialDrafts[lang.code] = { title: detail.title ?? '', description: detail.description ?? '', - features: (detail.tour_features || []).map((f) => f.title), + features: (detail.tour_features || []).map((f: any) => + typeof f === 'string' + ? { title: f, icon: 'check' } + : { title: f?.title || '', icon: f?.icon || 'check' } + ), tips: (detail.travel_tips || []).map((t) => ({ title: t.title, desc: t.description, @@ -346,6 +373,12 @@ export function TourDetailDialog({ } }, [tourId, isOpen]) + // Reset inline edit states when language, tab or edit mode changes + useEffect(() => { + setEditingTip(null) + setEditingFeature(null) + }, [selectedLang, activeTab, isEditMode]) + // Load sub-tabs (passengers, comments, itineraries) on demand useEffect(() => { if (!tourId || !isOpen || !tour) return @@ -493,13 +526,44 @@ export function TourDetailDialog({ ...prev, [selectedLang]: { ...prev[selectedLang], - features: [...prev[selectedLang].features, newFeatureInput.trim()], + features: [ + ...prev[selectedLang].features, + { title: newFeatureInput.trim(), icon: newFeatureIcon || 'check' }, + ], }, })) setNewFeatureInput('') + setNewFeatureIcon('check') + } + + const handleStartEditFeature = (idx: number, feat: FeatureDraftItem) => { + setEditingFeature({ idx, title: feat.title, icon: feat.icon || 'check' }) + } + + const handleSaveEditFeature = () => { + if (!editingFeature || !editingFeature.title.trim()) return + setAllTranslations((prev) => ({ + ...prev, + [selectedLang]: { + ...prev[selectedLang], + features: prev[selectedLang].features.map((f, i) => + i === editingFeature.idx + ? { title: editingFeature.title.trim(), icon: editingFeature.icon || 'check' } + : f + ), + }, + })) + setEditingFeature(null) + } + + const handleCancelEditFeature = () => { + setEditingFeature(null) } const handleRemoveFeature = (idx: number) => { + if (editingFeature?.idx === idx) { + setEditingFeature(null) + } setAllTranslations((prev) => ({ ...prev, [selectedLang]: { @@ -522,7 +586,34 @@ export function TourDetailDialog({ setNewTipDesc('') } + const handleStartEditTip = (idx: number, tip: { title: string; desc: string }) => { + setEditingTip({ idx, title: tip.title, desc: tip.desc }) + } + + const handleSaveEditTip = () => { + if (!editingTip || !editingTip.title.trim()) return + setAllTranslations((prev) => ({ + ...prev, + [selectedLang]: { + ...prev[selectedLang], + tips: prev[selectedLang].tips.map((t, i) => + i === editingTip.idx + ? { title: editingTip.title.trim(), desc: editingTip.desc.trim() } + : t + ), + }, + })) + setEditingTip(null) + } + + const handleCancelEditTip = () => { + setEditingTip(null) + } + const handleRemoveTip = (idx: number) => { + if (editingTip?.idx === idx) { + setEditingTip(null) + } setAllTranslations((prev) => ({ ...prev, [selectedLang]: { @@ -762,7 +853,9 @@ export function TourDetailDialog({ translationsPayload[lang.code] = { title: draft.title.trim(), description: draft.description.trim(), - tour_feature: draft.features.map((f) => ({ title: f.trim() })).filter((f) => f.title), + tour_feature: draft.features + .map((f) => ({ title: f.title.trim(), icon: f.icon || 'check' })) + .filter((f) => f.title), travel_tips: travel_tips_obj, } } @@ -1067,8 +1160,39 @@ export function TourDetailDialog({ - {/* Integrated 5-Language Selector Bar with dirty indicators via ServiceTranslation */} -
+ {/* Unified Navigation Tabs and Language Bar on a single line */} +
+ {/* Main Navigation Tabs */} + setActiveTab(val)} + className="shrink-0" + > + + + + مشخصات و متون + + + + برنامه سفر + + + + امکانات و نکات + + + + مسافران + + + + نظرات + + + + + {/* Integrated 5-Language Selector Bar with dirty indicators via ServiceTranslation */} handleLanguageSwitch(lang)} @@ -1090,40 +1214,12 @@ export function TourDetailDialog({ ]) )} primaryLang={transData?.primary_language as ServiceLanguageCode} - title="زبان فعال محتوا" - variant="pills" + title="" + showGlobeIcon={true} + variant="inline" + className="shrink-0" />
- - {/* Main Navigation Tabs */} - setActiveTab(val)} - className="mt-3 w-full" - > - - - - مشخصات و متون - - - - برنامه سفر - - - - امکانات و نکات - - - - مسافران - - - - نظرات - - - {/* Modal Scrollable Body */} @@ -1758,29 +1854,91 @@ export function TourDetailDialog({ {currentLangDraft.features.length === 0 ? ( هیچ امکانی ثبت نشده است. ) : ( - currentLangDraft.features.map((feat, idx) => ( -
- {feat} - {isEditMode && ( + currentLangDraft.features.map((feat, idx) => + editingFeature?.idx === idx ? ( +
+ setEditingFeature({ ...editingFeature, icon })} + size="sm" + /> + setEditingFeature({ ...editingFeature, title: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveEditFeature() + } else if (e.key === 'Escape') { + handleCancelEditFeature() + } + }} + autoFocus + className="h-7 w-36 bg-surface-base text-xs rounded-lg px-2 border-border-soft" + /> - )} -
- )) + +
+ ) : ( +
+ + + + {feat.title} + {isEditMode && ( +
+ + +
+ )} +
+ ) + ) )}
{isEditMode && (
+ setNewFeatureInput(e.target.value)} @@ -1815,27 +1973,102 @@ export function TourDetailDialog({ {currentLangDraft.tips.length === 0 ? ( هیچ نکته‌ای ثبت نشده است. ) : ( - currentLangDraft.tips.map((tip, idx) => ( -
-
- {tip.title} - {isEditMode && ( - - )} + currentLangDraft.tips.map((tip, idx) => + editingTip?.idx === idx ? ( +
+
+ ویرایش نکته سفر: +
+ + +
+
+ setEditingTip({ ...editingTip, title: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveEditTip() + } else if (e.key === 'Escape') { + handleCancelEditTip() + } + }} + placeholder={`عنوان نکته به زبان ${activeLangConfig.label}...`} + dir={activeLangConfig.dir} + className="bg-surface-base text-xs rounded-xl border-border-soft h-8" + autoFocus + /> +