From 2278b07519cda17e243978c1f3135dfe03414254 Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Sat, 3 Oct 2026 17:25:23 +0330 Subject: [PATCH] feat: add comprehensive billing page and supporting UI components, services, and localizations --- scripts/generate-locales.js | 8 +- src/app/providers.tsx | 8 +- src/components/layout/PanelSlot.tsx | 4 +- .../ServiceTranslation/ServiceTranslation.tsx | 163 ++++ .../shared/ServiceTranslation/index.ts | 2 + .../shared/ServiceTranslation/types.ts | 74 ++ src/components/ui/card.tsx | 2 +- src/components/ui/dialog.tsx | 11 +- src/components/ui/sidebar.tsx | 162 +++- src/components/ui/table.tsx | 2 +- src/features/billing/BillingPage.tsx | 3 +- .../billing/components/BillingDetailModal.tsx | 209 +++-- .../billing/components/BillingStatusBadge.tsx | 9 + src/features/billing/services/billing-api.ts | 11 + src/features/billing/types.ts | 1 + src/features/services/ServicesPage.tsx | 2 +- .../components/CategoriesServiceView.tsx | 163 ++-- .../components/CreateLocationDialog.tsx | 58 +- .../services/components/DuasServiceView.tsx | 10 +- .../services/components/FaqServiceView.tsx | 99 +-- .../components/ShrinesServiceView.tsx | 2 +- .../services/components/SimcardFormDialog.tsx | 68 +- .../services/components/StoreServiceView.tsx | 826 ++++++++++++++++-- src/features/services/services/store-api.ts | 81 ++ src/features/services/types/store.ts | 21 +- src/features/tours/TourOrdersPage.tsx | 2 +- .../tours/components/CreateTourDialog.tsx | 55 +- .../tours/components/TourDetailDialog.tsx | 92 +- .../components/TourTranslationsDialog.tsx | 52 +- .../users/components/UserFactorsTab.tsx | 7 + src/features/users/types.ts | 2 +- src/index.css | 11 +- src/locales/ar.json | 12 +- src/locales/en.json | 12 +- src/locales/fa.json | 12 +- 35 files changed, 1703 insertions(+), 553 deletions(-) create mode 100644 src/components/shared/ServiceTranslation/ServiceTranslation.tsx create mode 100644 src/components/shared/ServiceTranslation/index.ts create mode 100644 src/components/shared/ServiceTranslation/types.ts diff --git a/scripts/generate-locales.js b/scripts/generate-locales.js index 4206640..371476e 100644 --- a/scripts/generate-locales.js +++ b/scripts/generate-locales.js @@ -583,7 +583,7 @@ export const arLocale = { duasList: 'الأدعية والزيارات', categories: 'التصنيفات', cities: 'المدن المقدسة', - store: 'متجر معصومة (Store)', + store: 'متجر نصرة العقيلة (Store)', storeProducts: 'المنتجات', storeCategories: 'التصنيفات', costEstimate: 'تقدير التكلفة (Cost Estimate)', @@ -767,8 +767,8 @@ export const arLocale = { categoriesDesc: 'إدارة التصنيفات الموضوعية للخدمات والأماكن والمتجر ومحتويات التطبيق', citiesTitle: 'المدن المقدسة', citiesDesc: 'المدن المقدسة (النجف، كربلاء، الكاظمية، سامراء، مشهد، قم، مكة، المدينة)', - storeTitle: 'متجر معصومة', - storeDesc: 'منتجات متجر معصومة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء', + storeTitle: 'متجر نصرة العقيلة', + storeDesc: 'منتجات متجر نصرة العقيلة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء', costEstimateTitle: 'تقدير التكلفة', costEstimateDesc: 'حساب تكاليف الرحلات الفردية والعائلية حسب الموسم والخدمات ومستوى الفندق', supportTitle: 'الدعم الفني', @@ -817,7 +817,7 @@ export const arLocale = { noReceiptUploaded: 'لم يتم رفع إيصال', serviceTour: 'رحلة زيارة', serviceSimcard: 'شريحة اتصال', - serviceShop: 'متجر معصومة', + serviceShop: 'متجر نصرة العقيلة', serviceExchange: 'صرافة (تصريف)', serviceCustomTrip: 'رحلة مخصصة', allStatuses: 'جميع الحالات', diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 62b6fc8..728b5f5 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -6,12 +6,16 @@ import { queryClient } from '@/lib/query-client' import { TooltipProvider } from '@/components/ui/tooltip' import { ToastContainer } from '@/components/ui/toast-container' +import { useLanguageStore } from '@/i18n' -/* پروایدرهای سراسری: React Query + RTL + Tooltips + Router + Toasts */ +/* پروایدرهای سراسری: React Query + RTL/LTR چندزبانه + Tooltips + Router + Toasts */ export default function Providers({ children }: { children: React.ReactNode }) { + const language = useLanguageStore((s) => s.language) + const dir = language === 'en' ? 'ltr' : 'rtl' + return ( - + {children} diff --git a/src/components/layout/PanelSlot.tsx b/src/components/layout/PanelSlot.tsx index b151d48..9419326 100644 --- a/src/components/layout/PanelSlot.tsx +++ b/src/components/layout/PanelSlot.tsx @@ -97,6 +97,8 @@ export default function PanelSlot() { isMobile: false, open: isPanelOpen, state: isPanelOpen ? 'expanded' : 'collapsed', + handleSidebarMouseEnter: () => {}, + handleSidebarMouseLeave: () => {}, } as typeof sidebar return ( @@ -105,7 +107,7 @@ export default function PanelSlot() { className="contents" style={{ '--sidebar-width': 'var(--panel-width)', '--sidebar': '#26272c' } as CSSProperties} > - + {content} diff --git a/src/components/shared/ServiceTranslation/ServiceTranslation.tsx b/src/components/shared/ServiceTranslation/ServiceTranslation.tsx new file mode 100644 index 0000000..a3bfb98 --- /dev/null +++ b/src/components/shared/ServiceTranslation/ServiceTranslation.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import { Ic } from '@/icons' +import { + SERVICE_LANGUAGES, + type ServiceLanguageCode, + type ServiceLanguageMeta, + type ServiceTranslationProps, +} from './types' + +export function ServiceTranslation({ + activeLang, + onLangChange, + completedLangs, + dirtyLangs, + primaryLang, + title = 'زبان ترجمه و محتوا', + showDirectionBadge = true, + showGlobeIcon = true, + required = false, + className = '', + variant = 'grid', + children, +}: ServiceTranslationProps) { + const currentLang = + SERVICE_LANGUAGES.find((l) => l.code === activeLang) || SERVICE_LANGUAGES[0] + + const isCompleted = (code: ServiceLanguageCode): boolean => { + if (!completedLangs) return false + if (Array.isArray(completedLangs)) { + return completedLangs.includes(code) + } + return Boolean(completedLangs[code]) + } + + const isDirty = (code: ServiceLanguageCode): boolean => { + if (!dirtyLangs) return false + if (Array.isArray(dirtyLangs)) { + return dirtyLangs.includes(code) + } + return Boolean(dirtyLangs[code]) + } + + return ( +
+ {/* هدر انتخاب زبان و نمایش دایرکشن */} +
+ + + {showDirectionBadge && ( +
+ جهت متن: + + {currentLang.dir.toUpperCase()} + +
+ )} +
+ + {/* تب‌های انتخاب زبان */} + {variant === 'pills' ? ( +
+ {SERVICE_LANGUAGES.map((lang) => { + const isSelected = activeLang === lang.code + const hasData = isCompleted(lang.code) + const dirty = isDirty(lang.code) + const isBase = primaryLang ? primaryLang === lang.code : lang.code === 'ar' + + return ( + + ) + })} +
+ ) : ( +
+ {SERVICE_LANGUAGES.map((lang) => { + const isSelected = activeLang === lang.code + const hasData = isCompleted(lang.code) + const dirty = isDirty(lang.code) + const isBase = primaryLang ? primaryLang === lang.code : lang.code === 'ar' + + return ( + + ) + })} +
+ )} + + {/* اگر کامپوننت حاوی فرزند بود، اینجا قرار می‌گیرد */} + {children} +
+ ) +} diff --git a/src/components/shared/ServiceTranslation/index.ts b/src/components/shared/ServiceTranslation/index.ts new file mode 100644 index 0000000..fe807de --- /dev/null +++ b/src/components/shared/ServiceTranslation/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './ServiceTranslation' diff --git a/src/components/shared/ServiceTranslation/types.ts b/src/components/shared/ServiceTranslation/types.ts new file mode 100644 index 0000000..3b24eb7 --- /dev/null +++ b/src/components/shared/ServiceTranslation/types.ts @@ -0,0 +1,74 @@ +export type ServiceLanguageCode = 'ar' | 'fa' | 'en' | 'ru' | 'id' + +export interface ServiceLanguageMeta { + code: ServiceLanguageCode + name: string + nativeName: string + flag: string + badge: string + dir: 'rtl' | 'ltr' + label: string +} + +export const SERVICE_LANGUAGES: readonly ServiceLanguageMeta[] = [ + { + code: 'ar', + name: 'العربية', + nativeName: 'العربية', + flag: '🇸🇦', + badge: 'AR', + dir: 'rtl', + label: 'العربية (پیش‌فرض)', + }, + { + code: 'fa', + name: 'فارسی', + nativeName: 'فارسی', + flag: '🇮🇷', + badge: 'FA', + dir: 'rtl', + label: 'فارسی', + }, + { + code: 'en', + name: 'English', + nativeName: 'English', + flag: '🇬🇧', + badge: 'EN', + dir: 'ltr', + label: 'انگلیسی', + }, + { + code: 'ru', + name: 'Русский', + nativeName: 'Русский', + flag: '🇷🇺', + badge: 'RU', + dir: 'ltr', + label: 'روسی', + }, + { + code: 'id', + name: 'Bahasa Indonesia', + nativeName: 'Indonesia', + flag: '🇮🇩', + badge: 'ID', + dir: 'ltr', + label: 'اندونزیایی', + }, +] as const + +export interface ServiceTranslationProps { + activeLang: ServiceLanguageCode + onLangChange: (lang: ServiceLanguageCode) => void + completedLangs?: Partial> | ServiceLanguageCode[] + dirtyLangs?: Partial> | ServiceLanguageCode[] + primaryLang?: ServiceLanguageCode + title?: string + showDirectionBadge?: boolean + showGlobeIcon?: boolean + required?: boolean + className?: string + variant?: 'grid' | 'pills' + children?: React.ReactNode +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 71e95fc..62323cd 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -34,7 +34,7 @@ const CardTitle = React.forwardRef< >(({ className, ...props }, ref) => (

)) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 8a8b8c6..068a69d 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -19,12 +19,14 @@ function DialogOverlay({ className, ...props }: React.ComponentProps { showCloseButton?: boolean + closeButtonClassName?: string } function DialogContent({ className, children, showCloseButton = true, + closeButtonClassName, onPointerDownOutside, onInteractOutside, ...props @@ -52,9 +54,12 @@ function DialogContent({ {showCloseButton && ( - + بستن )} @@ -69,7 +74,7 @@ const DialogDescription = DialogPrimitive.Description function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
) diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 84358c9..44a6551 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -30,7 +30,13 @@ type SidebarContextProps = { openMobile: boolean setOpenMobile: (open: boolean) => void isMobile: boolean + isPinned: boolean + setIsPinned: (pinned: boolean | ((prev: boolean) => boolean)) => void toggleSidebar: () => void + handleTriggerMouseEnter: () => void + handleTriggerMouseLeave: () => void + handleSidebarMouseEnter: () => void + handleSidebarMouseLeave: () => void } const SidebarContext = React.createContext(null) @@ -42,7 +48,7 @@ function useSidebar() { } function SidebarProvider({ - defaultOpen = true, + defaultOpen = false, open: openProp, onOpenChange: setOpenProp, className, @@ -58,6 +64,9 @@ function SidebarProvider({ const [openMobile, setOpenMobile] = React.useState(false) const [_open, _setOpen] = React.useState(defaultOpen) + const [isPinned, setIsPinnedState] = React.useState(defaultOpen) + const isPinnedRef = React.useRef(defaultOpen) + const open = openProp ?? _open const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { @@ -69,9 +78,72 @@ function SidebarProvider({ [setOpenProp, open], ) + const setIsPinned = React.useCallback((value: boolean | ((prev: boolean) => boolean)) => { + setIsPinnedState((prev) => { + const next = typeof value === 'function' ? value(prev) : value + isPinnedRef.current = next + return next + }) + }, []) + + const hoverTimeoutRef = React.useRef | null>(null) + + const cancelCloseTimer = React.useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current) + hoverTimeoutRef.current = null + } + }, []) + + const scheduleCloseTimer = React.useCallback(() => { + cancelCloseTimer() + hoverTimeoutRef.current = setTimeout(() => { + if (!isPinnedRef.current) { + setOpen(false) + } + }, 300) + }, [cancelCloseTimer, setOpen]) + + const handleTriggerMouseEnter = React.useCallback(() => { + if (isMobile) return + cancelCloseTimer() + setOpen(true) + }, [isMobile, cancelCloseTimer, setOpen]) + + const handleTriggerMouseLeave = React.useCallback(() => { + if (isMobile || isPinnedRef.current) return + scheduleCloseTimer() + }, [isMobile, scheduleCloseTimer]) + + const handleSidebarMouseEnter = React.useCallback(() => { + if (isMobile) return + cancelCloseTimer() + }, [isMobile, cancelCloseTimer]) + + const handleSidebarMouseLeave = React.useCallback(() => { + if (isMobile || isPinnedRef.current) return + scheduleCloseTimer() + }, [isMobile, scheduleCloseTimer]) + + React.useEffect(() => { + return () => { + cancelCloseTimer() + } + }, [cancelCloseTimer]) + const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o) - }, [isMobile, setOpen, setOpenMobile]) + cancelCloseTimer() + if (isMobile) { + return setOpenMobile((o) => !o) + } + + // اگر در وضعیت پین/فیکس بود، با کلیک مجدد بسته شده و به رفتار اولیه (هاور موقت) بازمی‌گردد. + // اگر فیکس نبود (حتی اگر با هاور باز شده باشد)، قفل/فیکس شده و باز می‌ماند. + const nextPinned = !isPinnedRef.current + isPinnedRef.current = nextPinned + setIsPinnedState(nextPinned) + setOpen(nextPinned) + }, [cancelCloseTimer, isMobile, setOpen, setOpenMobile]) React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -87,8 +159,36 @@ function SidebarProvider({ const state = open ? 'expanded' : 'collapsed' const contextValue = React.useMemo( - () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + () => ({ + state, + open, + setOpen, + openMobile, + setOpenMobile, + isMobile, + isPinned, + setIsPinned, + toggleSidebar, + handleTriggerMouseEnter, + handleTriggerMouseLeave, + handleSidebarMouseEnter, + handleSidebarMouseLeave, + }), + [ + state, + open, + setOpen, + openMobile, + setOpenMobile, + isMobile, + isPinned, + setIsPinned, + toggleSidebar, + handleTriggerMouseEnter, + handleTriggerMouseLeave, + handleSidebarMouseEnter, + handleSidebarMouseLeave, + ], ) return ( @@ -116,13 +216,25 @@ function Sidebar({ collapsible = 'offcanvas', className, children, + onMouseEnter, + onMouseLeave, + autoCloseOnLeave = true, ...props }: React.ComponentProps<'div'> & { side?: 'left' | 'right' variant?: 'sidebar' | 'floating' | 'inset' collapsible?: 'offcanvas' | 'icon' | 'none' + autoCloseOnLeave?: boolean }) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { + isMobile, + state, + openMobile, + setOpenMobile, + handleSidebarMouseEnter, + handleSidebarMouseLeave, + isPinned, + } = useSidebar() if (collapsible === 'none') { return ( @@ -165,6 +277,7 @@ function Sidebar({ data-variant={variant} data-side={side} data-slot="sidebar" + data-pinned={isPinned} > {/* spacer که فاصلهٔ سایدبار را در flow رزرو می‌کند */}
{ + onMouseEnter?.(event) + if (autoCloseOnLeave) { + handleSidebarMouseEnter?.() + } + }} + onMouseLeave={(event) => { + onMouseLeave?.(event) + if (autoCloseOnLeave) { + handleSidebarMouseLeave?.() + } + }} {...props} >
) { - const { toggleSidebar } = useSidebar() +function SidebarTrigger({ + className, + onClick, + onMouseEnter, + onMouseLeave, + ...props +}: React.ComponentProps) { + const { toggleSidebar, handleTriggerMouseEnter, handleTriggerMouseLeave, isPinned } = useSidebar() return (
-
+
+
+
+ - {/* دراپ‌داون تعاملی انتخاب وضعیت صورت‌حساب و دکمه ذخیره سریع در هدر */} -
-
- -
+ {/* بدنه محتوا با اسکرول داخلی مجزا */} +
+ {/* کادر مهلت پرداخت (تاریخ انقضا) */} + {activeFactor.expiration_date && ( +
+ + + مهلت پرداخت (تاریخ انقضا): + + + {formatDateByLanguage(activeFactor.expiration_date, language as any)} + +
+ )} - {/* دکمه ذخیره وضعیت سریع در کنار منو برای جلوگیری از سردرگمی کاربر */} - {hasChanges && ( - - )} + {/* بخش استاتوس پرداخت: به صورت دراپ‌داون بولد و تعاملی زیر مهلت پرداخت */} +
+
+
+ +
+
+

وضعیت پرداخت صورت‌حساب:

+

جهت تغییر وضعیت فاکتور، وضعیت مورد نظر را از دراپ‌داون انتخاب نمایید:

+ +
+ +
- - {/* بدنه محتوا با اسکرول داخلی مجزا */} -
{/* کادر ویرایش/مشاهده دلیل رد صورت‌حساب در صورت انتخاب وضعیت رد شده */} {selectedStatus === 'rejected' && ( -
+
@@ -313,8 +355,23 @@ export function BillingDetailModal({
)} - {/* اطلاع‌رسانی تمدید خودکار مهلت 3 روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */} - {(activeFactor.status === 'expired' || activeFactor.status === 'rejected') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && ( + {/* کارت وضعیت کنسل‌شدن صورت‌حساب */} + {activeFactor.status === 'cancelled' && selectedStatus === 'cancelled' && ( +
+
+ +
+
+

وضعیت صورت‌حساب: کنسل‌شده

+

+ {activeFactor.rejected_description || 'این صورت‌حساب کنسل شده است.'} +

+
+
+ )} + + {/* اطلاع‌رسانی تمدید خودکار مهلت 3 روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده یا کنسل‌شده */} + {(activeFactor.status === 'expired' || activeFactor.status === 'rejected' || activeFactor.status === 'cancelled') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
@@ -335,18 +392,6 @@ export function BillingDetailModal({
)} - {activeFactor.expiration_date && ( -
- - - مهلت پرداخت (تاریخ انقضا): - - - {formatDateByLanguage(activeFactor.expiration_date, language as any)} - -
- )} - {/* اطلاعات خریدار و اطلاعات مالی در ۲ ستون */}
{/* کارت مشخصات خریدار */} @@ -758,6 +803,20 @@ export function BillingDetailModal({
+ {/* دکمه لغو فاکتور: منحصراً در وضعیت در انتظار پرداخت و پیش از بارگذاری هرگونه فیش یا پرداخت */} + {activeFactor.status === 'awaiting_payment' && !activeFactor.transaction_receipt && ( + + )} + - ) - })} -
+
+ setActiveLangTab(lang)} + completedLangs={{ + ar: Boolean(titleAr.trim()), + fa: Boolean(titleFa.trim()), + en: Boolean(titleEn.trim()), + ru: Boolean(titleRu.trim()), + id: Boolean(titleId.trim()), + }} + primaryLang="ar" + title="اطلاعات متنی مکان" + variant="pills" + /> + {isLoadingDetail && ( +
+ + در حال دریافت ترجمه‌ها از دیتابیس... +
+ )}
{/* لودینگ اسکلتون شیمر خنثی در صورت خالی بودن داده‌ها هنگام دریافت ترجمه‌ها */} diff --git a/src/features/services/components/DuasServiceView.tsx b/src/features/services/components/DuasServiceView.tsx index f08267a..5d79898 100644 --- a/src/features/services/components/DuasServiceView.tsx +++ b/src/features/services/components/DuasServiceView.tsx @@ -754,7 +754,7 @@ export function DuasServiceView() { }, [duas]) return ( -
+
{/* ۰. کارت‌های آماری کلان ادعیه و زیارات */}
{/* کل ادعیه */} @@ -941,12 +941,12 @@ export function DuasServiceView() { - شناسه + شناسه نام زیارت / دعا شهر(ها) دسته‌بندی اعمال مکان متبرک / حرم - تعداد فراز + تعداد فراز وضعیت عملیات @@ -1103,9 +1103,9 @@ export function DuasServiceView() { - {toFa(dua.verses_count)} فراز + {toFa(dua.verses_count)} فراز diff --git a/src/features/services/components/FaqServiceView.tsx b/src/features/services/components/FaqServiceView.tsx index 3c243a0..efab283 100644 --- a/src/features/services/components/FaqServiceView.tsx +++ b/src/features/services/components/FaqServiceView.tsx @@ -18,6 +18,10 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog' +import { + ServiceTranslation, + type ServiceLanguageCode, +} from '@/components/shared/ServiceTranslation' import { SUPPORTED_LANGUAGES, type SupportedLanguage, @@ -1345,37 +1349,20 @@ export function FaqServiceView() { - {/* تب‌های انتخاب زبان */} -
- {SUPPORTED_LANGUAGES.map((lang) => { - const isCurrent = catActiveLang === lang.code - const isFilled = Boolean(catTranslations[lang.code]?.title.trim()) - return ( - - ) - })} -
+ {/* تب‌های انتخاب زبان با ServiceTranslation */} + setCatActiveLang(lang as SupportedLanguage)} + completedLangs={Object.fromEntries( + (['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [ + code, + Boolean(catTranslations[code]?.title.trim()), + ]) + )} + primaryLang="ar" + title="زبان ترجمه و محتوا" + variant="pills" + /> {/* فیلدهای ورودی زبان انتخاب‌شده */}
- {/* تب‌های انتخاب زبان */} -
- {SUPPORTED_LANGUAGES.map((lang) => { - const isCurrent = itemActiveLang === lang.code - const isFilled = - Boolean(itemTranslations[lang.code]?.question.trim()) && - Boolean(itemTranslations[lang.code]?.answer.trim()) - return ( - - ) - })} -
+ {/* تب‌های انتخاب زبان با ServiceTranslation */} + setItemActiveLang(lang as SupportedLanguage)} + completedLangs={Object.fromEntries( + (['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [ + code, + Boolean( + itemTranslations[code]?.question.trim() && + itemTranslations[code]?.answer.trim() + ), + ]) + )} + primaryLang="ar" + title="زبان ترجمه و محتوا" + variant="pills" + /> {/* فیلدهای ورودی زبان انتخاب‌شده */}
+
{/* ردیف آمار خلاصه (طراحی خنثی مطابق سند) */}
diff --git a/src/features/services/components/SimcardFormDialog.tsx b/src/features/services/components/SimcardFormDialog.tsx index d50c6c7..47b12aa 100644 --- a/src/features/services/components/SimcardFormDialog.tsx +++ b/src/features/services/components/SimcardFormDialog.tsx @@ -11,9 +11,14 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Ic } from '@/icons' +import { + ServiceTranslation, + SERVICE_LANGUAGES, + type ServiceLanguageCode, +} from '@/components/shared/ServiceTranslation' import type { SimCardItem, CreateSimCardInput } from '../services/simcard-api' -export type SupportedLanguage = 'ar' | 'fa' | 'en' | 'ru' | 'id' +export type SupportedLanguage = ServiceLanguageCode interface SimcardFormDialogProps { open: boolean @@ -29,13 +34,7 @@ const COUNTRIES = [ { id: 4, code: 'SA', name: 'عربستان سعودی', flag: '🇸🇦' }, ] -export const LANGUAGES = [ - { code: 'ar', name: 'العربية', flag: '🇸🇦', badge: 'AR', dir: 'rtl', label: 'العربية (پیش‌فرض)' }, - { code: 'fa', name: 'فارسی', flag: '🇮🇷', badge: 'FA', dir: 'rtl', label: 'فارسی' }, - { code: 'en', name: 'English', flag: '🇬🇧', badge: 'EN', dir: 'ltr', label: 'انگلیسی' }, - { code: 'ru', name: 'Русский', flag: '🇷🇺', badge: 'RU', dir: 'ltr', label: 'روسی' }, - { code: 'id', name: 'Bahasa Indonesia', flag: '🇮🇩', badge: 'ID', dir: 'ltr', label: 'اندونزیایی' }, -] as const +export const LANGUAGES = SERVICE_LANGUAGES const createEmptyTitles = (): Record => ({ ar: '', @@ -379,47 +378,18 @@ export function SimcardFormDialog({
- {/* ۲. تب‌های انتخاب ۵ زبان با پرچم و نشانگر وضعیت ترجمه (زیر کشور مقصد) */} -
-
- -
- -
- {LANGUAGES.map((lang) => { - const isSelected = activeLangTab === lang.code - const hasTitle = Boolean(titles[lang.code]?.trim()) - const detailsCount = (details[lang.code] || []).length - return ( - - ) - })} -
-
+ {/* ۲. تب‌های انتخاب ۵ زبان با کامپوننت مشترک ServiceTranslation */} + handleTabChange(lang)} + completedLangs={Object.fromEntries( + Object.entries(titles).map(([k, v]) => [k, Boolean(v?.trim())]) + )} + primaryLang="ar" + title="زبان ترجمه و محتوا" + required + variant="grid" + /> {/* ۳. عنوان بسته سیم‌کارت به زبان فعال */}
diff --git a/src/features/services/components/StoreServiceView.tsx b/src/features/services/components/StoreServiceView.tsx index 5200d5d..a20be27 100644 --- a/src/features/services/components/StoreServiceView.tsx +++ b/src/features/services/components/StoreServiceView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect, useMemo, useRef } from 'react' import { Ic } from '@/icons' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -8,6 +8,11 @@ import { Switch } from '@/components/ui/switch' import { Shimmer, ShimmerBox } from '@/components/shared/shimmer_frontend' import { toFa, cn } from '@/lib/utils' import { toast } from '@/stores/toast' +import { + ServiceTranslation, + SERVICE_LANGUAGES, + type ServiceLanguageCode, +} from '@/components/shared/ServiceTranslation' import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../types/faq' import { Dialog, @@ -32,11 +37,26 @@ import { updateStoreCategory, deleteStoreCategory, fetchStoreProducts, + fetchStoreProductDetail, createStoreProduct, updateStoreProduct, deleteStoreProduct, + uploadStoreProductImage, + deleteStoreProductImage, + fetchStoreAvailableCountries, + type StoreCountryOption, } from '../services/store-api' +const COUNTRY_MAP: Record = { + IR: { nameFa: 'ایران', flag: '🇮🇷' }, + IQ: { nameFa: 'عراق', flag: '🇮🇶' }, + SA: { nameFa: 'عربستان', flag: '🇸🇦' }, + SY: { nameFa: 'سوریه', flag: '🇸🇾' }, + OMN: { nameFa: 'عمان', flag: '🇴🇲' }, + KWT: { nameFa: 'کویت', flag: '🇰🇼' }, + BHR: { nameFa: 'بحرین', flag: '🇧🇭' }, +} + const DEFAULT_CATEGORY_TRANSLATIONS: Record> = { food: { fa: 'خوراکی', @@ -140,6 +160,10 @@ export function StoreServiceView({ // فیلترهای محصولات const [productSearch, setProductSearch] = useState('') const [selectedCategoryFilter, setSelectedCategoryFilter] = useState('ALL') + const [selectedCountryFilter, setSelectedCountryFilter] = useState('ALL') + + // لیست کشورهای در دسترس + const [availableCountries, setAvailableCountries] = useState([]) // مودال ایجاد / ویرایش دسته‌بندی const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false) @@ -163,12 +187,43 @@ export function StoreServiceView({ // مودال ایجاد / ویرایش محصول const [isProductModalOpen, setIsProductModalOpen] = useState(false) const [editingProduct, setEditingProduct] = useState(null) - const [productTitleInput, setProductTitleInput] = useState('') + const [prodActiveLang, setProdActiveLang] = useState('ar') + const [prodTitles, setProdTitles] = useState>({ + fa: '', + ar: '', + en: '', + ru: '', + id: '', + }) + const [prodDetails, setProdDetails] = useState>({ + fa: '', + ar: '', + en: '', + ru: '', + id: '', + }) const [productPriceInput, setProductPriceInput] = useState('') const [productStockInput, setProductStockInput] = useState('') const [productCategoryIdInput, setProductCategoryIdInput] = useState('') + const [selectedProductCountryIds, setSelectedProductCountryIds] = useState([]) const [isSavingProduct, setIsSavingProduct] = useState(false) + // تصاویر محصول در مودال + interface ProductImageModalItem { + id?: number + filer_id?: number + url: string + isNew?: boolean + isUploading?: boolean + tempId?: string + fileName?: string + } + const [productImages, setProductImages] = useState([]) + const [deletedProductImageIds, setDeletedProductImageIds] = useState([]) + const [newUploadedFilerIds, setNewUploadedFilerIds] = useState([]) + const [isUploadingImage, setIsUploadingImage] = useState(false) + const productImageInputRef = useRef(null) + // مودال حذف محصول const [deletingProduct, setDeletingProduct] = useState(null) const [isDeletingProduct, setIsDeletingProduct] = useState(false) @@ -186,12 +241,23 @@ export function StoreServiceView({ } } + // بارگذاری کشورها + const loadAvailableCountries = async () => { + try { + const data = await fetchStoreAvailableCountries() + setAvailableCountries(data) + } catch (err: any) { + console.warn('Failed to load store countries:', err) + } + } + // بارگذاری محصولات const loadProducts = async () => { try { setIsLoadingProducts(true) const data = await fetchStoreProducts({ category: selectedCategoryFilter !== 'ALL' ? selectedCategoryFilter : undefined, + country: selectedCountryFilter !== 'ALL' ? selectedCountryFilter : undefined, search: productSearch || undefined, }) setProducts(data) @@ -202,9 +268,10 @@ export function StoreServiceView({ } } - // لود اولیه دسته‌ها + // لود اولیه دسته‌ها و کشورها useEffect(() => { loadCategories() + loadAvailableCountries() }, []) // لود محصولات به محض سوئیچ به تب یا تغییر فیلتر @@ -212,7 +279,7 @@ export function StoreServiceView({ if (activeTab === 'products') { loadProducts() } - }, [activeTab, selectedCategoryFilter]) + }, [activeTab, selectedCategoryFilter, selectedCountryFilter]) // رفرش داده‌ها const handleRefresh = async () => { @@ -220,7 +287,7 @@ export function StoreServiceView({ if (activeTab === 'categories') { await loadCategories() } else { - await Promise.all([loadCategories(), loadProducts()]) + await Promise.all([loadCategories(), loadProducts(), loadAvailableCountries()]) } setIsRefreshing(false) toast.success('اطلاعات با موفقیت به‌روزرسانی شد.') @@ -410,22 +477,160 @@ export function StoreServiceView({ } } + // آپلود تصاویر محصول با لودینگ مجزا برای هر تصویر + const handleProductImageSelect = async (files: FileList | null) => { + if (!files || files.length === 0) return + const fileList = Array.from(files) + + const validFiles: { file: File; tempId: string; previewUrl: string }[] = [] + + for (const file of fileList) { + if (!file.type.startsWith('image/')) { + toast.error(`فایل «${file.name}» فرمت تصویر معتبری نیست.`) + continue + } + if (file.size > 10 * 1024 * 1024) { + toast.error(`حجم فایل «${file.name}» بیشتر از ۱۰ مگابایت است.`) + continue + } + + const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + const previewUrl = URL.createObjectURL(file) + validFiles.push({ file, tempId, previewUrl }) + } + + if (validFiles.length === 0) { + if (productImageInputRef.current) productImageInputRef.current.value = '' + return + } + + // اضافه کردن تمام تصاویر انتخابی با وضعیت در حال آپلود به گالری + setProductImages((prev) => [ + ...prev, + ...validFiles.map((vf) => ({ + tempId: vf.tempId, + url: vf.previewUrl, + fileName: vf.file.name, + isUploading: true, + isNew: true, + })), + ]) + + setIsUploadingImage(true) + + try { + // آپلود همزمان همه تصاویر و به‌روزرسانی مستقل وضعیت هر کدام + await Promise.allSettled( + validFiles.map(async ({ file, tempId, previewUrl }) => { + try { + const res = await uploadStoreProductImage(file) + const fid = res.filer_id || res.id + + setNewUploadedFilerIds((prev) => [...prev, fid]) + setProductImages((prev) => + prev.map((item) => + item.tempId === tempId + ? { + id: res.product_image_id || undefined, + filer_id: fid, + url: res.url, + isNew: true, + isUploading: false, + } + : item + ) + ) + toast.success(`تصویر «${file.name}» با موفقیت افزوده شد.`) + } catch (err: any) { + setProductImages((prev) => prev.filter((item) => item.tempId !== tempId)) + toast.error(err?.message || `خطا در آپلود تصویر «${file.name}».`) + } finally { + URL.revokeObjectURL(previewUrl) + } + }) + ) + } finally { + setIsUploadingImage(false) + if (productImageInputRef.current) { + productImageInputRef.current.value = '' + } + } + } + + // حذف تصویر محصول + const handleRemoveProductImage = (index: number) => { + const target = productImages[index] + if (!target || target.isUploading) return + + if (target.id && !target.isNew) { + setDeletedProductImageIds((prev) => [...prev, target.id!]) + } + if (target.filer_id && target.isNew) { + setNewUploadedFilerIds((prev) => prev.filter((id) => id !== target.filer_id)) + } + + setProductImages((prev) => prev.filter((_, i) => i !== index)) + } + // ذخیره محصول (افزودن یا ویرایش) const handleSaveProduct = async (e: React.FormEvent) => { e.preventDefault() - const cleanTitle = productTitleInput.trim() + if (productImages.some((img) => img.isUploading)) { + toast.error('لطفاً تا اتمام فرآیند آپلود تمامی تصاویر شکیبا باشید.') + return + } + + const cleanTitle = + prodTitles[prodActiveLang]?.trim() || + prodTitles.ar?.trim() || + prodTitles.fa?.trim() || + prodTitles.en?.trim() || + '' + if (!cleanTitle) { - toast.error('لطفاً عنوان محصول را وارد نمایید.') + toast.error('لطفاً عنوان محصول را حداقل در یک زبان وارد نمایید.') return } + if (selectedProductCountryIds.length === 0) { + toast.error('لطفاً حداقل یک کشور در دسترس برای این کالا انتخاب نمایید.') + return + } + + const titlesList = (['fa', 'ar', 'en', 'ru', 'id'] as ServiceLanguageCode[]) + .map((code) => ({ + language_code: code, + title: prodTitles[code]?.trim() || cleanTitle, + })) + .filter((item) => Boolean(item.title)) + + const detailsList = (['fa', 'ar', 'en', 'ru', 'id'] as ServiceLanguageCode[]) + .map((code) => ({ + language_code: code, + detail: prodDetails[code]?.trim() || '', + })) + .filter((item) => Boolean(item.detail)) + + const cleanDetailText = + prodDetails[prodActiveLang]?.trim() || + prodDetails.ar?.trim() || + prodDetails.fa?.trim() || + prodDetails.en?.trim() || + '' + setIsSavingProduct(true) try { const payload: CreateStoreProductInput = { title_text: cleanTitle, + title: titlesList, + detail_text: cleanDetailText, + detail: detailsList, price: productPriceInput || '0', stock: Number(productStockInput) || 0, category_id: productCategoryIdInput !== '' ? Number(productCategoryIdInput) : null, + country_ids: selectedProductCountryIds, + uploaded_image_ids: newUploadedFilerIds, + delete_image_ids: deletedProductImageIds, } if (editingProduct) { @@ -439,10 +644,16 @@ export function StoreServiceView({ } setIsProductModalOpen(false) setEditingProduct(null) - setProductTitleInput('') + setProdTitles({ fa: '', ar: '', en: '', ru: '', id: '' }) + setProdDetails({ fa: '', ar: '', en: '', ru: '', id: '' }) + setProdActiveLang('ar') setProductPriceInput('') setProductStockInput('') setProductCategoryIdInput('') + setSelectedProductCountryIds([]) + setProductImages([]) + setDeletedProductImageIds([]) + setNewUploadedFilerIds([]) } catch (err: any) { toast.error(err?.message || 'خطا در ذخیره اطلاعات محصول.') } finally { @@ -450,6 +661,112 @@ export function StoreServiceView({ } } + // پر کردن فرم مودال با داده‌های محصول + const populateProductFormData = (product: StoreProductItem) => { + setEditingProduct(product) + const initialTitles: Record = { + fa: '', + ar: '', + en: '', + ru: '', + id: '', + } + const initialDetails: Record = { + fa: '', + ar: '', + en: '', + ru: '', + id: '', + } + + if (Array.isArray(product.raw_title)) { + for (const item of product.raw_title) { + if (item && item.language_code && item.title) { + const code = item.language_code as ServiceLanguageCode + if (code in initialTitles) initialTitles[code] = item.title + } + } + } else if (product.raw_title && typeof product.raw_title === 'object') { + for (const code of ['fa', 'ar', 'en', 'ru', 'id'] as ServiceLanguageCode[]) { + if (product.raw_title[code]) initialTitles[code] = String(product.raw_title[code]) + } + } + + if (Array.isArray(product.raw_detail)) { + for (const item of product.raw_detail) { + if (item && item.language_code && item.detail) { + const code = item.language_code as ServiceLanguageCode + if (code in initialDetails) initialDetails[code] = item.detail + } + } + } else if (Array.isArray(product.detail)) { + for (const item of product.detail) { + if (item && item.language_code && item.detail) { + const code = item.language_code as ServiceLanguageCode + if (code in initialDetails) initialDetails[code] = item.detail + } + } + } else if (typeof product.detail === 'string' && product.detail) { + initialDetails.ar = product.detail + initialDetails.fa = product.detail + initialDetails.en = product.detail + } + + if (!initialTitles.ar && !initialTitles.fa && product.title) { + initialTitles.fa = product.title + initialTitles.ar = product.title + initialTitles.en = product.title + } + + setProdTitles(initialTitles) + setProdDetails(initialDetails) + setProdActiveLang(initialTitles.fa ? 'fa' : initialTitles.ar ? 'ar' : 'en') + setProductPriceInput(product.price) + setProductStockInput(String(product.stock)) + setProductCategoryIdInput(product.category?.id || '') + const initialCountryIds = + product.countries && product.countries.length > 0 + ? product.countries.map((c) => c.id) + : product.country + ? [product.country.id] + : [] + setSelectedProductCountryIds(initialCountryIds) + const existingImages = (product.images || []) + .map((img) => ({ + id: img.id, + url: + img.url || + img.image_url?.medium || + img.image_url?.large || + img.image_url?.original || + img.image_url?.sm || + '', + isNew: false, + })) + .filter((img) => Boolean(img.url)) + setProductImages(existingImages) + setDeletedProductImageIds([]) + setNewUploadedFilerIds([]) + } + + // باز کردن مودال ویرایش محصول و واکشی مستقیم جزئیات تازه از سرور + const handleOpenEditProduct = async (product: StoreProductItem) => { + populateProductFormData(product) + setIsProductModalOpen(true) + + if (product.slug) { + try { + const fresh = await fetchStoreProductDetail(product.slug) + if (fresh) { + populateProductFormData(fresh) + setProducts((prev) => prev.map((p) => (p.slug === fresh.slug ? { ...p, ...fresh } : p))) + } + } catch (err) { + console.warn('Failed to fetch fresh product detail:', err) + } + } + } + // تایید حذف محصول const handleConfirmDeleteProduct = async () => { if (!deletingProduct) return @@ -641,10 +958,16 @@ export function StoreServiceView({ size="sm" onClick={() => { setEditingProduct(null) - setProductTitleInput('') + setProdTitles({ fa: '', ar: '', en: '', ru: '', id: '' }) + setProdDetails({ fa: '', ar: '', en: '', ru: '', id: '' }) + setProdActiveLang('ar') setProductPriceInput('') setProductStockInput('') setProductCategoryIdInput('') + setSelectedProductCountryIds([]) + setProductImages([]) + setDeletedProductImageIds([]) + setNewUploadedFilerIds([]) setIsProductModalOpen(true) }} className="h-9 px-3.5 rounded-xl text-xs gap-1.5 font-bold" @@ -909,6 +1232,26 @@ export function StoreServiceView({ ))}
+ + {/* منوی فیلتر کشور */} +
+ کشور: + +
- + @@ -961,6 +1304,7 @@ export function StoreServiceView({ size="sm" onClick={() => { setSelectedCategoryFilter('ALL') + setSelectedCountryFilter('ALL') setProductSearch('') loadProducts() }} @@ -973,7 +1317,8 @@ export function StoreServiceView({ ) : ( products.map((product) => { - const firstImage = product.images?.[0]?.image_url?.small || + const firstImage = product.images?.[0]?.url || + product.images?.[0]?.image_url?.small || product.images?.[0]?.image_url?.sm || product.images?.[0]?.image_url?.original @@ -999,11 +1344,96 @@ export function StoreServiceView({ )} - {/* عنوان */} - {/* دسته‌بندی */} @@ -1032,9 +1462,49 @@ export function StoreServiceView({ {toFa(product.stock)} عدد - {/* کشور */} - {/* عملیات */} @@ -1043,14 +1513,7 @@ export function StoreServiceView({ - ) - })} - + {/* تب‌های انتخاب زبان با کامپوننت مشترک ServiceTranslation */} + setCatActiveLang(lang as SupportedLanguage)} + completedLangs={Object.fromEntries( + Object.entries(catTranslations).map(([k, v]) => [k, Boolean(v?.trim())]) + )} + primaryLang="ar" + title="زبان ترجمه و محتوای دسته‌بندی" + variant="pills" + /> {/* فیلد ورودی زبان انتخاب‌شده */}
!open && setDeletingCategory(null)}> - +
@@ -1325,7 +1768,7 @@ export function StoreServiceView({ {/* مودال افزودن / ویرایش محصول (شامل Dropdown دسته‌بندی) */} {/* ─────────────────────────────────────────────────────────────────── */} - +
@@ -1352,20 +1795,69 @@ export function StoreServiceView({
-
- {/* عنوان */} -
- - setProductTitleInput(e.target.value)} - placeholder="مثال: تسبیح تربت اصل، جانماز ترمه..." - className="h-10 text-xs rounded-xl bg-background border-border-soft focus:border-primary px-3 text-start" - dir="auto" - /> -
+
+ {/* کامپوننت یکپارچه چندزبانه برای عنوان و مشخصات کالا */} + + + {/* ورودی‌های متنی بر اساس زبان فعال */} + {(() => { + const currentMeta = + SERVICE_LANGUAGES.find((l) => l.code === prodActiveLang) || SERVICE_LANGUAGES[0] + return ( +
+ {/* عنوان کالا */} +
+ + + setProdTitles((prev) => ({ ...prev, [prodActiveLang]: e.target.value })) + } + placeholder={`عنوان کالا به زبان ${currentMeta.name}...`} + className="h-10 text-xs rounded-xl bg-background border-border-soft focus:border-primary px-3 text-start" + dir={currentMeta.dir} + /> +
+ + {/* توضیحات کالا */} +
+ +
قیمت واحد موجودی انبارکشور مبدأکشورهای در دسترس عملیات
-
- {product.title} -
+ {/* عنوان و توضیحات */} +
+ {(() => { + const titlesMap: Record = {} + if (Array.isArray(product.raw_title)) { + for (const item of product.raw_title) { + if (item && item.language_code && item.title) { + titlesMap[item.language_code] = item.title + } + } + } else if (product.raw_title && typeof product.raw_title === 'object') { + for (const [k, v] of Object.entries(product.raw_title)) { + if (v) titlesMap[k] = String(v) + } + } + + const detailsMap: Record = {} + if (Array.isArray(product.raw_detail)) { + for (const item of product.raw_detail) { + if (item && item.language_code && item.detail) { + detailsMap[item.language_code] = item.detail + } + } + } else if (product.raw_detail && typeof product.raw_detail === 'object') { + for (const [k, v] of Object.entries(product.raw_detail)) { + if (v) detailsMap[k] = String(v) + } + } + + const primaryTitle = + titlesMap.fa || + titlesMap.ar || + product.title || + titlesMap.en || + 'بدون عنوان' + + const secondaryTitle = + titlesMap.en && titlesMap.en !== primaryTitle + ? titlesMap.en + : titlesMap.ar && titlesMap.ar !== primaryTitle + ? titlesMap.ar + : null + + const displayDetail = + detailsMap.fa || + detailsMap.ar || + (typeof product.detail === 'string' ? product.detail : '') || + detailsMap.en || + '' + + return ( +
+
+ {primaryTitle} +
+ {secondaryTitle && ( +
+ {secondaryTitle} +
+ )} + {displayDetail && ( +
+ {displayDetail} +
+ )} +
+ {SERVICE_LANGUAGES.map((l) => { + const trVal = titlesMap[l.code] + const hasText = Boolean(trVal?.trim()) + return hasText ? ( + + {l.code.toUpperCase()} + + ) : null + })} +
+
+ ) + })()}
- {product.country?.name || '—'} + {/* کشورهای در دسترس */} + + {(() => { + const list = + product.countries && product.countries.length > 0 + ? product.countries + : product.country + ? [product.country] + : [] + + if (list.length === 0) { + return عمومی / بدون محدودیت + } + + if (availableCountries.length > 0 && list.length === availableCountries.length) { + return ( + + 🌐 همه کشورها ({toFa(list.length)}) + + ) + } + + return ( +
+ {list.map((c) => { + const meta = COUNTRY_MAP[c.code] || { nameFa: c.name, flag: '📍' } + return ( + + {meta.flag} + {meta.nameFa} + + ) + })} +
+ ) + })()}