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:
@@ -4,6 +4,7 @@ const {
|
|||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
getClientDetailsAnalytics,
|
getClientDetailsAnalytics,
|
||||||
getClientFilterOptions,
|
getClientFilterOptions,
|
||||||
|
getClientPurchasePatternAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
getProductDetailsAnalytics,
|
getProductDetailsAnalytics,
|
||||||
@@ -75,6 +76,15 @@ router.get('/analytics/clients/filters', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/clients/purchase-pattern', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getClientPurchasePatternAnalytics());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching client purchase pattern analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
|
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
|
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
|
||||||
|
|||||||
@@ -942,6 +942,58 @@ const getClientFilterOptions = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getClientPurchasePatternAnalytics = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
${CUSTOMER_IDENTITY_CTE},
|
||||||
|
order_events AS (
|
||||||
|
SELECT DISTINCT ON (
|
||||||
|
${CUSTOMER_KEY_SQL},
|
||||||
|
COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text)
|
||||||
|
)
|
||||||
|
data_pedido_date,
|
||||||
|
created_at
|
||||||
|
FROM identity_orders
|
||||||
|
WHERE data_pedido_date IS NOT NULL
|
||||||
|
ORDER BY
|
||||||
|
${CUSTOMER_KEY_SQL},
|
||||||
|
COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text),
|
||||||
|
created_at ASC NULLS LAST
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
EXTRACT(DOW FROM data_pedido_date)::int as weekday,
|
||||||
|
EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour,
|
||||||
|
COUNT(*)::int as order_count
|
||||||
|
FROM order_events
|
||||||
|
GROUP BY weekday, hour
|
||||||
|
ORDER BY weekday ASC, hour ASC;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
|
||||||
|
const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
label: `${String(hour).padStart(2, '0')}h`,
|
||||||
|
value: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.rows.forEach(row => {
|
||||||
|
const weekday = row.weekday === null || row.weekday === undefined ? null : Number(row.weekday);
|
||||||
|
const hour = row.hour === null || row.hour === undefined ? null : Number(row.hour);
|
||||||
|
const orderCount = toNumber(row.order_count);
|
||||||
|
|
||||||
|
if (weekday !== null && Number.isInteger(weekday) && purchaseWeekdays[weekday]) {
|
||||||
|
purchaseWeekdays[weekday].value += orderCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hour !== null && Number.isInteger(hour) && purchaseHours[hour]) {
|
||||||
|
purchaseHours[hour].value += orderCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchaseWeekdays,
|
||||||
|
purchaseHours
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getOrderGroupKey = (row) => (
|
const getOrderGroupKey = (row) => (
|
||||||
row.pedido_id ||
|
row.pedido_id ||
|
||||||
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
|
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
|
||||||
@@ -1353,6 +1405,7 @@ module.exports = {
|
|||||||
getFrequencyScore,
|
getFrequencyScore,
|
||||||
getClientDetailsAnalytics,
|
getClientDetailsAnalytics,
|
||||||
getClientFilterOptions,
|
getClientFilterOptions,
|
||||||
|
getClientPurchasePatternAnalytics,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
getClientDetailsAnalytics,
|
getClientDetailsAnalytics,
|
||||||
getClientFilterOptions,
|
getClientFilterOptions,
|
||||||
|
getClientPurchasePatternAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
@@ -791,6 +792,40 @@ test('getClientFilterOptions returns all distinct order metadata options', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
|
||||||
|
pool.query = async (sql, params = []) => {
|
||||||
|
assert.deepEqual(params, []);
|
||||||
|
assert.match(sql, /order_events AS/);
|
||||||
|
assert.match(sql, /DISTINCT ON \(\s+customer_key,\s+COALESCE\(NULLIF\(pedido_id, ''\), data_pedido \|\| '_' \|\| valor_pedido::text\)\s+\)/);
|
||||||
|
assert.match(sql, /EXTRACT\(DOW FROM data_pedido_date\)::int as weekday/);
|
||||||
|
assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{ weekday: 1, hour: 9, order_count: 2 },
|
||||||
|
{ weekday: 5, hour: 18, order_count: 1 },
|
||||||
|
{ weekday: 5, hour: null, order_count: 1 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pattern = await getClientPurchasePatternAnalytics();
|
||||||
|
|
||||||
|
assert.equal(pattern.purchaseWeekdays.length, 7);
|
||||||
|
assert.equal(pattern.purchaseHours.length, 24);
|
||||||
|
assert.deepEqual(pattern.purchaseWeekdays[1], { label: 'Seg', value: 2 });
|
||||||
|
assert.deepEqual(pattern.purchaseWeekdays[5], { label: 'Sex', value: 2 });
|
||||||
|
assert.deepEqual(pattern.purchaseHours[9], { label: '09h', value: 2 });
|
||||||
|
assert.deepEqual(pattern.purchaseHours[18], { label: '18h', value: 1 });
|
||||||
|
assert.deepEqual(pattern.purchaseHours[0], { label: '00h', value: 0 });
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => {
|
test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => {
|
||||||
const originalQuery = pool.query;
|
const originalQuery = pool.query;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
@@ -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';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
@@ -300,6 +300,28 @@ export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Parti
|
|||||||
}, options);
|
}, 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> => {
|
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
|
||||||
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
return getCachedAnalytics(path, async () => {
|
return getCachedAnalytics(path, async () => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
|
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 RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types';
|
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||||
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
||||||
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
||||||
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
||||||
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
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 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 = () => (
|
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="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 [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
|
||||||
const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
|
const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
|
||||||
const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics));
|
const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics));
|
||||||
|
const [purchasePattern, setPurchasePattern] = useState<ClientPurchasePatternAnalytics | null>(null);
|
||||||
|
const [isPatternLoading, setIsPatternLoading] = useState(true);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -237,6 +289,26 @@ const Clients = () => {
|
|||||||
};
|
};
|
||||||
}, [dateRange, metadataFilters]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isFilterMenuOpen) return;
|
if (!isFilterMenuOpen) return;
|
||||||
|
|
||||||
@@ -446,6 +518,8 @@ const Clients = () => {
|
|||||||
const hasActiveFilters = activeFilterCount > 0;
|
const hasActiveFilters = activeFilterCount > 0;
|
||||||
const isRefreshing = isLoading && clientAnalytics.length > 0;
|
const isRefreshing = isLoading && clientAnalytics.length > 0;
|
||||||
const shouldShowRfmLoading = isRfmLoading && 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -773,6 +847,82 @@ const Clients = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -153,6 +153,17 @@ export interface ClientFilterOptions {
|
|||||||
sellers: ClientSellerFilterOption[];
|
sellers: ClientSellerFilterOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientPurchasePatternAnalytics {
|
||||||
|
purchaseWeekdays: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
purchaseHours: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RfmClient {
|
export interface RfmClient {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
clientToken: string;
|
clientToken: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user