@extends('backend.layout.master-tailwind') @section('title', __('Analytics')) @section('breadcrumb_section', 'Overview') @section('breadcrumb_page', __('Analytics')) @section('content') @php // ── Helpers ─────────────────────────────────────────────────────────────────── $fmt = function($n) { if (!$n) return '$0'; if ($n >= 1_000_000) return '$' . rtrim(rtrim(number_format($n / 1_000_000, 2), '0'), '.') . 'M'; if ($n >= 1_000) return '$' . rtrim(rtrim(number_format($n / 1_000, 1), '0'), '.') . 'k'; return '$' . number_format($n, 0); }; $fmtYn = function($n) { if ($n >= 1_000_000) return rtrim(rtrim(number_format($n / 1_000_000, 1), '0'), '.') . 'M'; if ($n >= 1_000) return intval($n / 1_000) . 'k'; return number_format($n, 0); }; $fmtY = function($n) use ($fmt) { return $fmt($n); }; $delta = function($cur, $prev) { if (!$prev) return ['—', true]; $pct = round(($cur - $prev) / $prev * 100, 1); return [$pct >= 0 ? "↑ {$pct}%" : "↓ " . abs($pct) . "%", $pct >= 0]; }; $sfn = function(array $data, $w = 220, $h = 36) { $vals = array_values($data); if (count($vals) < 2) return "M0 " . ($h-3) . " L{$w} " . ($h-3); $min = min($vals); $max = max($vals); $range = max($max - $min, 1); $n = count($vals); $pts = []; foreach ($vals as $i => $v) { $x = round($i / ($n-1) * $w, 2); $y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 2); $pts[] = "$x $y"; } return 'M' . implode(' L', $pts); }; $niceMax = function($maxVal, $steps = 4) { if (!$maxVal || $maxVal <= 0) return 100; $mag = pow(10, floor(log10($maxVal))); foreach ([1, 2, 2.5, 5, 10] as $c) { if ($c * $mag * $steps >= $maxVal) return $c * $mag * $steps; } return $mag * 10 * $steps; }; // ── KPI deltas ──────────────────────────────────────────────────────────────── [$gmvDelta, $gmvUp ] = $delta($gmvCur, $gmvPrev); [$feesDelta, $feesUp ] = $delta($feesCur, $feesPrev); [$contractsDelta, $contractsUp] = $delta($contractsCur, $contractsPrev); [$signupsDelta, $signupsUp ] = $delta($signupsCur, $signupsPrev); $takeRate = $gmvCur > 0 ? round($feesCur / $gmvCur * 100, 2) : 0; // ── KPI sparkline paths ─────────────────────────────────────────────────────── $gmvSparkPath = $sfn(array_values($gmvSparkRaw)); $feesSparkPath = $sfn(array_values($feesSparkRaw)); $contractsSparkPath = $sfn(array_values($contractsSparkRaw)); $signupsSparkPath = $sfn(array_values($signupsSparkRaw)); // ── Revenue area chart (viewBox 760×280) ────────────────────────────────────── $rX0 = 36; $rXe = 748; $rY0 = 12; $rYe = 256; $rCW = $rXe - $rX0; $rCH = $rYe - $rY0; $rMax = $niceMax($revSeries->max('gmv') ?: 1); $rn = $revSeries->count(); $rGmvPts = []; $rFeesPts = []; foreach ($revSeries as $i => $row) { $x = round($rX0 + ($rn > 1 ? $i / ($rn-1) : 0) * $rCW, 1); $rGmvPts[] = "$x " . round($rYe - ($row->gmv / $rMax) * $rCH, 1); $rFeesPts[] = "$x " . round($rYe - ($row->fees / $rMax) * $rCH, 1); } $rGmvLine = $rn ? 'M' . implode(' L', $rGmvPts) : ''; $rGmvArea = $rn ? $rGmvLine . " L{$rXe} {$rYe} L{$rX0} {$rYe} Z" : ''; $rFeesLine = $rn ? 'M' . implode(' L', $rFeesPts) : ''; $rSteps = []; for ($i = 0; $i <= 4; $i++) { $v = $rMax * $i / 4; $rSteps[] = ['v' => $v, 'y' => round($rYe - ($v / $rMax) * $rCH, 1)]; } $rLabels = []; $rLblStep = max(1, (int)ceil($rn / 6)); foreach ($revSeries as $i => $row) { if ($i % $rLblStep === 0 || $i === $rn - 1) { $x = round($rX0 + ($rn > 1 ? $i / ($rn-1) : 0) * $rCW, 1); $rLabels[] = ['x' => $x, 'label' => \Carbon\Carbon::parse($row->bucket)->format($labelFmt)]; } } // ── Category mix donut ──────────────────────────────────────────────────────── $totalCatJobs = $catMix->sum('job_count') ?: 1; $donutColors = [ 'var(--accent)', 'color-mix(in oklch, var(--accent) 70%, white)', 'color-mix(in oklch, var(--accent) 50%, white)', 'color-mix(in oklch, var(--accent) 30%, white)', 'var(--text-dim)', 'var(--surface-2)', ]; $donutPaths = []; $donutAngle = -90; foreach ($catMix as $ci => $cat) { $sweep = ($cat->job_count / $totalCatJobs) * 360; $ea = $donutAngle + $sweep; $cx = 70; $cy = 70; $ro = 66; $ri = 46; $x1 = round($cx + $ro * cos(deg2rad($donutAngle)), 3); $y1 = round($cy + $ro * sin(deg2rad($donutAngle)), 3); $x2 = round($cx + $ro * cos(deg2rad($ea)), 3); $y2 = round($cy + $ro * sin(deg2rad($ea)), 3); $x3 = round($cx + $ri * cos(deg2rad($ea)), 3); $y3 = round($cy + $ri * sin(deg2rad($ea)), 3); $x4 = round($cx + $ri * cos(deg2rad($donutAngle)), 3); $y4 = round($cy + $ri * sin(deg2rad($donutAngle)), 3); $la = $sweep > 180 ? 1 : 0; $donutPaths[] = [ 'path' => "M {$x1} {$y1} A {$ro} {$ro} 0 {$la} 1 {$x2} {$y2} L {$x3} {$y3} A {$ri} {$ri} 0 {$la} 0 {$x4} {$y4} Z", 'color' => $donutColors[$ci] ?? 'var(--surface-2)', 'pct' => round($cat->job_count / $totalCatJobs * 100), 'name' => $cat->category, ]; $donutAngle = $ea; } // ── Jobs posted vs filled bar chart (viewBox 760×240) ──────────────────────── $jX0 = 36; $jXe = 748; $jY0 = 12; $jYe = 216; $jCW = $jXe - $jX0; $jCH = $jYe - $jY0; $postedMap = $jobsPostedSeries->keyBy('bucket'); $filledMap = $jobsFilledSeries->keyBy('bucket'); $jBuckets = $jobsPostedSeries->pluck('bucket')->merge($jobsFilledSeries->pluck('bucket'))->unique()->sort()->values(); $jData = $jBuckets->map(function($b) use ($postedMap, $filledMap) { $posted = (int)($postedMap->get($b)?->cnt ?? 0); $filled = min((int)($filledMap->get($b)?->cnt ?? 0), $posted); return ['bucket' => $b, 'posted' => $posted, 'filled' => $filled, 'unfilled' => $posted - $filled]; }); $jMax = $niceMax($jData->max('posted') ?: 1); $jn = $jData->count(); $jSlotW = $jn > 0 ? $jCW / $jn : $jCW; $jBarW = max(6, min(18, round($jSlotW * 0.35, 1))); $jSteps = []; for ($i = 0; $i <= 4; $i++) { $v = $jMax * $i / 4; $jSteps[] = ['v' => $v, 'y' => round($jYe - ($v / $jMax) * $jCH, 1)]; } $jLabels = []; $jLblStep = max(1, (int)ceil($jn / 8)); foreach ($jData as $i => $row) { if ($i % $jLblStep === 0 || $i === $jn - 1) { $jLabels[] = ['x' => round($jX0 + ($i + 0.5) * $jSlotW, 1), 'label' => \Carbon\Carbon::parse($row['bucket'])->format($labelFmt)]; } } // ── Activation funnel ───────────────────────────────────────────────────────── $fBase = max($funnelVisited, 1); $funnel = [ ['label' => __('Registered users'), 'n' => $funnelVisited, 'pct' => 100, 'drop' => false], ['label' => __('Signed up (period)'), 'n' => $funnelSignups, 'pct' => round($funnelSignups / $fBase * 100, 1), 'drop' => true], ['label' => __('Posted job/proposal'), 'n' => $funnelPosted, 'pct' => round($funnelPosted / $fBase * 100, 1), 'drop' => true], ['label' => __('First contract'), 'n' => $funnelContracts, 'pct' => round($funnelContracts / $fBase * 100, 1), 'drop' => true], ['label' => __('Paid'), 'n' => $funnelPaid, 'pct' => round($funnelPaid / $fBase * 100, 1), 'drop' => true], ]; // ── Category sparklines ─────────────────────────────────────────────────────── $catSparkPaths = []; foreach ($topCatRows as $cr) { $vals = $catSpark30->has($cr->cat_id) ? $catSpark30[$cr->cat_id]->toArray() : [0, 0]; $catSparkPaths[$cr->cat_id] = $sfn($vals, 80, 22); } // ── Top markets ─────────────────────────────────────────────────────────────── $mMax = $topMarkets->max('cnt') ?: 1; $mTotal = $topMarkets->sum('cnt') ?: 1; @endphp
{{ __('Marketplace health across GMV, supply, and demand') }}
| {{ __('Category') }} | {{ __('Active jobs') }} | {{ __('GMV') }} | {{ __('Trend') }} | Δ vs prev |
|---|---|---|---|---|
|
{{ $abbr }}
{{ $catName }}
|
{{ number_format($catRow->active_jobs) }} | {{ $fmt($catRow->gmv) }} | {{ $catDelta }} |