Browse Source
feat: add core panel UI features including billing, tours, services, layout components, and i18n support
master
feat: add core panel UI features including billing, tours, services, layout components, and i18n support
master
27 changed files with 2629 additions and 1396 deletions
-
548guide/aqila_panel_ui_design_system_standard.md
-
6src/app/router.tsx
-
242src/components/layout/AppSidebar.tsx
-
67src/components/layout/LanguageSwitcher.tsx
-
50src/components/layout/Topbar.tsx
-
96src/config/navigation.ts
-
428src/features/billing/BillingPage.tsx
-
5src/features/billing/components/BillingDetailModal.tsx
-
21src/features/billing/components/BillingServiceBadge.tsx
-
14src/features/billing/components/BillingStatusBadge.tsx
-
28src/features/billing/components/RejectFactorDialog.tsx
-
25src/features/global-search/GlobalSearchDialog.tsx
-
398src/features/services/ServicesPage.tsx
-
4src/features/services/components/CategoriesServiceView.tsx
-
4src/features/services/components/CreateLocationDialog.tsx
-
8src/features/services/components/DuasServiceView.tsx
-
1080src/features/services/components/FaqServiceView.tsx
-
2src/features/services/components/ShrinesServiceView.tsx
-
26src/features/tours/OngoingToursPage.tsx
-
34src/features/tours/ToursPage.tsx
-
141src/i18n/index.ts
-
4src/index.css
-
133src/locales/ar.json
-
133src/locales/en.json
-
133src/locales/fa.json
-
2src/main.tsx
-
39src/stores/billing-badge.ts
@ -14,19 +14,59 @@ import { |
|||
SidebarMenuBadge, |
|||
} from '@/components/ui/sidebar' |
|||
import { useAuth, useLogout } from '@/features/auth/use-auth' |
|||
import { navigationConfig } from '@/config/navigation' |
|||
import { getNavigationConfig, type NavItemConfig, type NavSubItemConfig } from '@/config/navigation' |
|||
import { siteConfig } from '@/config/site' |
|||
import { AqilaLogo } from '@/components/AqilaLogo' |
|||
import { useTranslation } from '@/i18n' |
|||
import { useBillingBadgeStore } from '@/stores/billing-badge' |
|||
|
|||
import * as React from 'react' |
|||
import { cn } from '@/lib/utils' |
|||
import type { NavItemConfig } from '@/config/navigation' |
|||
|
|||
function BillingSidebarBadges() { |
|||
const { pendingReviewCount, awaitingPaymentCount, fetchCounts } = useBillingBadgeStore() |
|||
const { t } = useTranslation() |
|||
|
|||
React.useEffect(() => { |
|||
fetchCounts() |
|||
// Poll every 30 seconds for live updates
|
|||
const interval = setInterval(fetchCounts, 30000) |
|||
return () => clearInterval(interval) |
|||
}, [fetchCounts]) |
|||
|
|||
if (pendingReviewCount === 0 && awaitingPaymentCount === 0) return null |
|||
|
|||
return ( |
|||
<div className="flex items-center gap-1 ms-auto shrink-0"> |
|||
{/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */} |
|||
{pendingReviewCount > 0 && ( |
|||
<span |
|||
className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-amber-500 text-white font-mono font-bold text-[10px] shadow-xs animate-pulse" |
|||
title={`${t('billing.pendingReview')}: ${pendingReviewCount}`} |
|||
> |
|||
{pendingReviewCount} |
|||
</span> |
|||
)} |
|||
|
|||
{/* ۲. دایره آبی: در انتظار پرداخت کاربر */} |
|||
{awaitingPaymentCount > 0 && ( |
|||
<span |
|||
className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-blue-500 text-white font-mono font-bold text-[10px] shadow-xs" |
|||
title={`${t('billing.awaitingPayment')}: ${awaitingPaymentCount}`} |
|||
> |
|||
{awaitingPaymentCount} |
|||
</span> |
|||
)} |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { |
|||
const { t } = useTranslation() |
|||
if (soon) { |
|||
return ( |
|||
<span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3"> |
|||
بهزودی |
|||
{t('navigation.soon')} |
|||
</span> |
|||
) |
|||
} |
|||
@ -34,22 +74,141 @@ function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { |
|||
return null |
|||
} |
|||
|
|||
function checkSubItemActive(sub: NavSubItemConfig, pathname: string): boolean { |
|||
if (sub.to) { |
|||
if (sub.to === '/tours' || sub.to === '/services') { |
|||
if (pathname === sub.to) return true |
|||
} else if (pathname === sub.to || pathname.startsWith(sub.to + '/')) { |
|||
return true |
|||
} |
|||
} |
|||
if (sub.children && sub.children.length > 0) { |
|||
return sub.children.some((c) => checkSubItemActive(c, pathname)) |
|||
} |
|||
return false |
|||
} |
|||
|
|||
function SubItem({ sub, pathname, dir }: { sub: NavSubItemConfig; pathname: string; dir: 'rtl' | 'ltr' }) { |
|||
const hasGrandChildren = Boolean(sub.children && sub.children.length > 0) |
|||
const isGrandChildActive = Boolean(hasGrandChildren && sub.children?.some((c) => checkSubItemActive(c, pathname))) |
|||
const [isOpen, setIsOpen] = React.useState<boolean>(isGrandChildActive) |
|||
|
|||
React.useEffect(() => { |
|||
if (isGrandChildActive) { |
|||
setIsOpen(true) |
|||
} |
|||
}, [isGrandChildActive]) |
|||
|
|||
if (hasGrandChildren && sub.children) { |
|||
return ( |
|||
<div className="flex flex-col"> |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOpen((prev) => !prev)} |
|||
className={cn( |
|||
'flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors', |
|||
isGrandChildActive |
|||
? 'text-primary font-bold bg-primary/5' |
|||
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
|||
)} |
|||
> |
|||
<div className="flex items-center gap-2 min-w-0"> |
|||
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />} |
|||
<span className="whitespace-nowrap">{sub.title}</span> |
|||
</div> |
|||
<div className="flex items-center gap-1 ms-auto shrink-0"> |
|||
{sub.badge && ( |
|||
<span className="rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary"> |
|||
{sub.badge} |
|||
</span> |
|||
)} |
|||
<Ic |
|||
name={isOpen ? 'chevUp' : 'chevDown'} |
|||
className="size-3 text-grey-3 transition-transform shrink-0 opacity-70" |
|||
/> |
|||
</div> |
|||
</button> |
|||
|
|||
{isOpen && ( |
|||
<div |
|||
className={cn( |
|||
'mt-1 flex flex-col gap-1 border-border/40', |
|||
dir === 'ltr' ? 'ml-3 border-l pl-2' : 'mr-3 border-r pr-2' |
|||
)} |
|||
> |
|||
{sub.children.map((grand) => { |
|||
const isGrandActive = grand.to |
|||
? grand.to === '/services' || grand.to === '/tours' |
|||
? pathname === grand.to |
|||
: pathname === grand.to || pathname.startsWith(grand.to + '/') |
|||
: false |
|||
return ( |
|||
<NavLink |
|||
key={grand.to || grand.title} |
|||
to={grand.to || '#'} |
|||
className={cn( |
|||
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors', |
|||
isGrandActive |
|||
? 'bg-primary/15 text-primary font-bold shadow-xs' |
|||
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
|||
)} |
|||
> |
|||
{grand.icon && <Ic name={grand.icon} className="size-3 shrink-0" />} |
|||
<span className="whitespace-nowrap">{grand.title}</span> |
|||
{grand.badge && ( |
|||
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[9px] text-primary"> |
|||
{grand.badge} |
|||
</span> |
|||
)} |
|||
</NavLink> |
|||
) |
|||
})} |
|||
</div> |
|||
)} |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
const isSubActive = |
|||
sub.to === '/tours' || sub.to === '/services' |
|||
? pathname === sub.to |
|||
: Boolean(sub.to && (pathname === sub.to || pathname.startsWith(sub.to + '/'))) |
|||
|
|||
return ( |
|||
<NavLink |
|||
to={sub.to || '#'} |
|||
className={cn( |
|||
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors', |
|||
isSubActive |
|||
? 'bg-primary/15 text-primary font-bold shadow-xs' |
|||
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
|||
)} |
|||
> |
|||
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />} |
|||
<span className="whitespace-nowrap">{sub.title}</span> |
|||
{sub.badge && ( |
|||
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary"> |
|||
{sub.badge} |
|||
</span> |
|||
)} |
|||
</NavLink> |
|||
) |
|||
} |
|||
|
|||
function NavItem({ item }: { item: NavItemConfig }) { |
|||
const { pathname } = useLocation() |
|||
const { dir } = useTranslation() |
|||
const hasChildren = Boolean(item.children && item.children.length > 0) |
|||
const isChildActive = |
|||
hasChildren && |
|||
item.children?.some((c) => |
|||
c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to) |
|||
) |
|||
hasChildren && item.children?.some((c) => checkSubItemActive(c, pathname)) |
|||
const isAlwaysOpen = Boolean(item.alwaysOpen) |
|||
const [isOpen, setIsOpen] = React.useState<boolean>(isAlwaysOpen || Boolean(isChildActive)) |
|||
|
|||
React.useEffect(() => { |
|||
if (isAlwaysOpen || isChildActive) { |
|||
if (isChildActive) { |
|||
setIsOpen(true) |
|||
} |
|||
}, [isAlwaysOpen, isChildActive]) |
|||
}, [isChildActive]) |
|||
|
|||
if (hasChildren && item.children) { |
|||
return ( |
|||
@ -79,45 +238,34 @@ function NavItem({ item }: { item: NavItemConfig }) { |
|||
</SidebarMenuButton> |
|||
|
|||
{(isAlwaysOpen || isOpen) && ( |
|||
<div className="mr-5 mt-1 flex flex-col gap-1 border-r border-border/40 pr-2"> |
|||
{item.children.map((sub) => { |
|||
const isSubActive = |
|||
sub.to === '/tours' || sub.to === '/services' |
|||
? pathname === sub.to |
|||
: pathname.startsWith(sub.to) |
|||
return ( |
|||
<NavLink |
|||
key={sub.to} |
|||
to={sub.to} |
|||
className={cn( |
|||
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors', |
|||
isSubActive |
|||
? 'bg-primary/15 text-primary font-bold shadow-xs' |
|||
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
|||
)} |
|||
> |
|||
<Ic name={sub.icon} className="size-3.5 shrink-0" /> |
|||
<span className="whitespace-nowrap">{sub.title}</span> |
|||
{sub.badge && ( |
|||
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary"> |
|||
{sub.badge} |
|||
</span> |
|||
)} |
|||
</NavLink> |
|||
) |
|||
})} |
|||
<div className={cn( |
|||
'mt-1 flex flex-col gap-1 border-border/40', |
|||
dir === 'ltr' ? 'ml-5 border-l pl-2' : 'mr-5 border-r pr-2' |
|||
)}> |
|||
{item.children.map((sub, idx) => ( |
|||
<SubItem key={sub.to || sub.title || idx} sub={sub} pathname={pathname} dir={dir} /> |
|||
))} |
|||
</div> |
|||
)} |
|||
</SidebarMenuItem> |
|||
) |
|||
} |
|||
|
|||
const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false |
|||
const isActive = item.to |
|||
? item.to === '/' || item.to === '/services' || item.to === '/tours' |
|||
? pathname === item.to |
|||
: pathname === item.to || pathname.startsWith(item.to + '/') |
|||
: false |
|||
const isBilling = item.to === '/billing' |
|||
const inner = ( |
|||
<> |
|||
<Ic name={item.icon} className="size-4 shrink-0" /> |
|||
<span className="whitespace-nowrap">{item.title}</span> |
|||
{isBilling ? ( |
|||
<BillingSidebarBadges /> |
|||
) : ( |
|||
<Tail badge={item.badge} soon={item.soon} /> |
|||
)} |
|||
</> |
|||
) |
|||
|
|||
@ -140,8 +288,9 @@ function NavItem({ item }: { item: NavItemConfig }) { |
|||
function SidebarUserFooter() { |
|||
const { user } = useAuth() |
|||
const logout = useLogout() |
|||
const { t } = useTranslation() |
|||
|
|||
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'م' |
|||
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'A' |
|||
|
|||
return ( |
|||
<SidebarFooter className="mt-auto pb-4"> |
|||
@ -152,7 +301,7 @@ function SidebarUserFooter() { |
|||
</div> |
|||
<div className="min-w-0 flex-1"> |
|||
<div className="truncate text-xs font-bold text-grey-1"> |
|||
{user?.name?.trim() || 'مدیر کل سیستم'} |
|||
{user?.name?.trim() || t('navigation.adminRole')} |
|||
</div> |
|||
<div dir="ltr" className="truncate text-start text-[11px] text-grey-3"> |
|||
{user?.email ?? '[email protected]'} |
|||
@ -162,12 +311,12 @@ function SidebarUserFooter() { |
|||
<SidebarMenu> |
|||
<SidebarMenuItem> |
|||
<SidebarMenuButton |
|||
tooltip="خروج" |
|||
tooltip={t('navigation.logout')} |
|||
onClick={logout} |
|||
className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400" |
|||
> |
|||
<Ic name="logout" className="size-4 shrink-0" /> |
|||
<span className="truncate">خروج از سیستم</span> |
|||
<span className="truncate">{t('navigation.logout')}</span> |
|||
</SidebarMenuButton> |
|||
</SidebarMenuItem> |
|||
</SidebarMenu> |
|||
@ -176,8 +325,11 @@ function SidebarUserFooter() { |
|||
} |
|||
|
|||
export default function AppSidebar() { |
|||
const { t, dir } = useTranslation() |
|||
const navConfig = React.useMemo(() => getNavigationConfig(t), [t]) |
|||
|
|||
return ( |
|||
<Sidebar side="right" collapsible="offcanvas" className="h-svh"> |
|||
<Sidebar side={dir === 'ltr' ? 'left' : 'right'} collapsible="offcanvas" className="h-svh"> |
|||
<SidebarHeader className="py-3"> |
|||
<div className="flex items-center gap-3 px-2"> |
|||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-white shadow-md"> |
|||
@ -185,7 +337,7 @@ export default function AppSidebar() { |
|||
</div> |
|||
<div className="min-w-0"> |
|||
<div className="truncate text-base font-extrabold leading-tight text-foreground"> |
|||
{siteConfig.name} |
|||
{t('navigation.brand')} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -193,12 +345,12 @@ export default function AppSidebar() { |
|||
<SidebarSeparator /> |
|||
|
|||
<SidebarContent className="no-scrollbar px-1 flex-1"> |
|||
{navigationConfig.map((group, idx) => ( |
|||
{navConfig.map((group, idx) => ( |
|||
<SidebarGroup key={group.title || idx}> |
|||
{group.title ? <SidebarGroupLabel>{group.title}</SidebarGroupLabel> : null} |
|||
<SidebarMenu> |
|||
{group.items.map((item) => ( |
|||
<NavItem key={item.title} item={item} /> |
|||
<NavItem key={item.titleKey || item.title} item={item} /> |
|||
))} |
|||
</SidebarMenu> |
|||
</SidebarGroup> |
|||
|
|||
@ -0,0 +1,67 @@ |
|||
import React from 'react' |
|||
import { |
|||
DropdownMenu, |
|||
DropdownMenuTrigger, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
DropdownMenuLabel, |
|||
DropdownMenuSeparator, |
|||
} from '@/components/ui/dropdown-menu' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Ic } from '@/icons' |
|||
import { useTranslation, SupportedLanguage } from '@/i18n' |
|||
|
|||
export default function LanguageSwitcher() { |
|||
const { language, setLanguage, languages, t, currentLanguageInfo } = useTranslation() |
|||
|
|||
return ( |
|||
<DropdownMenu> |
|||
<DropdownMenuTrigger asChild> |
|||
<Button |
|||
variant="outline" |
|||
size="sm" |
|||
className="h-9 px-2.5 sm:px-3 rounded-xl border-border/70 bg-card/40 hover:bg-card text-xs font-semibold gap-2 shadow-xs transition-all" |
|||
title={t('topbar.language')} |
|||
> |
|||
<span className="text-sm leading-none">{currentLanguageInfo.flag}</span> |
|||
<span className="hidden sm:inline-block text-[12px] text-foreground"> |
|||
{currentLanguageInfo.nativeName} |
|||
</span> |
|||
<Ic name="chevDown" className="size-3 text-muted-foreground transition-transform opacity-70" /> |
|||
</Button> |
|||
</DropdownMenuTrigger> |
|||
|
|||
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5 shadow-xl border-border/70"> |
|||
<DropdownMenuLabel className="text-[11px] font-bold text-muted-foreground px-2 py-1"> |
|||
{t('topbar.selectLanguage')} |
|||
</DropdownMenuLabel> |
|||
<DropdownMenuSeparator /> |
|||
|
|||
{languages.map((item) => { |
|||
const isSelected = item.code === language |
|||
return ( |
|||
<DropdownMenuItem |
|||
key={item.code} |
|||
onSelect={() => setLanguage(item.code as SupportedLanguage)} |
|||
onClick={() => setLanguage(item.code as SupportedLanguage)} |
|||
className={`flex items-center justify-between px-2.5 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors ${ |
|||
isSelected |
|||
? 'bg-primary/15 text-primary font-bold shadow-2xs' |
|||
: 'text-foreground hover:bg-muted/50' |
|||
}`}
|
|||
> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="text-base leading-none">{item.flag}</span> |
|||
<span>{item.nativeName}</span> |
|||
<span className="text-[10px] text-muted-foreground uppercase font-mono"> |
|||
({item.code}) |
|||
</span> |
|||
</div> |
|||
{isSelected && <Ic name="check" className="size-3.5 text-primary" />} |
|||
</DropdownMenuItem> |
|||
) |
|||
})} |
|||
</DropdownMenuContent> |
|||
</DropdownMenu> |
|||
) |
|||
} |
|||
1080
src/features/services/components/FaqServiceView.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,141 @@ |
|||
import { create } from 'zustand' |
|||
import { persist } from 'zustand/middleware' |
|||
import fa from '@/locales/fa.json' |
|||
import ar from '@/locales/ar.json' |
|||
import en from '@/locales/en.json' |
|||
|
|||
export type SupportedLanguage = 'fa' | 'ar' | 'en' |
|||
export type Direction = 'rtl' | 'ltr' |
|||
|
|||
export interface LanguageOption { |
|||
code: SupportedLanguage |
|||
label: string |
|||
nativeName: string |
|||
dir: Direction |
|||
flag: string |
|||
} |
|||
|
|||
export const SUPPORTED_LANGUAGES: LanguageOption[] = [ |
|||
{ code: 'fa', label: 'فارسی (FA)', nativeName: 'فارسی', dir: 'rtl', flag: '🇮🇷' }, |
|||
{ code: 'ar', label: 'العربية (AR)', nativeName: 'العربية', dir: 'rtl', flag: '🇸🇦' }, |
|||
{ code: 'en', label: 'English (EN)', nativeName: 'English', dir: 'ltr', flag: '🇬🇧' }, |
|||
] |
|||
|
|||
const dictionaries: Record<SupportedLanguage, Record<string, any>> = { |
|||
fa, |
|||
ar, |
|||
en, |
|||
} |
|||
|
|||
/** |
|||
* دریافت مقدار ترجمه بر اساس کلید نقطهای (dot-notation) |
|||
* پشتیبانی از fallback به فارسی و پارامترهای درونمتنی {key} |
|||
*/ |
|||
export function translate( |
|||
lang: SupportedLanguage, |
|||
key: string, |
|||
params?: Record<string, string | number> |
|||
): string { |
|||
const getNestedValue = (obj: any, path: string): string | undefined => { |
|||
const keys = path.split('.') |
|||
let current = obj |
|||
for (const k of keys) { |
|||
if (current && typeof current === 'object' && k in current) { |
|||
current = current[k] |
|||
} else { |
|||
return undefined |
|||
} |
|||
} |
|||
return typeof current === 'string' ? current : undefined |
|||
} |
|||
|
|||
// تلاش برای خواندن از زبان فعلی
|
|||
let text = getNestedValue(dictionaries[lang], key) |
|||
|
|||
// در صورت نبودن، تلاش برای خواندن از زبان فارسی (Fallback)
|
|||
if (!text && lang !== 'fa') { |
|||
text = getNestedValue(dictionaries.fa, key) |
|||
} |
|||
|
|||
// اگر کلاً پیدا نشد، خود کلید بازگردانده شود
|
|||
if (!text) { |
|||
const lastPart = key.split('.').pop() || key |
|||
return lastPart |
|||
} |
|||
|
|||
// جایگذاری پارامترها مانند {current} یا {total}
|
|||
if (params) { |
|||
Object.entries(params).forEach(([paramKey, paramVal]) => { |
|||
text = text!.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramVal)) |
|||
}) |
|||
} |
|||
|
|||
return text |
|||
} |
|||
|
|||
export function applyLanguageToDOM(lang: SupportedLanguage) { |
|||
if (typeof document !== 'undefined') { |
|||
const isLtr = lang === 'en' |
|||
document.documentElement.lang = lang |
|||
document.documentElement.dir = isLtr ? 'ltr' : 'rtl' |
|||
if (isLtr) { |
|||
document.documentElement.classList.add('lang-en') |
|||
document.documentElement.classList.remove('lang-rtl') |
|||
} else { |
|||
document.documentElement.classList.add('lang-rtl') |
|||
document.documentElement.classList.remove('lang-en') |
|||
} |
|||
} |
|||
} |
|||
|
|||
interface LanguageState { |
|||
language: SupportedLanguage |
|||
setLanguage: (lang: SupportedLanguage) => void |
|||
} |
|||
|
|||
export const useLanguageStore = create<LanguageState>()( |
|||
persist( |
|||
(set) => ({ |
|||
language: 'fa', |
|||
setLanguage: (lang: SupportedLanguage) => { |
|||
set({ language: lang }) |
|||
applyLanguageToDOM(lang) |
|||
}, |
|||
}), |
|||
{ |
|||
name: 'aqila_panel_language', |
|||
onRehydrateStorage: () => (state) => { |
|||
const lang = state?.language || 'fa' |
|||
applyLanguageToDOM(lang) |
|||
}, |
|||
} |
|||
) |
|||
) |
|||
|
|||
/** |
|||
* هوک سراسری ترجمه جهت استفاده آسان در تمامی کامپوننتهای پنل |
|||
*/ |
|||
export function useTranslation() { |
|||
const language = useLanguageStore((s) => s.language) |
|||
const setLanguage = useLanguageStore((s) => s.setLanguage) |
|||
|
|||
const dir: Direction = language === 'en' ? 'ltr' : 'rtl' |
|||
const isRTL = dir === 'rtl' |
|||
|
|||
const t = (key: string, params?: Record<string, string | number>): string => { |
|||
return translate(language, key, params) |
|||
} |
|||
|
|||
const currentLanguageInfo = |
|||
SUPPORTED_LANGUAGES.find((l) => l.code === language) || SUPPORTED_LANGUAGES[0] |
|||
|
|||
return { |
|||
t, |
|||
language, |
|||
setLanguage, |
|||
dir, |
|||
isRTL, |
|||
languages: SUPPORTED_LANGUAGES, |
|||
currentLanguageInfo, |
|||
} |
|||
} |
|||
@ -1,4 +1,5 @@ |
|||
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/[email protected]/Vazirmatn-font-face.css'); |
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap'); |
|||
@import 'tailwindcss'; |
|||
|
|||
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ |
|||
@ -84,7 +85,8 @@ html.light { |
|||
} |
|||
|
|||
@theme inline { |
|||
--font-sans: 'Vazirmatn', system-ui, sans-serif; |
|||
--font-sans: 'Vazirmatn', 'Inter', system-ui, sans-serif; |
|||
--font-mono: 'JetBrains Mono', 'Inter', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; |
|||
--color-background: var(--background); |
|||
--color-foreground: var(--foreground); |
|||
--color-card: var(--card); |
|||
|
|||
@ -0,0 +1,133 @@ |
|||
{ |
|||
"navigation": { |
|||
"brand": "لوحة إدارة عقيلة", |
|||
"brandShort": "عقيلة", |
|||
"dashboard": "لوحة القيادة", |
|||
"dashboardTitle": "لوحة إدارة النظام", |
|||
"tours": "الجولات", |
|||
"allTours": "جميع الجولات", |
|||
"ongoingTours": "الجولات الجارية", |
|||
"tourOrders": "الطلبات والحجوزات", |
|||
"services": "مركز الخدمات (الكل)", |
|||
"simcard": "شرائح الاتصال (Simcard)", |
|||
"zayerGuide": "دليل الزائر (Zayer Guide)", |
|||
"exchange": "الصرافة الإلكترونية (Exchange)", |
|||
"duas": "الأدعية والزيارات (Duas)", |
|||
"duasList": "الأدعية والزيارات", |
|||
"categories": "التصنيفات", |
|||
"shrines": "المزارات والعتبات (Shrines)", |
|||
"store": "متجر معصومة (Store)", |
|||
"costEstimate": "تقدير التكاليف (Cost Estimate)", |
|||
"support": "الدعم والاستشارات (Support)", |
|||
"supporters": "فريق الدعم (Supporters)", |
|||
"faq": "الأسئلة الشائعة (FAQ)", |
|||
"faqCategories": "التصنيفات الموضوعية", |
|||
"faqQuestions": "الأسئلة والأجوبة", |
|||
"consultant": "الدعم والاستشارات (Support)", |
|||
"billing": "الفواتير والمالية", |
|||
"settings": "الإعدادات", |
|||
"logout": "تسجيل الخروج", |
|||
"soon": "قريباً", |
|||
"adminRole": "المدير العام للنظام" |
|||
}, |
|||
"topbar": { |
|||
"serverOnline": "الخادم متصل", |
|||
"serverOffline": "انقطع الاتصال بالخادم", |
|||
"theme": "المظهر", |
|||
"language": "تغيير اللغة", |
|||
"selectLanguage": "اختر لغة النظام", |
|||
"search": "بحث..." |
|||
}, |
|||
"common": { |
|||
"save": "حفظ التغييرات", |
|||
"cancel": "إلغاء", |
|||
"confirm": "تأكيد", |
|||
"delete": "حذف", |
|||
"edit": "تعديل", |
|||
"close": "إغلاق", |
|||
"back": "رجوع", |
|||
"refresh": "تحديث", |
|||
"loading": "جاري التحميل...", |
|||
"status": "الحالة", |
|||
"actions": "الإجراءات", |
|||
"all": "الكل", |
|||
"active": "نشط", |
|||
"inactive": "غير نشط", |
|||
"noData": "لا توجد بيانات للعرض.", |
|||
"pageOf": "صفحة {current} من {total}", |
|||
"success": "تمت العملية بنجاح.", |
|||
"error": "حدث خطأ في الاتصال بالخادم.", |
|||
"retry": "إعادة المحاولة", |
|||
"previous": "السابق", |
|||
"next": "التالي", |
|||
"showing": "عرض", |
|||
"to": "إلى", |
|||
"of": "من" |
|||
}, |
|||
"billing": { |
|||
"title": "إدارة الفواتير والمدفوعات", |
|||
"description": "مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات عقيلة", |
|||
"totalInvoices": "إجمالي الفواتير", |
|||
"pendingReview": "بانتظار مراجعة الإيصال", |
|||
"awaitingPayment": "بانتظار دفع العميل", |
|||
"approvedPayments": "المدفوعات المعتمدة", |
|||
"rejectedOrExpired": "مرفوض / منتهي", |
|||
"searchPlaceholder": "البحث في الفواتير (اسم المستخدم، البريد، رقم البطاقة، رقم الفاتورة)...", |
|||
"statusFilter": "حالة الفاتورة", |
|||
"serviceFilter": "نوع الخدمة", |
|||
"clearFilters": "مسح الفلاتر", |
|||
"invoiceList": "قائمة الفواتير", |
|||
"customer": "العميل", |
|||
"amount": "المبلغ", |
|||
"service": "الخدمة", |
|||
"date": "تاريخ الإصدار", |
|||
"receipt": "إيصال الدفع", |
|||
"hasReceipt": "عرض الإيصال", |
|||
"noReceipt": "لا يوجد", |
|||
"approve": "اعتماد الدفع", |
|||
"reject": "رفض الفاتورة", |
|||
"rejectTitle": "رفض الفاتورة", |
|||
"rejectReason": "سبب رفض الفاتورة / الإيصال البنكي:", |
|||
"rejectReasonPlaceholder": "مثال: الإيصال غير واضح، المبلغ غير مطابق، رقم المعاملة غير صحيح...", |
|||
"rejectNotice": "سيتم عرض هذا التوضيح في تطبيق المسافر ضمن تفاصيل الفاتورة.", |
|||
"submitReject": "تسجيل ورفض الفاتورة", |
|||
"submitting": "جاري التسجيل...", |
|||
"statusPending": "قيد مراجعة الإيصال", |
|||
"statusAwaiting": "بانتظار الدفع", |
|||
"statusApproved": "تم الدفع والاعتماد", |
|||
"statusRejected": "مرفوض", |
|||
"statusExpired": "منتهي الصلاحية", |
|||
"invoice": "فاتورة", |
|||
"invoices": "فواتير", |
|||
"id": "المعرف", |
|||
"viewDetails": "التفاصيل", |
|||
"viewReceipt": "عرض إيصال الدفع", |
|||
"noReceiptUploaded": "لم يتم رفع إيصال", |
|||
"serviceTour": "جولات الزيارة", |
|||
"serviceSimcard": "شرائح الاتصال", |
|||
"serviceShop": "متجر الهدايا", |
|||
"serviceExchange": "الصرافة (تصريف)", |
|||
"serviceCustomTrip": "رحلة خاصة", |
|||
"allStatuses": "جميع الحالات", |
|||
"allServices": "جميع الخدمات", |
|||
"noFactorsFound": "لم يتم العثور على فواتير.", |
|||
"noFactorsFilter": "لم يتم العثور على نتائج مطابقة للفلاتر الحالية. يمكنك مسح الفلاتر.", |
|||
"noFactorsEmpty": "لم تصدر أي فواتير في النظام بعد.", |
|||
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة لهذا الفاتورة؟", |
|||
"receiptClearedSuccess": "تم مسح إيصال الدفع بنجاح.", |
|||
"tabAll": "جميع المعاملات", |
|||
"tabTour": "طلبات وحجوزات الجولات", |
|||
"tabSimcard": "شرائح الاتصال", |
|||
"tabExchange": "الصرافة والعملات", |
|||
"tabShop": "متجر الهدايا", |
|||
"tabCustomTrip": "الرحلات الخاصة", |
|||
"coreTourBadge": "المحور الأساسي", |
|||
"liveStatus": "الحالة اللحظية", |
|||
"quickFilter": "فلترة الحالة" |
|||
}, |
|||
"languages": { |
|||
"fa": "فارسی", |
|||
"ar": "العربية", |
|||
"en": "English" |
|||
} |
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
{ |
|||
"navigation": { |
|||
"brand": "Aqila Admin Panel", |
|||
"brandShort": "Aqila", |
|||
"dashboard": "Dashboard", |
|||
"dashboardTitle": "Admin Dashboard", |
|||
"tours": "Tours", |
|||
"allTours": "All Tours", |
|||
"ongoingTours": "Ongoing Tours", |
|||
"tourOrders": "Orders & Bookings", |
|||
"services": "Services Center", |
|||
"simcard": "SIM Card", |
|||
"zayerGuide": "Zayer Guide", |
|||
"exchange": "Online Exchange", |
|||
"duas": "Duas & Ziyarat", |
|||
"duasList": "Duas & Ziyarat", |
|||
"categories": "Categories", |
|||
"shrines": "Holy Shrines", |
|||
"store": "Massoumah Store", |
|||
"costEstimate": "Cost Estimate", |
|||
"support": "Support Center", |
|||
"supporters": "Support Staff", |
|||
"faq": "FAQ", |
|||
"faqCategories": "Subject Categories", |
|||
"faqQuestions": "Questions & Answers", |
|||
"consultant": "Support", |
|||
"billing": "Billing & Finance", |
|||
"settings": "Settings", |
|||
"logout": "Logout", |
|||
"soon": "Soon", |
|||
"adminRole": "System Administrator" |
|||
}, |
|||
"topbar": { |
|||
"serverOnline": "Server is Online", |
|||
"serverOffline": "Server Disconnected", |
|||
"theme": "Theme", |
|||
"language": "Language", |
|||
"selectLanguage": "Select System Language", |
|||
"search": "Search..." |
|||
}, |
|||
"common": { |
|||
"save": "Save Changes", |
|||
"cancel": "Cancel", |
|||
"confirm": "Confirm", |
|||
"delete": "Delete", |
|||
"edit": "Edit", |
|||
"close": "Close", |
|||
"back": "Back", |
|||
"refresh": "Refresh", |
|||
"loading": "Loading...", |
|||
"status": "Status", |
|||
"actions": "Actions", |
|||
"all": "All", |
|||
"active": "Active", |
|||
"inactive": "Inactive", |
|||
"noData": "No records found.", |
|||
"pageOf": "Page {current} of {total}", |
|||
"success": "Operation completed successfully.", |
|||
"error": "Server connection error.", |
|||
"retry": "Retry", |
|||
"previous": "Previous", |
|||
"next": "Next", |
|||
"showing": "Showing", |
|||
"to": "to", |
|||
"of": "of" |
|||
}, |
|||
"billing": { |
|||
"title": "Invoices & Billing Management", |
|||
"description": "Review bank receipts, approve or reject payments, and track invoices across Aqila services", |
|||
"totalInvoices": "Total Invoices", |
|||
"pendingReview": "Pending Receipt Review", |
|||
"awaitingPayment": "Awaiting Customer Payment", |
|||
"approvedPayments": "Approved Payments", |
|||
"rejectedOrExpired": "Rejected / Expired", |
|||
"searchPlaceholder": "Search invoices (Customer name, email, card number, invoice ID)...", |
|||
"statusFilter": "Invoice Status", |
|||
"serviceFilter": "Service Type", |
|||
"clearFilters": "Clear Filters", |
|||
"invoiceList": "Invoice List", |
|||
"customer": "Customer", |
|||
"amount": "Amount", |
|||
"service": "Service", |
|||
"date": "Issue Date", |
|||
"receipt": "Payment Receipt", |
|||
"hasReceipt": "View Receipt", |
|||
"noReceipt": "None", |
|||
"approve": "Approve Payment", |
|||
"reject": "Reject Invoice", |
|||
"rejectTitle": "Reject Invoice", |
|||
"rejectReason": "Rejection Reason / Bank Receipt:", |
|||
"rejectReasonPlaceholder": "e.g., Unreadable receipt, amount mismatch, invalid transaction reference...", |
|||
"rejectNotice": "This explanation will be displayed to the traveler in their invoice details.", |
|||
"submitReject": "Submit & Reject Invoice", |
|||
"submitting": "Submitting...", |
|||
"statusPending": "Pending Receipt Review", |
|||
"statusAwaiting": "Awaiting Payment", |
|||
"statusApproved": "Payment Approved", |
|||
"statusRejected": "Rejected", |
|||
"statusExpired": "Expired", |
|||
"invoice": "Invoice", |
|||
"invoices": "Invoices", |
|||
"id": "ID", |
|||
"viewDetails": "Details", |
|||
"viewReceipt": "View Receipt", |
|||
"noReceiptUploaded": "No receipt uploaded", |
|||
"serviceTour": "Pilgrimage Tours", |
|||
"serviceSimcard": "SIM Card", |
|||
"serviceShop": "Massoumah Store", |
|||
"serviceExchange": "Exchange (Tasreef)", |
|||
"serviceCustomTrip": "Custom Trip", |
|||
"allStatuses": "All Statuses", |
|||
"allServices": "All Services", |
|||
"noFactorsFound": "No invoices found.", |
|||
"noFactorsFilter": "No records match current filters. You can clear filters.", |
|||
"noFactorsEmpty": "No invoices have been issued in the system yet.", |
|||
"confirmClearReceipt": "Are you sure you want to remove the uploaded receipt image?", |
|||
"receiptClearedSuccess": "Payment receipt cleared successfully.", |
|||
"tabAll": "All Transactions", |
|||
"tabTour": "Tour Orders & Bookings", |
|||
"tabSimcard": "SIM Cards", |
|||
"tabExchange": "Currency & Exchange", |
|||
"tabShop": "Souvenir Store", |
|||
"tabCustomTrip": "Custom Trips", |
|||
"coreTourBadge": "Primary Core", |
|||
"liveStatus": "Live Status", |
|||
"quickFilter": "Status Filter" |
|||
}, |
|||
"languages": { |
|||
"fa": "فارسی", |
|||
"ar": "العربية", |
|||
"en": "English" |
|||
} |
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
{ |
|||
"navigation": { |
|||
"brand": "پنل مدیریت عقیله", |
|||
"brandShort": "عقیله", |
|||
"dashboard": "داشبورد", |
|||
"dashboardTitle": "داشبورد مدیریت", |
|||
"tours": "تورها", |
|||
"allTours": "فهرست همه تورها", |
|||
"ongoingTours": "تورهای در حال برگزاری", |
|||
"tourOrders": "سفارشات و رزروها", |
|||
"services": "مرکز خدمات (همه)", |
|||
"simcard": "سیمکارت (Simcard)", |
|||
"zayerGuide": "راهنمای زائر (Zayer Guide)", |
|||
"exchange": "صرافی آنلاین (Exchange)", |
|||
"duas": "ادعیه و زیارات (Duas)", |
|||
"duasList": "ادعیه و زیارات", |
|||
"categories": "دستهبندیها", |
|||
"shrines": "اماکن زیارتی (Shrines)", |
|||
"store": "فروشگاه معصومه (Store)", |
|||
"costEstimate": "تخمین هزینه (Cost Estimate)", |
|||
"support": "پشتیبانی (Support)", |
|||
"supporters": "پشتیبانها (Supporters)", |
|||
"faq": "سوالات متداول (FAQ)", |
|||
"faqCategories": "دستهبندیهای موضوعی", |
|||
"faqQuestions": "پرسش و پاسخها", |
|||
"consultant": "پشتیبانی (Support)", |
|||
"billing": "صورتحسابها و مالی", |
|||
"settings": "تنظیمات", |
|||
"logout": "خروج از سیستم", |
|||
"soon": "بهزودی", |
|||
"adminRole": "مدیر کل سیستم" |
|||
}, |
|||
"topbar": { |
|||
"serverOnline": "سرور متصل است", |
|||
"serverOffline": "قطع ارتباط با سرور", |
|||
"theme": "تغییر تم", |
|||
"language": "تغییر زبان", |
|||
"selectLanguage": "انتخاب زبان سامانه", |
|||
"search": "جستجو..." |
|||
}, |
|||
"common": { |
|||
"save": "ذخیره تغییرات", |
|||
"cancel": "انصراف", |
|||
"confirm": "تایید", |
|||
"delete": "حذف", |
|||
"edit": "ویرایش", |
|||
"close": "بستن", |
|||
"back": "بازگشت", |
|||
"refresh": "بهروزرسانی", |
|||
"loading": "در حال بارگذاری...", |
|||
"status": "وضعیت", |
|||
"actions": "عملیات", |
|||
"all": "همه", |
|||
"active": "فعال", |
|||
"inactive": "غیرفعال", |
|||
"noData": "موردی برای نمایش یافت نشد.", |
|||
"pageOf": "صفحه {current} از {total}", |
|||
"success": "عملیات با موفقیت انجام شد.", |
|||
"error": "خطا در برقراری ارتباط با سرور.", |
|||
"retry": "تلاش مجدد", |
|||
"previous": "قبلی", |
|||
"next": "بعدی", |
|||
"showing": "نمایش", |
|||
"to": "تا", |
|||
"of": "از" |
|||
}, |
|||
"billing": { |
|||
"title": "مدیریت صورتحسابها و فاکتورها", |
|||
"description": "بررسی فیشهای بانکی، تایید یا رد پرداختها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله", |
|||
"totalInvoices": "کل صورتحسابها", |
|||
"pendingReview": "در انتظار بررسی فیش", |
|||
"awaitingPayment": "در انتظار پرداخت مشتری", |
|||
"approvedPayments": "پرداختهای تایید شده", |
|||
"rejectedOrExpired": "رد شده / منقضی", |
|||
"searchPlaceholder": "جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)...", |
|||
"statusFilter": "وضعیت فاکتور", |
|||
"serviceFilter": "نوع سرویس", |
|||
"clearFilters": "پاکسازی فیلترها", |
|||
"invoiceList": "فهرست فاکتورها", |
|||
"customer": "مشتری", |
|||
"amount": "مبلغ", |
|||
"service": "سرویس", |
|||
"date": "تاریخ صدور", |
|||
"receipt": "فیش پرداختی", |
|||
"hasReceipt": "مشاهده فیش", |
|||
"noReceipt": "ندارد", |
|||
"approve": "تایید پرداخت", |
|||
"reject": "رد صورتحساب", |
|||
"rejectTitle": "رد صورتحساب", |
|||
"rejectReason": "دلیل رد صورتحساب / فیش پرداختی:", |
|||
"rejectReasonPlaceholder": "مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است...", |
|||
"rejectNotice": "این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورتحساب نمایش داده میشود.", |
|||
"submitReject": "ثبت و رد فاکتور", |
|||
"submitting": "در حال ثبت...", |
|||
"statusPending": "در انتظار بررسی فیش", |
|||
"statusAwaiting": "در انتظار پرداخت", |
|||
"statusApproved": "پرداخت تایید شده", |
|||
"statusRejected": "رد شده", |
|||
"statusExpired": "منقضی شده", |
|||
"invoice": "فاکتور", |
|||
"invoices": "فاکتور", |
|||
"id": "شناسه", |
|||
"viewDetails": "جزئیات", |
|||
"viewReceipt": "مشاهده فیش واریزی", |
|||
"noReceiptUploaded": "فیش واریزی بارگذاری نشده", |
|||
"serviceTour": "تورهای زیارتی", |
|||
"serviceSimcard": "سیمکارت", |
|||
"serviceShop": "فروشگاه سوغات", |
|||
"serviceExchange": "صرافی (تسریف)", |
|||
"serviceCustomTrip": "سفر اختصاصی", |
|||
"allStatuses": "همه وضعیتها", |
|||
"allServices": "همه سرویسها", |
|||
"noFactorsFound": "صورتحسابی یافت نشد.", |
|||
"noFactorsFilter": "با فیلترهای جستجوی فعلی موردی پیدا نشد. میتوانید فیلترها را پاکسازی نمایید.", |
|||
"noFactorsEmpty": "هنوز هیچ صورتحسابی در سیستم صادر نشده است.", |
|||
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاریشده برای این صورتحساب اطمینان دارید؟", |
|||
"receiptClearedSuccess": "فیش واریزی با موفقیت پاک شد.", |
|||
"tabAll": "همه تراکنشها", |
|||
"tabTour": "سفارشات و رزروهای تور", |
|||
"tabSimcard": "خرید سیمکارت", |
|||
"tabExchange": "صرافی و ارز", |
|||
"tabShop": "فروشگاه سوغات", |
|||
"tabCustomTrip": "سفر اختصاصی", |
|||
"coreTourBadge": "محور اصلی", |
|||
"liveStatus": "وضعیت لحظهای", |
|||
"quickFilter": "فیلتر وضعیت" |
|||
}, |
|||
"languages": { |
|||
"fa": "فارسی", |
|||
"ar": "العربية", |
|||
"en": "English" |
|||
} |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
import { create } from 'zustand' |
|||
import { fetchFactors } from '@/features/billing/services/billing-api' |
|||
|
|||
interface BillingBadgeState { |
|||
pendingReviewCount: number |
|||
awaitingPaymentCount: number |
|||
isLoading: boolean |
|||
lastFetched: number | null |
|||
fetchCounts: () => Promise<void> |
|||
setCounts: (pending: number, awaiting: number) => void |
|||
} |
|||
|
|||
export const useBillingBadgeStore = create<BillingBadgeState>((set) => ({ |
|||
pendingReviewCount: 0, |
|||
awaitingPaymentCount: 0, |
|||
isLoading: false, |
|||
lastFetched: null, |
|||
fetchCounts: async () => { |
|||
try { |
|||
set({ isLoading: true }) |
|||
// دریافت تعداد فاکتورهای در انتظار بررسی فیش (Pending Review)
|
|||
const pendingRes = await fetchFactors({ status: 'pending', limit: 1 }) |
|||
// دریافت تعداد فاکتورهای در انتظار پرداخت کاربر (Awaiting Payment)
|
|||
const awaitingRes = await fetchFactors({ status: 'awaiting_payment', limit: 1 }) |
|||
|
|||
set({ |
|||
pendingReviewCount: pendingRes?.count || 0, |
|||
awaitingPaymentCount: awaitingRes?.count || 0, |
|||
isLoading: false, |
|||
lastFetched: Date.now(), |
|||
}) |
|||
} catch { |
|||
set({ isLoading: false }) |
|||
} |
|||
}, |
|||
setCounts: (pending: number, awaiting: number) => { |
|||
set({ pendingReviewCount: pending, awaitingPaymentCount: awaiting }) |
|||
}, |
|||
})) |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue