Add skeleton loaders to client and product pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
This commit is contained in:
@@ -61,6 +61,82 @@ const getOrderMetadata = (order: OrderData) => {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ClientDetailsSkeleton = () => (
|
||||||
|
<div className="space-y-6" aria-label="Carregando cliente">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="skeleton h-4 w-20" />
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="skeleton h-16 w-16 rounded-2xl" />
|
||||||
|
<div>
|
||||||
|
<div className="skeleton h-7 w-56" />
|
||||||
|
<div className="skeleton mt-3 h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-10 w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map(item => (
|
||||||
|
<div key={`client-details-kpi-skeleton-${item}`} className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="flex justify-between gap-5">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="skeleton h-3 w-32" />
|
||||||
|
<div className="skeleton mt-3 h-7 w-28" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-11 w-11 shrink-0 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
|
<div className="skeleton h-5 w-36" />
|
||||||
|
<div className="mt-8 skeleton h-[320px] w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="skeleton h-5 w-44" />
|
||||||
|
<div className="skeleton mt-2 h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-7 w-28 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{[0, 1].map(item => (
|
||||||
|
<div key={`client-pattern-skeleton-${item}`} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
|
<div className="skeleton h-4 w-40" />
|
||||||
|
<div className="mt-5 flex h-56 items-end gap-3">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(bar => (
|
||||||
|
<div key={`client-pattern-bar-skeleton-${item}-${bar}`} className="skeleton flex-1" style={{ height: `${20 + ((bar * 17) % 65)}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{[0, 1, 2, 3].map(item => (
|
||||||
|
<div key={`client-order-skeleton-${item}`} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="skeleton h-8 w-8 rounded-lg" />
|
||||||
|
<div>
|
||||||
|
<div className="skeleton h-4 w-48" />
|
||||||
|
<div className="skeleton mt-2 h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-8 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const ClientDetails = () => {
|
const ClientDetails = () => {
|
||||||
const { clientToken } = useParams<{ clientToken: string }>();
|
const { clientToken } = useParams<{ clientToken: string }>();
|
||||||
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
|
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
|
||||||
@@ -129,11 +205,7 @@ const ClientDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <ClientDetailsSkeleton />;
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!details?.hasClient) {
|
if (!details?.hasClient) {
|
||||||
|
|||||||
@@ -133,6 +133,40 @@ const dateFilterPresets = [
|
|||||||
|
|
||||||
const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer";
|
const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer";
|
||||||
|
|
||||||
|
const ClientsTableSkeleton = () => (
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando clientes">
|
||||||
|
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
|
||||||
|
<div className="grid grid-cols-[72px_1.4fr_150px_110px_140px_140px_170px_110px] gap-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(item => (
|
||||||
|
<div key={`clients-head-skeleton-${item}`} className="skeleton h-3" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
||||||
|
<div key={`clients-row-skeleton-${row}`} className="grid grid-cols-[72px_1.4fr_150px_110px_140px_140px_170px_110px] gap-5 px-6 py-4">
|
||||||
|
<div className="skeleton h-7 w-7 rounded-full" />
|
||||||
|
<div className="skeleton h-4 w-4/5" />
|
||||||
|
<div className="skeleton h-7 rounded-full" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="skeleton h-6 w-6 rounded-md" />
|
||||||
|
<div className="skeleton h-6 w-6 rounded-md" />
|
||||||
|
<div className="skeleton h-6 w-6 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-zinc-100 p-4 dark:border-dark-border">
|
||||||
|
<div className="skeleton h-4 w-48" />
|
||||||
|
<div className="skeleton h-8 w-56" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Clients = () => {
|
const Clients = () => {
|
||||||
const { dateRange, setDateRange, themeMode } = useOutletContext<{
|
const { dateRange, setDateRange, themeMode } = useOutletContext<{
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
@@ -152,6 +186,7 @@ const Clients = () => {
|
|||||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(initialRfmAnalytics || null);
|
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(initialRfmAnalytics || null);
|
||||||
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
|
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
|
||||||
|
const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -164,11 +199,13 @@ const Clients = () => {
|
|||||||
const cachedOptions = getCachedClientFilterOptions(dateRange);
|
const cachedOptions = getCachedClientFilterOptions(dateRange);
|
||||||
const cachedClients = getCachedClientAnalytics(dateRange, metadataFilters);
|
const cachedClients = getCachedClientAnalytics(dateRange, metadataFilters);
|
||||||
const cachedRfm = getCachedRfmAnalytics(dateRange, metadataFilters);
|
const cachedRfm = getCachedRfmAnalytics(dateRange, metadataFilters);
|
||||||
|
const hasCachedClients = Boolean(cachedClients);
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
if (cachedOptions) setFilterOptions(cachedOptions);
|
if (cachedOptions) setFilterOptions(cachedOptions);
|
||||||
if (cachedClients) setClientAnalytics(cachedClients);
|
if (cachedClients) setClientAnalytics(cachedClients);
|
||||||
setRfmAnalytics(cachedRfm || null);
|
setRfmAnalytics(cachedRfm || null);
|
||||||
|
setIsLoading(!hasCachedClients);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterOptionsPromise = fetchClientFilterOptions(dateRange, { force: true });
|
const filterOptionsPromise = fetchClientFilterOptions(dateRange, { force: true });
|
||||||
@@ -179,6 +216,7 @@ const Clients = () => {
|
|||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setFilterOptions(options);
|
setFilterOptions(options);
|
||||||
setClientAnalytics(clientsData);
|
setClientAnalytics(clientsData);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rfmData = await rfmPromise;
|
const rfmData = await rfmPromise;
|
||||||
@@ -599,6 +637,9 @@ const Clients = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLoading && clientAnalytics.length === 0 ? (
|
||||||
|
<ClientsTableSkeleton />
|
||||||
|
) : (
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -710,6 +751,7 @@ const Clients = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,43 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ProductDetailsSkeleton = () => (
|
||||||
|
<div className="space-y-6" aria-label="Carregando produto">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="skeleton h-4 w-20" />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="skeleton h-16 w-16 rounded-2xl" />
|
||||||
|
<div>
|
||||||
|
<div className="skeleton h-3 w-24" />
|
||||||
|
<div className="skeleton mt-3 h-7 w-80 max-w-[70vw]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-10 w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{[0, 1].map(item => (
|
||||||
|
<div key={`product-details-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="flex justify-between gap-6">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="skeleton h-3 w-36" />
|
||||||
|
<div className="skeleton mt-3 h-8 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-12 w-12 shrink-0 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
|
<div className="skeleton h-5 w-56" />
|
||||||
|
<div className="mt-8 skeleton h-[400px] w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const ProductDetails = () => {
|
const ProductDetails = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { dateRange, setDateRange } = useOutletContext<{
|
const { dateRange, setDateRange } = useOutletContext<{
|
||||||
@@ -74,11 +111,7 @@ const ProductDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <ProductDetailsSkeleton />;
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!details?.productInfo) {
|
if (!details?.productInfo) {
|
||||||
|
|||||||
@@ -6,6 +6,37 @@ import type { DateRange, ProductAnalyticsItem } from '../types';
|
|||||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||||
import type { ProductSummary } from '../analytics/products';
|
import type { ProductSummary } from '../analytics/products';
|
||||||
|
|
||||||
|
const ProductTableSkeleton = () => (
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando produtos">
|
||||||
|
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
|
||||||
|
<div className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map(item => (
|
||||||
|
<div key={`products-head-skeleton-${item}`} className="skeleton h-3" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
||||||
|
<div key={`products-row-skeleton-${row}`} className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6 px-6 py-4">
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div>
|
||||||
|
<div className="skeleton h-4 w-4/5" />
|
||||||
|
<div className="skeleton mt-2 h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-7 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-zinc-100 p-4 dark:border-dark-border">
|
||||||
|
<div className="skeleton h-4 w-48" />
|
||||||
|
<div className="skeleton h-8 w-56" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
const { dateRange, setDateRange } = useOutletContext<{
|
const { dateRange, setDateRange } = useOutletContext<{
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
@@ -119,13 +150,16 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && productAnalytics.length > 0 && (
|
||||||
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||||
Atualizando produtos
|
Atualizando produtos
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoading && productAnalytics.length === 0 ? (
|
||||||
|
<ProductTableSkeleton />
|
||||||
|
) : (
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -217,6 +251,7 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -189,7 +189,20 @@ const RfmSkeleton = () => (
|
|||||||
{[0, 1, 2].map(row => (
|
{[0, 1, 2].map(row => (
|
||||||
<div key={`rfv-matrix-row-skeleton-${row}`} className="contents">
|
<div key={`rfv-matrix-row-skeleton-${row}`} className="contents">
|
||||||
{[0, 1, 2, 3].map(column => (
|
{[0, 1, 2, 3].map(column => (
|
||||||
<div key={`rfv-matrix-cell-skeleton-${row}-${column}`} className="min-h-28 rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
<div
|
||||||
|
key={`rfv-matrix-cell-skeleton-${row}-${column}`}
|
||||||
|
className={`min-h-28 rounded-xl border border-dark-border bg-dark-input/50 p-3 ${
|
||||||
|
column === 0 ? 'flex flex-col items-center justify-center text-center' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{column === 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="skeleton h-3 w-12" />
|
||||||
|
<div className="skeleton mt-2 h-4 w-12" />
|
||||||
|
<div className="skeleton mt-3 h-3 w-14" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="skeleton h-2 w-8" />
|
<div className="skeleton h-2 w-8" />
|
||||||
<div className="skeleton mt-5 h-4 w-28" />
|
<div className="skeleton mt-5 h-4 w-28" />
|
||||||
<div className="skeleton mt-2 h-3 w-36" />
|
<div className="skeleton mt-2 h-3 w-36" />
|
||||||
@@ -197,6 +210,8 @@ const RfmSkeleton = () => (
|
|||||||
<div className="skeleton h-5" />
|
<div className="skeleton h-5" />
|
||||||
<div className="skeleton h-5" />
|
<div className="skeleton h-5" />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user