Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
'use client'
|
|
|
|
import { Skeleton as HeroSkeleton } from '@heroui/react'
|
|
import clsx from 'clsx'
|
|
|
|
import { texts } from '@/texts'
|
|
|
|
interface SkeletonProps {
|
|
className?: string
|
|
}
|
|
|
|
export const Skeleton = ({ className }: SkeletonProps) => (
|
|
<HeroSkeleton
|
|
aria-hidden
|
|
className={clsx('block rounded-consumer-control bg-consumer-surface-muted motion-reduce:animate-none', className)}
|
|
/>
|
|
)
|
|
|
|
export const ListSkeleton = ({ count = 4, className }: { count?: number; className?: string }) => (
|
|
<div
|
|
aria-busy="true"
|
|
aria-label={texts.common.loadingList}
|
|
className={clsx('flex flex-col gap-3', className ?? 'p-4')}
|
|
role="status"
|
|
>
|
|
{Array.from({ length: count }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center gap-4 rounded-consumer-card border border-consumer-border bg-consumer-surface p-4"
|
|
>
|
|
<Skeleton className="size-12 shrink-0 rounded-2xl" />
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-2/5" />
|
|
<Skeleton className="h-3 w-3/4" />
|
|
</div>
|
|
<Skeleton className="h-6 w-16 rounded-full" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
|
|
export const DetailSkeleton = () => (
|
|
<div
|
|
aria-busy="true"
|
|
aria-label={texts.common.loadingDetails}
|
|
className="flex flex-col gap-4 p-4"
|
|
role="status"
|
|
>
|
|
<Skeleton className="aspect-[3/4] w-full rounded-consumer-modal" />
|
|
<div className="rounded-consumer-modal bg-consumer-surface p-4">
|
|
<Skeleton className="h-7 w-1/2" />
|
|
<Skeleton className="mt-4 h-4 w-full" />
|
|
<Skeleton className="mt-2 h-4 w-4/5" />
|
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
|
<Skeleton className="h-20" />
|
|
<Skeleton className="h-20" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const TABLE_SKELETON_CELL_WIDTHS = ['w-28', 'w-40', 'w-24', 'w-32', 'w-20', 'w-36'] as const
|
|
|
|
interface TableSkeletonProps {
|
|
columnCount?: number
|
|
rows?: number
|
|
showHeader?: boolean
|
|
}
|
|
|
|
export const TableSkeleton = ({ columnCount = 4, rows = 7, showHeader = true }: TableSkeletonProps) => {
|
|
const cellCount = Math.max(1, columnCount)
|
|
|
|
return (
|
|
<div
|
|
aria-busy="true"
|
|
aria-label={texts.common.loadingTable}
|
|
className="w-full overflow-hidden bg-surface"
|
|
role="status"
|
|
>
|
|
{showHeader ? (
|
|
<div className="flex gap-6 border-b border-border bg-surface-secondary p-4">
|
|
{Array.from({ length: cellCount }).map((_, index) => (
|
|
<Skeleton
|
|
key={index}
|
|
className={`h-4 ${TABLE_SKELETON_CELL_WIDTHS[index % TABLE_SKELETON_CELL_WIDTHS.length]}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
<div
|
|
key={rowIndex}
|
|
className="flex items-center gap-6 border-b border-separator px-4 py-3 last:border-0"
|
|
>
|
|
{Array.from({ length: cellCount }).map((_, cellIndex) => (
|
|
<Skeleton
|
|
key={cellIndex}
|
|
className="h-4 min-w-0 flex-1"
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Consumer route fallback with stable page chrome; intentionally contains no spinner. */
|
|
export const ConsumerRouteSkeleton = ({ className }: { className?: string }) => (
|
|
<section className={clsx('flex min-h-full flex-col bg-consumer-canvas', className)}>
|
|
<div
|
|
aria-busy="true"
|
|
aria-label={texts.common.preparingPanel}
|
|
className="flex min-h-full flex-col"
|
|
role="status"
|
|
>
|
|
<header
|
|
aria-hidden
|
|
className="consumer-deep-header flex min-h-[72px] items-center gap-3 px-4 py-3"
|
|
>
|
|
<Skeleton className="size-11 shrink-0 rounded-consumer-control" />
|
|
<Skeleton className="h-5 w-36 max-w-[60%]" />
|
|
</header>
|
|
<div
|
|
aria-hidden
|
|
className="consumer-page-container flex flex-col gap-4 py-4"
|
|
>
|
|
<div className="rounded-consumer-modal bg-consumer-surface p-4">
|
|
<Skeleton className="h-6 w-2/5" />
|
|
<Skeleton className="mt-4 h-4 w-full" />
|
|
<Skeleton className="mt-2 h-4 w-4/5" />
|
|
</div>
|
|
<div className="rounded-consumer-modal bg-consumer-surface p-4">
|
|
<Skeleton className="h-5 w-1/3" />
|
|
<div className="mt-4 flex flex-col gap-3">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center gap-3"
|
|
>
|
|
<Skeleton className="size-10 shrink-0 rounded-2xl" />
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-2/5" />
|
|
<Skeleton className="h-3 w-3/4" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
|
|
export const PageLoading = ({ label = texts.common.preparingPage }: { label?: string }) => (
|
|
<div
|
|
aria-busy="true"
|
|
aria-live="polite"
|
|
className="flex min-h-full w-full items-center justify-center bg-consumer-canvas px-4 py-16"
|
|
role="status"
|
|
>
|
|
<div className="flex flex-col items-center gap-5">
|
|
<div className="relative flex size-16 items-center justify-center rounded-consumer-surface bg-primary">
|
|
<span className="absolute inset-0 animate-ping rounded-consumer-surface bg-primary/20 motion-reduce:animate-none" />
|
|
<span className="relative size-5 animate-spin rounded-full border-2 border-white/40 border-t-white motion-reduce:animate-none" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-bold text-secondary-20">{label}</p>
|
|
<p className="mt-1 text-xs text-secondary-30">{texts.common.pleaseWait}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|