Browse Source
feat: add core admin panel application with layout, UI components, and feature modules
master
feat: add core admin panel application with layout, UI components, and feature modules
master
53 changed files with 5195 additions and 2117 deletions
-
4src/app/providers.tsx
-
2src/components/layout/AppSidebar.tsx
-
12src/components/layout/ServerHealthTrigger.tsx
-
9src/components/layout/ServerStatusDialog.tsx
-
2src/components/shared/AppImage.tsx
-
5src/components/shared/DeleteConfirmDialog.tsx
-
190src/components/shared/Shimmer.tsx
-
390src/components/shared/shimmer_frontend.tsx
-
42src/components/ui/avatar.tsx
-
100src/components/ui/button.tsx
-
10src/components/ui/multi-select.tsx
-
5src/components/ui/shimmer.tsx
-
10src/components/ui/single-select.tsx
-
6src/components/ui/skeleton.tsx
-
25src/components/ui/switch.tsx
-
95src/components/ui/toast-container.tsx
-
21src/config/navigation.ts
-
8src/features/auth/LoginPage.tsx
-
146src/features/billing/BillingPage.tsx
-
823src/features/billing/components/BillingDetailModal.tsx
-
121src/features/billing/components/BillingServiceBadge.tsx
-
55src/features/billing/components/BillingStatusBadge.tsx
-
288src/features/billing/components/ReceiptLightboxDialog.tsx
-
13src/features/billing/components/RejectFactorDialog.tsx
-
16src/features/billing/services/billing-api.ts
-
2src/features/services/ServicesPage.tsx
-
18src/features/services/components/CategoriesServiceView.tsx
-
12src/features/services/components/DuasServiceView.tsx
-
558src/features/services/components/FaqServiceView.tsx
-
3src/features/services/components/LocationDetailSheet.tsx
-
7src/features/services/components/ShrinesServiceView.tsx
-
41src/features/services/components/ZayerGuideServiceView.tsx
-
576src/features/tours/OngoingToursPage.tsx
-
112src/features/tours/TourOrdersPage.tsx
-
179src/features/tours/ToursPage.tsx
-
244src/features/tours/components/CreateTourDialog.tsx
-
2484src/features/tours/components/TourDetailDialog.tsx
-
27src/features/tours/components/TourDetailSheet.tsx
-
255src/features/tours/components/TourDetailShimmer.tsx
-
44src/features/tours/components/TourStatusBadge.tsx
-
24src/features/tours/components/TourTranslationsDialog.tsx
-
127src/features/tours/services/tour-api.ts
-
2src/features/tours/types.ts
-
10src/i18n/index.ts
-
1src/icons.tsx
-
48src/index.css
-
47src/lib/utils.ts
-
2src/locales/ar.json
-
2src/locales/en.json
-
4src/locales/fa.json
-
18src/stores/billing-badge.ts
-
63src/stores/toast.ts
-
4vite.config.ts
@ -1,190 +0,0 @@ |
|||||
import React from 'react' |
|
||||
import { cn } from '@/lib/utils' |
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' |
|
||||
import { Ic, IconName } from '@/icons' |
|
||||
|
|
||||
export interface ShimmerProps { |
|
||||
/** تعداد ردیفها یا آیتمهای اسکلتون (پیشفرض: ۵) */ |
|
||||
rows?: number |
|
||||
count?: number |
|
||||
/** ارتفاع هر ردیف (پیشفرض: h-12) */ |
|
||||
rowHeight?: string |
|
||||
/** کلاسهای سفارشی برای بدنه و والد */ |
|
||||
className?: string |
|
||||
/** کلاسهای سفارشی برای هر آیتم */ |
|
||||
itemClassName?: string |
|
||||
/** آیا درون یک Card با کادر و سایه استاندارد قرار گیرد؟ */ |
|
||||
withCard?: boolean |
|
||||
/** عنوان هدر کارت در صورت استفاده از withCard */ |
|
||||
cardHeaderTitle?: string |
|
||||
/** آیکون هدر کارت */ |
|
||||
cardHeaderIcon?: IconName |
|
||||
/** متن سمت چپ هدر کارت */ |
|
||||
cardHeaderSub?: React.ReactNode |
|
||||
/** نوع نمایش: ردیفی/جدولی (پیشفرض) یا کارتهای گرید یا KPI */ |
|
||||
variant?: 'list' | 'table' | 'grid' | 'kpi' |
|
||||
/** تعداد ستونها در حالت grid (پیشفرض: 3) */ |
|
||||
gridCols?: number |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله |
|
||||
* دارای پالت خنثی با کنتراست استاندارد و انیمیشن روان پالس (در هر دو تم دارک و لایت) |
|
||||
*/ |
|
||||
export function Shimmer({ |
|
||||
rows, |
|
||||
count = 5, |
|
||||
rowHeight = 'h-12', |
|
||||
className, |
|
||||
itemClassName, |
|
||||
withCard = false, |
|
||||
cardHeaderTitle, |
|
||||
cardHeaderIcon = 'list', |
|
||||
cardHeaderSub, |
|
||||
variant = 'table', |
|
||||
gridCols = 3, |
|
||||
}: ShimmerProps) { |
|
||||
const itemCount = rows !== undefined ? rows : count |
|
||||
|
|
||||
let content: React.ReactNode |
|
||||
|
|
||||
if (variant === 'grid') { |
|
||||
const colClasses: Record<number, string> = { |
|
||||
1: 'grid-cols-1', |
|
||||
2: 'grid-cols-1 sm:grid-cols-2', |
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', |
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', |
|
||||
} |
|
||||
|
|
||||
content = ( |
|
||||
<div className={cn('grid gap-4 p-4 sm:p-6', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}> |
|
||||
{Array.from({ length: itemCount }).map((_, i) => ( |
|
||||
<div |
|
||||
key={i} |
|
||||
className={cn( |
|
||||
'h-36 rounded-2xl bg-slate-200/90 dark:bg-white/10 animate-pulse border border-border-soft', |
|
||||
itemClassName |
|
||||
)} |
|
||||
/> |
|
||||
))} |
|
||||
</div> |
|
||||
) |
|
||||
} else if (variant === 'kpi') { |
|
||||
content = ( |
|
||||
<div className={cn('grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5', className)}> |
|
||||
{Array.from({ length: itemCount }).map((_, i) => ( |
|
||||
<div |
|
||||
key={i} |
|
||||
className={cn( |
|
||||
'h-20 rounded-2xl bg-slate-200/90 dark:bg-white/10 animate-pulse border border-border-soft p-4 flex items-center justify-between', |
|
||||
itemClassName |
|
||||
)} |
|
||||
> |
|
||||
<div className="space-y-2"> |
|
||||
<div className="h-3 w-16 rounded bg-slate-300 dark:bg-white/15" /> |
|
||||
<div className="h-5 w-10 rounded bg-slate-300 dark:bg-white/15" /> |
|
||||
</div> |
|
||||
<div className="size-9 rounded-xl bg-slate-300/80 dark:bg-white/15" /> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
) |
|
||||
} else if (variant === 'table') { |
|
||||
// رندرر شبیه جدول با ستونهای شکیل و تفکیکشده
|
|
||||
content = ( |
|
||||
<div className={cn('divide-y divide-border-soft', className)}> |
|
||||
{/* ردیف هدر جدول */} |
|
||||
<div className="p-4 bg-muted/40 flex items-center justify-between gap-4"> |
|
||||
<div className="h-4 w-12 rounded bg-slate-300 dark:bg-white/15 animate-pulse" /> |
|
||||
<div className="h-4 w-32 rounded bg-slate-300 dark:bg-white/15 animate-pulse" /> |
|
||||
<div className="h-4 w-20 rounded bg-slate-300 dark:bg-white/15 animate-pulse" /> |
|
||||
<div className="h-4 w-24 rounded bg-slate-300 dark:bg-white/15 animate-pulse" /> |
|
||||
<div className="h-4 w-20 rounded bg-slate-300 dark:bg-white/15 animate-pulse" /> |
|
||||
<div className="h-4 w-16 rounded bg-slate-300 dark:bg-white/15 animate-pulse" /> |
|
||||
</div> |
|
||||
|
|
||||
{/* سطرهای شیمر جدول */} |
|
||||
{Array.from({ length: itemCount }).map((_, i) => ( |
|
||||
<div |
|
||||
key={i} |
|
||||
className={cn( |
|
||||
'p-4 flex items-center justify-between gap-4 hover:bg-muted/10 transition-colors', |
|
||||
itemClassName |
|
||||
)} |
|
||||
> |
|
||||
{/* شناسه */} |
|
||||
<div className="h-4 w-10 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
|
|
||||
{/* کاربر (آواتار + نام) */} |
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[200px]"> |
|
||||
<div className="size-8 rounded-full bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
<div className="space-y-1.5 flex-1"> |
|
||||
<div className="h-3.5 w-24 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse" /> |
|
||||
<div className="h-2.5 w-16 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* بج سرویس */} |
|
||||
<div className="h-6 w-16 rounded-lg bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
|
|
||||
{/* مبلغ */} |
|
||||
<div className="h-4 w-20 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
|
|
||||
{/* بج وضعیت */} |
|
||||
<div className="h-6 w-24 rounded-full bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
|
|
||||
{/* تاریخ و اکشن */} |
|
||||
<div className="h-3.5 w-20 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
<div className="h-8 w-16 rounded-xl bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" /> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
) |
|
||||
} else { |
|
||||
// ردیفی ساده
|
|
||||
content = ( |
|
||||
<div className={cn('p-6 space-y-3', className)}> |
|
||||
{Array.from({ length: itemCount }).map((_, i) => ( |
|
||||
<div |
|
||||
key={i} |
|
||||
className={cn( |
|
||||
rowHeight, |
|
||||
'bg-slate-200/90 dark:bg-white/10 animate-pulse rounded-xl transition-all duration-300 border border-border-soft/40', |
|
||||
itemClassName |
|
||||
)} |
|
||||
/> |
|
||||
))} |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
if (withCard) { |
|
||||
return ( |
|
||||
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden"> |
|
||||
{(cardHeaderTitle || cardHeaderSub) && ( |
|
||||
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between"> |
|
||||
{cardHeaderTitle && ( |
|
||||
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground"> |
|
||||
<Ic name={cardHeaderIcon} className="size-4 text-primary" /> |
|
||||
<span>{cardHeaderTitle}</span> |
|
||||
</CardTitle> |
|
||||
)} |
|
||||
{cardHeaderSub && ( |
|
||||
<span className="text-xs text-muted-foreground font-mono"> |
|
||||
{cardHeaderSub} |
|
||||
</span> |
|
||||
)} |
|
||||
</CardHeader> |
|
||||
)} |
|
||||
<CardContent className="p-0"> |
|
||||
{content} |
|
||||
</CardContent> |
|
||||
</Card> |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
return <>{content}</> |
|
||||
} |
|
||||
|
|
||||
export const shimmer = Shimmer |
|
||||
export default Shimmer |
|
||||
@ -0,0 +1,390 @@ |
|||||
|
import React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' |
||||
|
import { Ic, IconName } from '@/icons' |
||||
|
|
||||
|
/** |
||||
|
* shimmer_frontend (Shimmer UI Component & Atoms for Aqila Web Panel) |
||||
|
* |
||||
|
* الگوبرداریشده از معماری استاندارد AppShimmer در فلاتر (Neutral Slate Palette): |
||||
|
* - حالت لایت (Light Mode): رنگ پایه #E2E8F0 و هایلایت #F8FAFC |
||||
|
* - حالت دارک (Dark Mode): رنگ پایه #22252C و هایلایت #333742 |
||||
|
* - دوره تناوب (Period): ۱۵۰۰ میلیثانیه برای حرکت نرم و یکدست موج نوری |
||||
|
* |
||||
|
* شامل اتمهای لگویی: |
||||
|
* - ShimmerBox: برای کارتها، تصاویر، فیلدها و دکمهها |
||||
|
* - ShimmerCircle: برای آواتارها، آیکونها و بجهای دایرهای |
||||
|
* - ShimmerLine: برای خطوط متنی و عناوین تایپوگرافی |
||||
|
*/ |
||||
|
|
||||
|
/* ───────────────────────────────────────────────────────── |
||||
|
۱. اتمهای پایه لودینگ شیمر (Shimmer Atoms) |
||||
|
───────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export interface ShimmerBoxProps extends React.HTMLAttributes<HTMLDivElement> { |
||||
|
width?: string | number |
||||
|
height?: string | number |
||||
|
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' |
||||
|
className?: string |
||||
|
style?: React.CSSProperties |
||||
|
children?: React.ReactNode |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* اتم مستطیلی اسکلتون (مشابه ShimmerBox در فلاتر) |
||||
|
*/ |
||||
|
export function ShimmerBox({ |
||||
|
width, |
||||
|
height, |
||||
|
rounded = 'xl', |
||||
|
className, |
||||
|
style, |
||||
|
children, |
||||
|
...props |
||||
|
}: ShimmerBoxProps) { |
||||
|
const roundedClass = { |
||||
|
none: 'rounded-none', |
||||
|
sm: 'rounded-sm', |
||||
|
md: 'rounded-md', |
||||
|
lg: 'rounded-lg', |
||||
|
xl: 'rounded-xl', |
||||
|
'2xl': 'rounded-2xl', |
||||
|
'3xl': 'rounded-3xl', |
||||
|
full: 'rounded-full', |
||||
|
}[rounded] |
||||
|
|
||||
|
const customStyle: React.CSSProperties = { |
||||
|
...(width !== undefined ? { width: typeof width === 'number' ? `${width}px` : width } : {}), |
||||
|
...(height !== undefined ? { height: typeof height === 'number' ? `${height}px` : height } : {}), |
||||
|
...style, |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
'shimmer-wave border border-border-soft shrink-0', |
||||
|
roundedClass, |
||||
|
className |
||||
|
)} |
||||
|
style={customStyle} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export interface ShimmerCircleProps extends React.HTMLAttributes<HTMLDivElement> { |
||||
|
size?: number | string |
||||
|
radius?: number |
||||
|
className?: string |
||||
|
style?: React.CSSProperties |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* اتم دایرهای اسکلتون (مشابه ShimmerCircle در فلاتر) |
||||
|
*/ |
||||
|
export function ShimmerCircle({ |
||||
|
size = 40, |
||||
|
radius, |
||||
|
className, |
||||
|
style, |
||||
|
...props |
||||
|
}: ShimmerCircleProps) { |
||||
|
const finalSize = radius !== undefined ? radius * 2 : size |
||||
|
const sizeValue = typeof finalSize === 'number' ? `${finalSize}px` : finalSize |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
'shimmer-wave rounded-full border border-border-soft shrink-0', |
||||
|
className |
||||
|
)} |
||||
|
style={{ |
||||
|
width: sizeValue, |
||||
|
height: sizeValue, |
||||
|
...style, |
||||
|
}} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export interface ShimmerLineProps extends React.HTMLAttributes<HTMLDivElement> { |
||||
|
width?: string | number |
||||
|
height?: string | number |
||||
|
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full' |
||||
|
className?: string |
||||
|
style?: React.CSSProperties |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* اتم خطی متنی اسکلتون (مشابه ShimmerLine در فلاتر) |
||||
|
*/ |
||||
|
export function ShimmerLine({ |
||||
|
width = '100%', |
||||
|
height = '14px', |
||||
|
rounded = 'sm', |
||||
|
className, |
||||
|
style, |
||||
|
...props |
||||
|
}: ShimmerLineProps) { |
||||
|
const roundedClass = { |
||||
|
none: 'rounded-none', |
||||
|
sm: 'rounded-sm', |
||||
|
md: 'rounded-md', |
||||
|
lg: 'rounded-lg', |
||||
|
full: 'rounded-full', |
||||
|
}[rounded] |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
'shimmer-wave border border-border-soft shrink-0', |
||||
|
roundedClass, |
||||
|
className |
||||
|
)} |
||||
|
style={{ |
||||
|
width: typeof width === 'number' ? `${width}px` : width, |
||||
|
height: typeof height === 'number' ? `${height}px` : height, |
||||
|
...style, |
||||
|
}} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/* ───────────────────────────────────────────────────────── |
||||
|
۲. کامپوننت جامع قالبها (Composite Shimmer Component) |
||||
|
───────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export interface ShimmerProps { |
||||
|
/** تعداد ردیفها یا آیتمهای اسکلتون (پیشفرض: ۵) */ |
||||
|
rows?: number |
||||
|
count?: number |
||||
|
/** ارتفاع هر ردیف (در صورت استفاده سفارشی) */ |
||||
|
rowHeight?: string |
||||
|
/** کلاسهای سفارشی برای بدنه و والد */ |
||||
|
className?: string |
||||
|
/** کلاسهای سفارشی برای هر آیتم */ |
||||
|
itemClassName?: string |
||||
|
/** آیا درون یک Card با کادر و سایه استاندارد قرار گیرد؟ */ |
||||
|
withCard?: boolean |
||||
|
/** عنوان هدر کارت در صورت استفاده از withCard */ |
||||
|
cardHeaderTitle?: string |
||||
|
/** آیکون هدر کارت */ |
||||
|
cardHeaderIcon?: IconName |
||||
|
/** متن سمت چپ هدر کارت */ |
||||
|
cardHeaderSub?: React.ReactNode |
||||
|
/** نوع نمایش: جدولی (پیشفرض)، ردیفی، کارتی، آمار KPI یا جزئیات */ |
||||
|
variant?: 'table' | 'grid' | 'kpi' | 'list' | 'detail' | 'custom' |
||||
|
/** تعداد ستونها در حالت grid (پیشفرض: 3) */ |
||||
|
gridCols?: number |
||||
|
/** فرزندان سفارشی در صورت استفاده از کامپوننت به عنوان Wrapper */ |
||||
|
children?: React.ReactNode |
||||
|
/** آیا انیمیشن فعال باشد؟ (پیشفرض: true) */ |
||||
|
isEnabled?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* کامپوننت مرجع و یکپارچه Shimmer برای پنل مدیریت عقیله |
||||
|
*/ |
||||
|
export function Shimmer({ |
||||
|
rows, |
||||
|
count = 5, |
||||
|
rowHeight, |
||||
|
className, |
||||
|
itemClassName, |
||||
|
withCard = false, |
||||
|
cardHeaderTitle, |
||||
|
cardHeaderIcon = 'list', |
||||
|
cardHeaderSub, |
||||
|
variant = 'table', |
||||
|
gridCols = 3, |
||||
|
children, |
||||
|
isEnabled = true, |
||||
|
}: ShimmerProps) { |
||||
|
if (!isEnabled) { |
||||
|
return <>{children}</> |
||||
|
} |
||||
|
|
||||
|
const itemCount = rows !== undefined ? rows : count |
||||
|
|
||||
|
let content: React.ReactNode |
||||
|
|
||||
|
if (children && variant === 'custom') { |
||||
|
content = <div className={className}>{children}</div> |
||||
|
} else if (variant === 'grid') { |
||||
|
const colClasses: Record<number, string> = { |
||||
|
1: 'grid-cols-1', |
||||
|
2: 'grid-cols-1 sm:grid-cols-2', |
||||
|
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', |
||||
|
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', |
||||
|
} |
||||
|
|
||||
|
content = ( |
||||
|
<div className={cn('grid gap-4 p-4 sm:p-6', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}> |
||||
|
{Array.from({ length: itemCount }).map((_, i) => ( |
||||
|
<div |
||||
|
key={i} |
||||
|
className={cn( |
||||
|
'flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4', |
||||
|
itemClassName |
||||
|
)} |
||||
|
> |
||||
|
<ShimmerBox height={160} rounded="xl" className="w-full" /> |
||||
|
<div className="space-y-2 pt-1"> |
||||
|
<ShimmerLine width="75%" height={16} rounded="sm" /> |
||||
|
<ShimmerLine width="45%" height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
<div className="flex items-center justify-between pt-2 border-t border-border-soft"> |
||||
|
<ShimmerLine width="30%" height={12} rounded="sm" /> |
||||
|
<ShimmerBox width={60} height={24} rounded="lg" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} else if (variant === 'kpi') { |
||||
|
content = ( |
||||
|
<div className={cn('grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5', className)}> |
||||
|
{Array.from({ length: itemCount }).map((_, i) => ( |
||||
|
<div |
||||
|
key={i} |
||||
|
className={cn( |
||||
|
'h-20 rounded-2xl border border-border-soft bg-card/40 p-4 flex items-center justify-between', |
||||
|
itemClassName |
||||
|
)} |
||||
|
> |
||||
|
<div className="space-y-2"> |
||||
|
<ShimmerLine width={60} height={12} rounded="sm" /> |
||||
|
<ShimmerLine width={40} height={20} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerBox width={36} height={36} rounded="xl" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} else if (variant === 'list') { |
||||
|
content = ( |
||||
|
<div className={cn('space-y-3 p-4', className)}> |
||||
|
{Array.from({ length: itemCount }).map((_, i) => ( |
||||
|
<div |
||||
|
key={i} |
||||
|
className={cn( |
||||
|
'flex items-center justify-between rounded-xl border border-border-soft bg-card/40 p-3.5', |
||||
|
itemClassName |
||||
|
)} |
||||
|
> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<ShimmerCircle size={36} /> |
||||
|
<div className="space-y-1.5"> |
||||
|
<ShimmerLine width={140} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={80} height={10} rounded="sm" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<ShimmerBox width={70} height={28} rounded="lg" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} else if (variant === 'detail') { |
||||
|
content = ( |
||||
|
<div className={cn('space-y-5 p-4 sm:p-6', className)}> |
||||
|
<div className="flex items-center justify-between pb-4 border-b border-border-soft"> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<ShimmerCircle size={48} /> |
||||
|
<div className="space-y-2"> |
||||
|
<ShimmerLine width={180} height={18} rounded="sm" /> |
||||
|
<ShimmerLine width={110} height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerBox width={80} height={32} rounded="xl" /> |
||||
|
<ShimmerBox width={32} height={32} rounded="xl" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
||||
|
<ShimmerBox height={110} rounded="2xl" /> |
||||
|
<ShimmerBox height={110} rounded="2xl" /> |
||||
|
</div> |
||||
|
<div className="space-y-2.5 pt-2"> |
||||
|
<ShimmerLine width="100%" height={14} rounded="sm" /> |
||||
|
<ShimmerLine width="90%" height={14} rounded="sm" /> |
||||
|
<ShimmerLine width="60%" height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} else { |
||||
|
// پیشفرض: Table (جدول دادهها با تفکیک ستونها)
|
||||
|
content = ( |
||||
|
<div className={cn('divide-y divide-border-soft', className)}> |
||||
|
{/* ردیف هدر جدول */} |
||||
|
<div className="p-4 bg-muted/40 flex items-center justify-between gap-4"> |
||||
|
<ShimmerLine width={48} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={120} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={80} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={64} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={32} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
|
||||
|
{/* ردیفهای محتوای جدول */} |
||||
|
{Array.from({ length: itemCount }).map((_, i) => ( |
||||
|
<div key={i} className={cn('p-4 flex items-center justify-between gap-4', itemClassName)}> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<ShimmerBox width={18} height={18} rounded="sm" /> |
||||
|
<ShimmerLine width={36} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<div className="space-y-1.5 flex-1 max-w-xs"> |
||||
|
<ShimmerLine width="80%" height={14} rounded="sm" /> |
||||
|
<ShimmerLine width="45%" height={10} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerLine width={70} height={14} rounded="sm" /> |
||||
|
<ShimmerBox width={80} height={24} rounded="full" /> |
||||
|
<ShimmerBox width={28} height={28} rounded="lg" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
if (withCard) { |
||||
|
return ( |
||||
|
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden"> |
||||
|
{(cardHeaderTitle || cardHeaderSub) && ( |
||||
|
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between"> |
||||
|
{cardHeaderTitle && ( |
||||
|
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground"> |
||||
|
<Ic name={cardHeaderIcon} className="size-4 text-primary" /> |
||||
|
<span>{cardHeaderTitle}</span> |
||||
|
</CardTitle> |
||||
|
)} |
||||
|
{cardHeaderSub && ( |
||||
|
<span className="text-xs text-muted-foreground font-mono"> |
||||
|
{cardHeaderSub} |
||||
|
</span> |
||||
|
)} |
||||
|
</CardHeader> |
||||
|
)} |
||||
|
<CardContent className="p-0"> |
||||
|
{content} |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
return <>{content}</> |
||||
|
} |
||||
|
|
||||
|
// ضمیمه کردن متدهای کمکی استاتیک (Static Helper Properties)
|
||||
|
Shimmer.Box = ShimmerBox |
||||
|
Shimmer.Circle = ShimmerCircle |
||||
|
Shimmer.Line = ShimmerLine |
||||
|
|
||||
|
// نامهای مستعار و سازگاری
|
||||
|
export const shimmer = Shimmer |
||||
|
export const ShimmerFrontend = Shimmer |
||||
|
export const AppShimmer = Shimmer |
||||
|
export const shimmer_frontend = Shimmer |
||||
|
|
||||
|
export default Shimmer |
||||
@ -1,35 +1,49 @@ |
|||||
import * as React from 'react' |
import * as React from 'react' |
||||
import { cn } from '@/lib/utils' |
import { cn } from '@/lib/utils' |
||||
|
import { Ic, type IconName } from '@/icons' |
||||
|
|
||||
type AvatarProps = React.ComponentProps<'div'> & { |
type AvatarProps = React.ComponentProps<'div'> & { |
||||
tone?: string |
|
||||
|
icon?: IconName |
||||
|
iconClassName?: string |
||||
|
/** پارامترهای سازگاری با فراخوانیهای قبلی */ |
||||
|
src?: string | null |
||||
letter?: string |
letter?: string |
||||
|
tone?: string |
||||
ring?: boolean |
ring?: boolean |
||||
/** URL تصویر واقعی؛ اگر باشد بهجای گرادیان+حرف نمایش داده میشود. */ |
|
||||
src?: string | null |
|
||||
} |
} |
||||
|
|
||||
/* Hosseinieh letter-avatar: gradient circle + Persian initial |
|
||||
اگر src داده شود، تصویر واقعی نمایش داده میشود و گرادیان حذف میشود. */ |
|
||||
function Avatar({ className, tone, letter = 'م', ring = false, src, children, ...props }: AvatarProps) { |
|
||||
|
/** |
||||
|
* کامپوننت آواتار استاندارد پنل مدیریت عقیله |
||||
|
* به صورت پیشفرض آیکون ادمک (User Icon) با کنتراست بالا، پالت خنثی و ظاهری شارپ رندر میکند. |
||||
|
*/ |
||||
|
function Avatar({ |
||||
|
className, |
||||
|
icon = 'user', |
||||
|
iconClassName, |
||||
|
children, |
||||
|
src, |
||||
|
letter, |
||||
|
tone, |
||||
|
ring, |
||||
|
...props |
||||
|
}: AvatarProps) { |
||||
return ( |
return ( |
||||
<div |
<div |
||||
className={cn( |
className={cn( |
||||
'relative flex shrink-0 items-center justify-center rounded-full text-white select-none', |
|
||||
!src && tone, |
|
||||
src && 'overflow-hidden', |
|
||||
ring && 'ring-2 ring-red-mid/70 ring-offset-2 ring-offset-background', |
|
||||
|
'relative flex shrink-0 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700/60 text-slate-600 dark:text-slate-300 select-none shadow-2xs', |
||||
className, |
className, |
||||
)} |
)} |
||||
{...props} |
{...props} |
||||
> |
> |
||||
{src ? ( |
|
||||
<img src={src} alt="" loading="lazy" className="size-full object-cover" /> |
|
||||
) : ( |
|
||||
children ?? letter |
|
||||
|
{children ?? ( |
||||
|
<Ic |
||||
|
name={icon} |
||||
|
className={cn('size-4 shrink-0', iconClassName)} |
||||
|
/> |
||||
)} |
)} |
||||
</div> |
</div> |
||||
) |
) |
||||
} |
} |
||||
|
|
||||
export { Avatar } |
export { Avatar } |
||||
|
|
||||
@ -1,2 +1,3 @@ |
|||||
export * from '@/components/shared/Shimmer' |
|
||||
export { default } from '@/components/shared/Shimmer' |
|
||||
|
export * from '@/components/shared/shimmer_frontend' |
||||
|
export { default } from '@/components/shared/shimmer_frontend' |
||||
|
|
||||
@ -1,13 +1,15 @@ |
|||||
|
import * as React from 'react' |
||||
import { cn } from '@/lib/utils' |
import { cn } from '@/lib/utils' |
||||
|
|
||||
function Skeleton({ className, ...props }) { |
|
||||
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { |
||||
return ( |
return ( |
||||
<div |
<div |
||||
data-slot="skeleton" |
data-slot="skeleton" |
||||
className={cn('animate-pulse rounded-md bg-white/8', className)} |
|
||||
|
className={cn('shimmer-wave rounded-md border border-border-soft', className)} |
||||
{...props} |
{...props} |
||||
/> |
/> |
||||
) |
) |
||||
} |
} |
||||
|
|
||||
export { Skeleton } |
export { Skeleton } |
||||
|
|
||||
@ -0,0 +1,95 @@ |
|||||
|
import React from 'react' |
||||
|
import { useToastStore, type ToastItem } from '@/stores/toast' |
||||
|
import { Ic, type IconName } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
export function ToastContainer() { |
||||
|
const { toasts, removeToast } = useToastStore() |
||||
|
|
||||
|
if (toasts.length === 0) return null |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
dir="rtl" |
||||
|
aria-live="polite" |
||||
|
className="fixed top-5 left-5 z-[9999] flex max-w-sm w-full flex-col gap-2.5 pointer-events-none select-none transition-all" |
||||
|
> |
||||
|
{toasts.map((toast) => ( |
||||
|
<ToastCard key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} /> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function ToastCard({ toast, onClose }: { toast: ToastItem; onClose: () => void }) { |
||||
|
const configs: Record< |
||||
|
ToastItem['type'], |
||||
|
{ |
||||
|
containerClass: string |
||||
|
icon: IconName |
||||
|
iconClass: string |
||||
|
borderClass: string |
||||
|
} |
||||
|
> = { |
||||
|
success: { |
||||
|
containerClass: |
||||
|
'bg-card/95 text-foreground dark:bg-card/95 border-emerald-500/30 shadow-lg shadow-emerald-500/5', |
||||
|
icon: 'checkCircle', |
||||
|
iconClass: 'text-emerald-500 dark:text-emerald-400', |
||||
|
borderClass: 'border-l-4 border-l-emerald-500', |
||||
|
}, |
||||
|
error: { |
||||
|
containerClass: |
||||
|
'bg-card/95 text-foreground dark:bg-card/95 border-rose-500/30 shadow-lg shadow-rose-500/5', |
||||
|
icon: 'xCircle', |
||||
|
iconClass: 'text-rose-500 dark:text-rose-400', |
||||
|
borderClass: 'border-l-4 border-l-rose-500', |
||||
|
}, |
||||
|
warning: { |
||||
|
containerClass: |
||||
|
'bg-card/95 text-foreground dark:bg-card/95 border-amber-500/30 shadow-lg shadow-amber-500/5', |
||||
|
icon: 'alert', |
||||
|
iconClass: 'text-amber-500 dark:text-amber-400', |
||||
|
borderClass: 'border-l-4 border-l-amber-500', |
||||
|
}, |
||||
|
info: { |
||||
|
containerClass: |
||||
|
'bg-card/95 text-foreground dark:bg-card/95 border-primary/30 shadow-lg shadow-primary/5', |
||||
|
icon: 'helpCircle', |
||||
|
iconClass: 'text-primary dark:text-primary', |
||||
|
borderClass: 'border-l-4 border-l-primary', |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
const current = configs[toast.type] || configs.info |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
role="alert" |
||||
|
className={cn( |
||||
|
'pointer-events-auto flex items-start gap-3 rounded-2xl border p-3.5 backdrop-blur-md transition-all duration-300 animate-in fade-in slide-in-from-top-3', |
||||
|
current.containerClass, |
||||
|
current.borderClass, |
||||
|
)} |
||||
|
> |
||||
|
<div className="mt-0.5 shrink-0"> |
||||
|
<Ic name={current.icon} className={cn('size-4.5', current.iconClass)} /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex-1 min-w-0"> |
||||
|
{toast.title && ( |
||||
|
<h5 className="text-xs font-bold leading-tight text-foreground mb-0.5">{toast.title}</h5> |
||||
|
)} |
||||
|
<p className="text-xs font-medium leading-relaxed text-muted-foreground">{toast.message}</p> |
||||
|
</div> |
||||
|
|
||||
|
<button |
||||
|
onClick={onClose} |
||||
|
aria-label="بستن اعلان" |
||||
|
className="shrink-0 p-1 -m-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
823
src/features/billing/components/BillingDetailModal.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2484
src/features/tours/components/TourDetailDialog.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,255 @@ |
|||||
|
import React from 'react' |
||||
|
import { ShimmerBox, ShimmerCircle, ShimmerLine } from '@/components/shared/shimmer_frontend' |
||||
|
|
||||
|
/** |
||||
|
* Shimmer اسکلتون استاندارد برای تب «مشخصات و متون» در پنجره جزئیات تور |
||||
|
* منطبق با توکنهای AppShimmer و معماری Slate خنثی |
||||
|
*/ |
||||
|
export function TourDetailOverviewShimmer() { |
||||
|
return ( |
||||
|
<div className="space-y-6"> |
||||
|
{/* بخش ۱: ترجمه پویا برای عنوان و توضیحات تور */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<div className="flex items-center justify-between pb-1 border-b border-border-soft/60"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerCircle size={18} /> |
||||
|
<ShimmerLine width={220} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerLine width={80} height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-3 pt-1"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<ShimmerLine width={90} height={12} rounded="sm" /> |
||||
|
<ShimmerBox height={38} rounded="xl" className="w-full" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<ShimmerLine width={120} height={12} rounded="sm" /> |
||||
|
<ShimmerBox height={96} rounded="xl" className="w-full" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* بخش ۲: مشخصات عمومی و زمانبندی تور */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-4"> |
||||
|
<div className="flex items-center gap-2 pb-2 border-b border-border-soft"> |
||||
|
<ShimmerCircle size={18} /> |
||||
|
<ShimmerLine width={180} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> |
||||
|
{Array.from({ length: 6 }).map((_, i) => ( |
||||
|
<div key={i} className="space-y-1.5"> |
||||
|
<ShimmerLine width={70} height={12} rounded="sm" /> |
||||
|
<ShimmerBox height={38} rounded="xl" className="w-full" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* بخش ۳: جدول ۳ ردیف نرخهای مصوب و تخفیف */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-4"> |
||||
|
<div className="flex items-center justify-between pb-2 border-b border-border-soft"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerCircle size={18} /> |
||||
|
<ShimmerLine width={200} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerBox width={80} height={22} rounded="md" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> |
||||
|
{Array.from({ length: 4 }).map((_, i) => ( |
||||
|
<div key={i} className="rounded-xl border border-border-soft bg-surface-base/50 p-3 space-y-2"> |
||||
|
<ShimmerLine width={110} height={12} rounded="sm" /> |
||||
|
<ShimmerBox height={34} rounded="xl" className="w-full" /> |
||||
|
<ShimmerLine width={130} height={10} rounded="sm" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* بخش ۴: وضعیت تکمیل ظرفیت */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/20 p-4 space-y-2.5"> |
||||
|
<div className="flex justify-between"> |
||||
|
<ShimmerLine width={120} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={40} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerBox height={8} rounded="full" className="w-full" /> |
||||
|
<div className="flex justify-between"> |
||||
|
<ShimmerLine width={140} height={12} rounded="sm" /> |
||||
|
<ShimmerLine width={160} height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* بخش ۵: گالری تصاویر تور */} |
||||
|
<div className="space-y-2"> |
||||
|
<ShimmerLine width={100} height={12} rounded="sm" /> |
||||
|
<div className="flex gap-2.5 overflow-x-auto pb-2 no-scrollbar"> |
||||
|
{Array.from({ length: 4 }).map((_, i) => ( |
||||
|
<ShimmerBox key={i} width={96} height={96} rounded="xl" /> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Shimmer اسکلتون استاندارد برای تب «برنامه سفر» |
||||
|
*/ |
||||
|
export function TourItineraryShimmer() { |
||||
|
return ( |
||||
|
<div className="space-y-4"> |
||||
|
{/* نوار بالایی هدر زبان */} |
||||
|
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerCircle size={18} /> |
||||
|
<ShimmerLine width={200} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<ShimmerLine width={80} height={12} rounded="sm" /> |
||||
|
<ShimmerBox width={120} height={28} rounded="xl" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۳ ردیف مراحل روزانه سفر */} |
||||
|
<div className="space-y-4"> |
||||
|
{Array.from({ length: 3 }).map((_, i) => ( |
||||
|
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerCircle size={24} /> |
||||
|
<ShimmerLine width={130} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerBox width={75} height={28} rounded="xl" /> |
||||
|
</div> |
||||
|
<div className="space-y-2"> |
||||
|
<ShimmerBox height={36} rounded="xl" className="w-full" /> |
||||
|
<ShimmerBox height={56} rounded="xl" className="w-full" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Shimmer اسکلتون استاندارد برای تب «امکانات و نکات» |
||||
|
*/ |
||||
|
export function TourFeaturesShimmer() { |
||||
|
return ( |
||||
|
<div className="space-y-6"> |
||||
|
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerCircle size={18} /> |
||||
|
<ShimmerLine width={200} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerBox width={120} height={28} rounded="xl" /> |
||||
|
</div> |
||||
|
|
||||
|
{/* امکانات */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<ShimmerLine width={150} height={14} rounded="sm" /> |
||||
|
<div className="flex flex-wrap gap-2"> |
||||
|
{Array.from({ length: 6 }).map((_, i) => ( |
||||
|
<ShimmerBox key={i} width={85 + (i % 3) * 15} height={28} rounded="xl" /> |
||||
|
))} |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2 pt-2"> |
||||
|
<ShimmerBox height={36} rounded="xl" className="flex-1" /> |
||||
|
<ShimmerBox width={75} height={36} rounded="xl" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* نکات راهنما */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<ShimmerLine width={150} height={14} rounded="sm" /> |
||||
|
<div className="space-y-2"> |
||||
|
{Array.from({ length: 2 }).map((_, i) => ( |
||||
|
<div key={i} className="rounded-xl border border-border-soft bg-surface-base/50 p-3 space-y-1.5"> |
||||
|
<ShimmerLine width={130} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width="90%" height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Shimmer اسکلتون استاندارد برای تب «مسافران» |
||||
|
*/ |
||||
|
export function TourPassengersShimmer() { |
||||
|
return ( |
||||
|
<div className="space-y-3"> |
||||
|
{Array.from({ length: 2 }).map((_, i) => ( |
||||
|
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<div className="flex items-center justify-between border-b border-border-soft pb-2"> |
||||
|
<ShimmerLine width={140} height={14} rounded="sm" /> |
||||
|
<ShimmerLine width={60} height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5"> |
||||
|
{Array.from({ length: 2 }).map((_, j) => ( |
||||
|
<div key={j} className="rounded-xl border border-border-soft bg-surface-base/50 p-3 space-y-2"> |
||||
|
<ShimmerLine width={120} height={14} rounded="sm" /> |
||||
|
<div className="flex justify-between"> |
||||
|
<ShimmerLine width={90} height={10} rounded="sm" /> |
||||
|
<ShimmerLine width={70} height={10} rounded="sm" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Shimmer اسکلتون استاندارد برای تب «نظرات» |
||||
|
*/ |
||||
|
export function TourCommentsShimmer() { |
||||
|
return ( |
||||
|
<div className="space-y-3"> |
||||
|
{Array.from({ length: 3 }).map((_, i) => ( |
||||
|
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerCircle size={24} /> |
||||
|
<ShimmerLine width={100} height={14} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerLine width={40} height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
<ShimmerLine width="95%" height={12} rounded="sm" /> |
||||
|
<ShimmerLine width="60%" height={12} rounded="sm" /> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
interface TourDetailModalShimmerProps { |
||||
|
activeTab?: 'overview' | 'itinerary' | 'features' | 'passengers' | 'comments' |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* رندرر هوشمند و جامع Shimmer متناسب با تب فعال در پنجره جزئیات تور |
||||
|
*/ |
||||
|
export function TourDetailModalShimmer({ activeTab = 'overview' }: TourDetailModalShimmerProps) { |
||||
|
switch (activeTab) { |
||||
|
case 'itinerary': |
||||
|
return <TourItineraryShimmer /> |
||||
|
case 'features': |
||||
|
return <TourFeaturesShimmer /> |
||||
|
case 'passengers': |
||||
|
return <TourPassengersShimmer /> |
||||
|
case 'comments': |
||||
|
return <TourCommentsShimmer /> |
||||
|
case 'overview': |
||||
|
default: |
||||
|
return <TourDetailOverviewShimmer /> |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
|
||||
|
export type ToastType = 'success' | 'error' | 'info' | 'warning' |
||||
|
|
||||
|
export interface ToastItem { |
||||
|
id: string |
||||
|
type: ToastType |
||||
|
title?: string |
||||
|
message: string |
||||
|
duration?: number |
||||
|
} |
||||
|
|
||||
|
interface ToastStore { |
||||
|
toasts: ToastItem[] |
||||
|
addToast: (toast: Omit<ToastItem, 'id'>) => string |
||||
|
removeToast: (id: string) => void |
||||
|
clearToasts: () => void |
||||
|
} |
||||
|
|
||||
|
export const useToastStore = create<ToastStore>((set) => ({ |
||||
|
toasts: [], |
||||
|
addToast: (toast) => { |
||||
|
const id = Math.random().toString(36).substring(2, 9) |
||||
|
const newToast: ToastItem = { ...toast, id } |
||||
|
|
||||
|
set((state) => ({ |
||||
|
toasts: [newToast, ...state.toasts.slice(0, 4)], // Keep at most 5 toasts
|
||||
|
})) |
||||
|
|
||||
|
const duration = toast.duration ?? 4000 |
||||
|
if (duration > 0) { |
||||
|
setTimeout(() => { |
||||
|
set((state) => ({ |
||||
|
toasts: state.toasts.filter((t) => t.id !== id), |
||||
|
})) |
||||
|
}, duration) |
||||
|
} |
||||
|
|
||||
|
return id |
||||
|
}, |
||||
|
removeToast: (id) => { |
||||
|
set((state) => ({ |
||||
|
toasts: state.toasts.filter((t) => t.id !== id), |
||||
|
})) |
||||
|
}, |
||||
|
clearToasts: () => set({ toasts: [] }), |
||||
|
})) |
||||
|
|
||||
|
/** |
||||
|
* Convenient standalone toast helper callable anywhere (components, hooks, async handlers). |
||||
|
*/ |
||||
|
export const toast = { |
||||
|
success: (message: string, title?: string, duration?: number) => |
||||
|
useToastStore.getState().addToast({ type: 'success', message, title, duration }), |
||||
|
error: (message: string, title?: string, duration?: number) => |
||||
|
useToastStore.getState().addToast({ type: 'error', message, title, duration }), |
||||
|
info: (message: string, title?: string, duration?: number) => |
||||
|
useToastStore.getState().addToast({ type: 'info', message, title, duration }), |
||||
|
warning: (message: string, title?: string, duration?: number) => |
||||
|
useToastStore.getState().addToast({ type: 'warning', message, title, duration }), |
||||
|
remove: (id: string) => useToastStore.getState().removeToast(id), |
||||
|
clear: () => useToastStore.getState().clearToasts(), |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue