Add client purchase pattern analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user