feat: replace recent orders with geographic and retention insights
This commit is contained in:
@@ -258,4 +258,117 @@ public function analytics(Request $request)
|
|||||||
return response()->json(['error' => 'Failed to fetch WooCommerce data.'], 500);
|
return response()->json(['error' => 'Failed to fetch WooCommerce data.'], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer Insights (Geographic Distribution, Retention).
|
||||||
|
*/
|
||||||
|
public function insights(Request $request)
|
||||||
|
{
|
||||||
|
$client = $this->wooClient();
|
||||||
|
if (!$client) {
|
||||||
|
return response()->json(['error' => 'API credentials missing.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
|
// Fetch up to 100 recent orders for insights
|
||||||
|
$params = [
|
||||||
|
'per_page' => 100,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'desc',
|
||||||
|
'status' => ['completed', 'processing']
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($from) $params['after'] = $from . 'T00:00:00';
|
||||||
|
if ($to) $params['before'] = $to . 'T23:59:59';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resp = $client->get('orders', $params);
|
||||||
|
$orders = $resp->successful() ? $resp->json() : [];
|
||||||
|
|
||||||
|
$provinces = [];
|
||||||
|
$guests = 0;
|
||||||
|
$newCustomers = 0;
|
||||||
|
$returningCustomers = 0;
|
||||||
|
|
||||||
|
// Simple map for ZA provinces (WooCommerce standard abbreviations)
|
||||||
|
$zaProvinces = [
|
||||||
|
'GT' => 'Gauteng',
|
||||||
|
'WC' => 'Western Cape',
|
||||||
|
'KZN' => 'KwaZulu-Natal',
|
||||||
|
'EC' => 'Eastern Cape',
|
||||||
|
'FS' => 'Free State',
|
||||||
|
'MP' => 'Mpumalanga',
|
||||||
|
'NW' => 'North West',
|
||||||
|
'NC' => 'Northern Cape',
|
||||||
|
'NL' => 'Limpopo',
|
||||||
|
'LP' => 'Limpopo' // Sometimes LP is used
|
||||||
|
];
|
||||||
|
|
||||||
|
// Local cache to count orders per returning customer ID
|
||||||
|
$customerOrderCounts = [];
|
||||||
|
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
// Geographic
|
||||||
|
if (isset($order['billing']) && !empty($order['billing']['state'])) {
|
||||||
|
$stateCode = strtoupper($order['billing']['state']);
|
||||||
|
$stateName = $zaProvinces[$stateCode] ?? $stateCode;
|
||||||
|
|
||||||
|
if (!isset($provinces[$stateName])) {
|
||||||
|
$provinces[$stateName] = 0;
|
||||||
|
}
|
||||||
|
$provinces[$stateName] += (float) $order['total'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention
|
||||||
|
$custId = (int) $order['customer_id'];
|
||||||
|
if ($custId === 0) {
|
||||||
|
$guests++;
|
||||||
|
} else {
|
||||||
|
if (!isset($customerOrderCounts[$custId])) {
|
||||||
|
// Optimistic assumption: if we haven't seen them in this batch,
|
||||||
|
// we can check if they have previous orders (or simplistically
|
||||||
|
// assume all first occurrences in this 100 order batch are 'New',
|
||||||
|
// and subsequent are 'Returning' in the timeframe).
|
||||||
|
// Since we sort by date descending, the FIRST time we process them is their latest order.
|
||||||
|
$customerOrderCounts[$custId] = 1;
|
||||||
|
} else {
|
||||||
|
$customerOrderCounts[$custId]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refine Returners: Anyone with > 1 order in this batch is considered a returner for this simplified metric.
|
||||||
|
// Ideally, you'd check WC Customer data to see 'orders_count', but this requires a separate API call per user
|
||||||
|
// To keep it fast, we estimate based on the batch. Or, better yet, we hit the customer endpoint if we have few unique customers.
|
||||||
|
// For now, let's use the batch estimation:
|
||||||
|
foreach ($customerOrderCounts as $id => $count) {
|
||||||
|
if ($count > 1) {
|
||||||
|
$returningCustomers++;
|
||||||
|
} else {
|
||||||
|
$newCustomers++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort provinces by total ZAR DESC
|
||||||
|
arsort($provinces);
|
||||||
|
// Cap at top 6 regions
|
||||||
|
$topProvinces = array_slice($provinces, 0, 6, true);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'geographic' => [
|
||||||
|
'labels' => array_keys($topProvinces),
|
||||||
|
'values' => array_values($topProvinces)
|
||||||
|
],
|
||||||
|
'retention' => [
|
||||||
|
'labels' => ['New Accounts', 'Returning Accounts', 'Guest Checkouts'],
|
||||||
|
'values' => [$newCustomers, $returningCustomers, $guests]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('WooCommerce API Error (insights): ' . $e->getMessage());
|
||||||
|
return response()->json(['error' => 'Failed to fetch WooCommerce insights data.'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,32 +144,6 @@
|
|||||||
<!-- Tables Row -->
|
<!-- Tables Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
<!-- Recent Orders -->
|
|
||||||
<div class="glass rounded-2xl flex flex-col h-full overflow-hidden">
|
|
||||||
<div class="p-5 border-b border-white/5 flex items-center justify-between bg-white/5">
|
|
||||||
<h3 class="font-bold text-white tracking-tight flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
||||||
Recent Orders
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-black/20 text-xs text-slate-400 uppercase tracking-wider">
|
|
||||||
<th class="p-3 font-medium">Order #</th>
|
|
||||||
<th class="p-3 font-medium">Customer</th>
|
|
||||||
<th class="p-3 font-medium">Date</th>
|
|
||||||
<th class="p-3 font-medium">Status</th>
|
|
||||||
<th class="p-3 font-medium text-right">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="table-orders" class="text-sm divide-y divide-white/5">
|
|
||||||
<!-- Populated via JS -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Top Selling Products -->
|
<!-- Top Selling Products -->
|
||||||
<div class="glass rounded-2xl flex flex-col h-full overflow-hidden">
|
<div class="glass rounded-2xl flex flex-col h-full overflow-hidden">
|
||||||
<div class="p-5 border-b border-white/5 flex items-center justify-between bg-white/5">
|
<div class="p-5 border-b border-white/5 flex items-center justify-between bg-white/5">
|
||||||
@@ -193,10 +167,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Geographic Sales Distribution -->
|
||||||
|
<div class="glass rounded-2xl p-5 flex flex-col items-center">
|
||||||
|
<div class="w-full flex items-center justify-between mb-4">
|
||||||
|
<h3 class="font-bold text-white tracking-tight flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
Geographic Sales Distribution
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full h-[300px]">
|
||||||
|
<canvas id="geoChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced Analytics Charts Row -->
|
<!-- Advanced Analytics Charts Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- Customer Retention -->
|
||||||
|
<div class="glass rounded-2xl p-5 flex flex-col items-center">
|
||||||
|
<div class="w-full flex items-center justify-between mb-4">
|
||||||
|
<h3 class="font-bold text-white tracking-tight flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
||||||
|
Customer Retention
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full max-w-[240px] aspect-square">
|
||||||
|
<canvas id="retentionChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Device Breakthrough -->
|
<!-- Device Breakthrough -->
|
||||||
<div class="glass rounded-2xl p-5 flex flex-col items-center">
|
<div class="glass rounded-2xl p-5 flex flex-col items-center">
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
Device Breakdown
|
Device Breakdown
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-full max-w-[300px] aspect-square">
|
<div class="relative w-full max-w-[240px] aspect-square">
|
||||||
<canvas id="deviceChart"></canvas>
|
<canvas id="deviceChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
Top Referral Sources
|
Top Referral Sources
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-full h-[300px]">
|
<div class="relative w-full h-[240px] mt-4">
|
||||||
<canvas id="sourceChart"></canvas>
|
<canvas id="sourceChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,39 +331,6 @@ function setKpisLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusBadge(status) {
|
|
||||||
switch(status) {
|
|
||||||
case 'processing': return `<span class="inline-flex items-center rounded-md bg-blue-500/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-500/20">Processing</span>`;
|
|
||||||
case 'completed': return `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-400 ring-1 ring-inset ring-green-500/20">Completed</span>`;
|
|
||||||
case 'pending': return `<span class="inline-flex items-center rounded-md bg-yellow-500/10 px-2 py-1 text-xs font-medium text-yellow-400 ring-1 ring-inset ring-yellow-500/20">Pending</span>`;
|
|
||||||
case 'cancelled': return `<span class="inline-flex items-center rounded-md bg-red-500/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-500/20">Cancelled</span>`;
|
|
||||||
default: return `<span class="inline-flex items-center rounded-md bg-slate-500/10 px-2 py-1 text-xs font-medium text-slate-400 ring-1 ring-inset ring-slate-500/20">${status}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRecentOrders() {
|
|
||||||
const tb = document.getElementById('table-orders');
|
|
||||||
tb.innerHTML = Array(5).fill('<tr><td colspan="5" class="p-3"><div class="h-4 w-full rounded loading-shimmer"></div></td></tr>').join('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const orders = await fetchJson('/api/ecommerce/recent-orders');
|
|
||||||
if (!orders || orders.length === 0) {
|
|
||||||
tb.innerHTML = '<tr><td colspan="5" class="p-4 text-center text-slate-500">No orders found.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tb.innerHTML = orders.map(o => `
|
|
||||||
<tr class="hover:bg-white/5 transition-colors">
|
|
||||||
<td class="p-3 font-medium text-white">#${o.number}</td>
|
|
||||||
<td class="p-3 text-slate-300 truncate max-w-[150px]">${o.customer}</td>
|
|
||||||
<td class="p-3 text-slate-500">${new Date(o.date_created).toLocaleDateString()}</td>
|
|
||||||
<td class="p-3">${getStatusBadge(o.status)}</td>
|
|
||||||
<td class="p-3 text-right font-medium text-emerald-400">${fmt(o.total)}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTopProducts() {
|
async function loadTopProducts() {
|
||||||
const tb = document.getElementById('table-products');
|
const tb = document.getElementById('table-products');
|
||||||
tb.innerHTML = Array(5).fill('<tr><td colspan="2" class="p-3"><div class="h-4 w-full rounded loading-shimmer"></div></td></tr>').join('');
|
tb.innerHTML = Array(5).fill('<tr><td colspan="2" class="p-3"><div class="h-4 w-full rounded loading-shimmer"></div></td></tr>').join('');
|
||||||
@@ -386,12 +353,82 @@ function getStatusBadge(status) {
|
|||||||
|
|
||||||
let deviceChartInstance = null;
|
let deviceChartInstance = null;
|
||||||
let sourceChartInstance = null;
|
let sourceChartInstance = null;
|
||||||
|
let geoChartInstance = null;
|
||||||
|
let retentionChartInstance = null;
|
||||||
|
|
||||||
// Custom darker Chart JS defaults for dark UI
|
// Custom darker Chart JS defaults for dark UI
|
||||||
Chart.defaults.color = '#94a3b8';
|
Chart.defaults.color = '#94a3b8';
|
||||||
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.05)';
|
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.05)';
|
||||||
Chart.defaults.font.family = "'Inter', sans-serif";
|
Chart.defaults.font.family = "'Inter', sans-serif";
|
||||||
|
|
||||||
|
async function loadInsights() {
|
||||||
|
try {
|
||||||
|
const data = await fetchJson('/api/ecommerce/insights');
|
||||||
|
|
||||||
|
// Geo Bar Chart
|
||||||
|
const ctxGeo = document.getElementById('geoChart').getContext('2d');
|
||||||
|
if (geoChartInstance) geoChartInstance.destroy();
|
||||||
|
geoChartInstance = new Chart(ctxGeo, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.geographic.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Sales (ZAR)',
|
||||||
|
data: data.geographic.values,
|
||||||
|
backgroundColor: '#e11d48', // Rose color
|
||||||
|
borderRadius: 4,
|
||||||
|
barThickness: 'flex',
|
||||||
|
maxBarThickness: 40
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y', // Horizontal bar to accommodate province names
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return fmt(context.raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true, grid: { color: 'rgba(255, 255, 255, 0.05)' } },
|
||||||
|
y: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retention Doughnut
|
||||||
|
const ctxRet = document.getElementById('retentionChart').getContext('2d');
|
||||||
|
if (retentionChartInstance) retentionChartInstance.destroy();
|
||||||
|
retentionChartInstance = new Chart(ctxRet, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: data.retention.labels,
|
||||||
|
datasets: [{
|
||||||
|
data: data.retention.values,
|
||||||
|
backgroundColor: ['#10b981', '#3b82f6', '#475569'],
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '75%',
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { padding: 20, usePointStyle: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAnalytics() {
|
async function loadAnalytics() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson('/api/ecommerce/analytics');
|
const data = await fetchJson('/api/ecommerce/analytics');
|
||||||
@@ -455,9 +492,9 @@ function getStatusBadge(status) {
|
|||||||
function loadAllData() {
|
function loadAllData() {
|
||||||
updateFilterStatus();
|
updateFilterStatus();
|
||||||
loadSummary();
|
loadSummary();
|
||||||
loadRecentOrders();
|
|
||||||
loadTopProducts();
|
loadTopProducts();
|
||||||
loadAnalytics();
|
loadAnalytics();
|
||||||
|
loadInsights();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
document.getElementById('last-updated').textContent = 'Updated ' + now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0');
|
document.getElementById('last-updated').textContent = 'Updated ' + now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0');
|
||||||
|
|||||||
@@ -37,4 +37,5 @@
|
|||||||
Route::get('/recent-orders', [EcommerceController::class, 'recentOrders']);
|
Route::get('/recent-orders', [EcommerceController::class, 'recentOrders']);
|
||||||
Route::get('/top-products', [EcommerceController::class, 'topProducts']);
|
Route::get('/top-products', [EcommerceController::class, 'topProducts']);
|
||||||
Route::get('/analytics', [EcommerceController::class, 'analytics']);
|
Route::get('/analytics', [EcommerceController::class, 'analytics']);
|
||||||
|
Route::get('/insights', [EcommerceController::class, 'insights']);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user