Add client purchase pattern analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s

This commit is contained in:
Cauê Faleiros
2026-07-01 10:25:07 -03:00
parent b99b60b32e
commit a26bc8813e
6 changed files with 284 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types';
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -300,6 +300,28 @@ export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Parti
}, options);
};
const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({
purchaseWeekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map(label => ({ label, value: 0 })),
purchaseHours: Array.from({ length: 24 }, (_, hour) => ({
label: `${String(hour).padStart(2, '0')}h`,
value: 0
}))
});
export const fetchClientPurchasePatternAnalytics = async (options?: CacheOptions): Promise<ClientPurchasePatternAnalytics> => {
const path = '/analytics/clients/purchase-pattern';
return getCachedAnalytics(path, async () => {
try {
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
if (!response.ok) return emptyClientPurchasePattern();
return await response.json();
} catch (error) {
console.error('Fetch client purchase pattern analytics failed', error);
return emptyClientPurchasePattern();
}
}, options);
};
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import RefreshStatus from '../components/RefreshStatus';
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types';
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types';
import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
@@ -133,6 +134,55 @@ 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 CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
const WEEKDAY_BAR_COLOR = '#25C2FF';
const HOUR_BAR_COLOR = '#52DFA0';
type PatternTooltipProps = {
active?: boolean;
payload?: Array<{ value: number }>;
label?: string;
};
const PatternTooltip = ({ active, payload, label }: PatternTooltipProps) => {
if (!active || !payload?.length) return null;
const value = payload[0].value;
return (
<div className="rounded-xl bg-dark-card p-3 shadow-lg">
<p className="mb-1 font-bold text-brand-primary">{label}</p>
<p className="m-0 text-dark-text">
{value} {value === 1 ? 'pedido' : 'pedidos'}
</p>
</div>
);
};
const ClientPurchasePatternSkeleton = () => (
<section className="space-y-4" aria-label="Carregando padrão de compra">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<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={`clients-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={`clients-pattern-bar-skeleton-${item}-${bar}`} className="skeleton flex-1" style={{ height: `${20 + ((bar * 17) % 65)}%` }} />
))}
</div>
</div>
))}
</div>
</section>
);
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">
@@ -189,6 +239,8 @@ const Clients = () => {
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics));
const [purchasePattern, setPurchasePattern] = useState<ClientPurchasePatternAnalytics | null>(null);
const [isPatternLoading, setIsPatternLoading] = useState(true);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -237,6 +289,26 @@ const Clients = () => {
};
}, [dateRange, metadataFilters]);
useEffect(() => {
let isMounted = true;
const loadPurchasePattern = async () => {
setIsPatternLoading(true);
const nextPattern = await fetchClientPurchasePatternAnalytics();
if (isMounted) {
setPurchasePattern(nextPattern);
setIsPatternLoading(false);
}
};
void loadPurchasePattern();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (!isFilterMenuOpen) return;
@@ -446,6 +518,8 @@ const Clients = () => {
const hasActiveFilters = activeFilterCount > 0;
const isRefreshing = isLoading && clientAnalytics.length > 0;
const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0;
const hasWeekdayPattern = purchasePattern?.purchaseWeekdays.some(day => day.value > 0) ?? false;
const hasHourPattern = purchasePattern?.purchaseHours.some(hour => hour.value > 0) ?? false;
return (
<div className="space-y-6">
@@ -773,6 +847,82 @@ const Clients = () => {
</div>
</div>
)}
{isPatternLoading ? (
<ClientPurchasePatternSkeleton />
) : (
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h2>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando os clientes costumam comprar.</p>
</div>
<span className="w-fit rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-card dark:text-dark-muted">
Todo período
</span>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h3>
{hasWeekdayPattern ? (
<div className="mt-5 h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={purchasePattern?.purchaseWeekdays || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis dataKey="label" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} animationDuration={850} animationEasing="ease-out">
{(purchasePattern?.purchaseWeekdays || []).map(day => (
<Cell key={`clients-weekday-${day.label}`} fill={WEEKDAY_BAR_COLOR} fillOpacity={0.62} stroke={WEEKDAY_BAR_COLOR} strokeOpacity={0.9} strokeWidth={1.25} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-56 items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Sem compras registradas.
</div>
)}
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h3>
{hasHourPattern ? (
<div className="mt-5 h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={purchasePattern?.purchaseHours || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="label"
stroke={CHART_AXIS_COLOR}
fontSize={10}
tickLine={false}
axisLine={false}
interval={0}
tickFormatter={(value) => Number(String(value).replace('h', '')) % 3 === 0 ? String(value) : ''}
/>
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} animationDuration={850} animationEasing="ease-out">
{(purchasePattern?.purchaseHours || []).map(hour => (
<Cell key={`clients-hour-${hour.label}`} fill={HOUR_BAR_COLOR} fillOpacity={0.56} stroke={HOUR_BAR_COLOR} strokeOpacity={0.86} strokeWidth={1.1} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-56 items-center justify-center px-6 text-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Sem horário de compra disponível.
</div>
)}
</div>
</div>
</section>
)}
</div>
);
};

View File

@@ -153,6 +153,17 @@ export interface ClientFilterOptions {
sellers: ClientSellerFilterOption[];
}
export interface ClientPurchasePatternAnalytics {
purchaseWeekdays: Array<{
label: string;
value: number;
}>;
purchaseHours: Array<{
label: string;
value: number;
}>;
}
export interface RfmClient {
customerKey: string;
clientToken: string;