diff --git a/src/app/Http/Controllers/EcommerceController.php b/src/app/Http/Controllers/EcommerceController.php index dbaa5bd..da876c6 100644 --- a/src/app/Http/Controllers/EcommerceController.php +++ b/src/app/Http/Controllers/EcommerceController.php @@ -258,4 +258,117 @@ public function analytics(Request $request) 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); + } + } } diff --git a/src/resources/views/ecommerce.blade.php b/src/resources/views/ecommerce.blade.php index 414d127..a8620ec 100644 --- a/src/resources/views/ecommerce.blade.php +++ b/src/resources/views/ecommerce.blade.php @@ -144,32 +144,6 @@
- -
-
-

- - Recent Orders -

-
-
- - - - - - - - - - - - - -
Order #CustomerDateStatusTotal
-
-
-
@@ -193,10 +167,36 @@
+ +
+
+

+ + Geographic Sales Distribution +

+
+
+ +
+
+
-
+
+ + +
+
+

+ + Customer Retention +

+
+
+ +
+
@@ -206,7 +206,7 @@ Device Breakdown
-
+
@@ -219,7 +219,7 @@ Top Referral Sources
-
+
@@ -331,39 +331,6 @@ function setKpisLoading() { } } - function getStatusBadge(status) { - switch(status) { - case 'processing': return `Processing`; - case 'completed': return `Completed`; - case 'pending': return `Pending`; - case 'cancelled': return `Cancelled`; - default: return `${status}`; - } - } - - async function loadRecentOrders() { - const tb = document.getElementById('table-orders'); - tb.innerHTML = Array(5).fill('
').join(''); - - try { - const orders = await fetchJson('/api/ecommerce/recent-orders'); - if (!orders || orders.length === 0) { - tb.innerHTML = 'No orders found.'; - return; - } - - tb.innerHTML = orders.map(o => ` - - #${o.number} - ${o.customer} - ${new Date(o.date_created).toLocaleDateString()} - ${getStatusBadge(o.status)} - ${fmt(o.total)} - - `).join(''); - } catch(e) { console.error(e); } - } - async function loadTopProducts() { const tb = document.getElementById('table-products'); tb.innerHTML = Array(5).fill('
').join(''); @@ -386,12 +353,82 @@ function getStatusBadge(status) { let deviceChartInstance = null; let sourceChartInstance = null; + let geoChartInstance = null; + let retentionChartInstance = null; // Custom darker Chart JS defaults for dark UI Chart.defaults.color = '#94a3b8'; Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.05)'; 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() { try { const data = await fetchJson('/api/ecommerce/analytics'); @@ -455,9 +492,9 @@ function getStatusBadge(status) { function loadAllData() { updateFilterStatus(); loadSummary(); - loadRecentOrders(); loadTopProducts(); loadAnalytics(); + loadInsights(); const now = new Date(); document.getElementById('last-updated').textContent = 'Updated ' + now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0'); diff --git a/src/routes/web.php b/src/routes/web.php index 75a9a9c..dcc6607 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -37,4 +37,5 @@ Route::get('/recent-orders', [EcommerceController::class, 'recentOrders']); Route::get('/top-products', [EcommerceController::class, 'topProducts']); Route::get('/analytics', [EcommerceController::class, 'analytics']); + Route::get('/insights', [EcommerceController::class, 'insights']); });