@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
{{-- Page head --}}

{{ __('Analytics') }}

{{ __('Marketplace health across GMV, supply, and demand') }}

@foreach(['24h','7d','30d','90d','ytd','all'] as $p) @endforeach
{{ __('Export CSV') }}
{{-- KPI row --}}
{{ __('Gross volume') }}
{{ $fmt($gmvCur) }}
{{ $gmvDelta }} vs {{ $fmt($gmvPrev) }} prev
{{ __('Platform fees') }}
{{ $fmt($feesCur) }}
{{ $feesDelta }} {{ $takeRate }}% take rate
{{ __('Active contracts') }}
{{ number_format($contractsCur) }}
{{ $contractsDelta }} {{ number_format($contractsCur) }} new this period
{{ __('New sign-ups') }}
{{ number_format($signupsCur) }}
{{ $signupsDelta }} vs {{ number_format($signupsPrev) }} prev
{{-- Row 1: Revenue area chart + Category mix --}}
{{ __('Gross volume & platform fees') }}
{{ ucfirst($period) }}
GMV {{ __('Platform fees') }}
@if($rn > 0) @foreach($rSteps as $step) {{ $fmtY($step['v']) }} @endforeach @foreach($rLabels as $lbl) {{ $lbl['label'] }} @endforeach @else
{{ __('No data for this period') }}
@endif
{{ __('Category mix') }}
{{ __('By jobs') }}
@if($catMix->sum('job_count') > 0) @foreach($donutPaths as $dp) @endforeach
@foreach($donutPaths as $dp)
{{ $dp['name'] }} {{ $dp['pct'] }}%
@endforeach
@else
{{ __('No data') }}
@endif
{{-- Row 2: Jobs posted vs filled + Activation funnel --}}
{{ __('Jobs posted vs filled') }}
@php $totalPosted = $jData->sum('posted'); $totalFilled = $jData->sum('filled'); $fillRate = $totalPosted > 0 ? round($totalFilled / $totalPosted * 100, 1) : 0; @endphp
{{ __('Fill rate') }} {{ $fillRate }}%
{{ __('Filled') }} {{ __('Unfilled') }}
@if($jn > 0) @foreach($jSteps as $step) {{ $fmtYn($step['v']) }} @endforeach @foreach($jLabels as $lbl) {{ $lbl['label'] }} @endforeach @foreach($jData as $i => $row) @php $cx = round($jX0 + ($i + 0.5) * $jSlotW, 1); $bx = round($cx - $jBarW / 2, 1); $hFill = $jMax > 0 ? round(($row['filled'] / $jMax) * $jCH, 1) : 0; $hUnf = $jMax > 0 ? round(($row['unfilled'] / $jMax) * $jCH, 1) : 0; @endphp @if($hUnf > 0) @endif @if($hFill > 0) @endif @endforeach @else
{{ __('No data for this period') }}
@endif
{{ __('Activation funnel') }}
{{ strtoupper($period) }}
@foreach($funnel as $fi => $step)
{{ $step['label'] }} {{ number_format($step['n']) }} {{ $step['pct'] }}%
@if($step['drop'] && $fi > 0) @php $prevPct = $funnel[$fi - 1]['pct']; $dropOff = $prevPct > 0 ? round(($prevPct - $step['pct']) / $prevPct * 100, 1) : 0; @endphp
↓ {{ $dropOff }}% drop-off
@endif
@endforeach
{{-- Row 3: Top categories + Top markets --}}
{{ __('Top categories') }}
@if($topCatRows->count()) @foreach($topCatRows as $catRow) @php $catName = $topCatNames[$catRow->cat_id] ?? 'Unknown'; $abbr = strtoupper(substr(preg_replace('/[^A-Za-z]/','', $catName), 0, 3)); $prevGmv = $topCatPrev[$catRow->cat_id] ?? 0; [$catDelta, $catUp] = $delta($catRow->gmv, $prevGmv); $sparkPath = $catSparkPaths[$catRow->cat_id] ?? 'M0 11 L80 11'; @endphp @endforeach
{{ __('Category') }} {{ __('Active jobs') }} {{ __('GMV') }} {{ __('Trend') }} Δ vs prev
{{ $abbr }}
{{ $catName }}
{{ number_format($catRow->active_jobs) }} {{ $fmt($catRow->gmv) }} {{ $catDelta }}
@else
{{ __('No category data for this period') }}
@endif
{{ __('Top markets') }}
{{ __('By sign-ups') }}
@if($topMarkets->count()) @foreach($topMarkets as $market) @php $tag = strtoupper(substr(preg_replace('/[^A-Za-z]/','', $market->country_name), 0, 2)); $barPct = round($market->cnt / $mMax * 100, 1); $sharePct = round($market->cnt / $mTotal * 100, 1); @endphp
{{ $tag }} {{ $market->country_name }}
{{ $sharePct }}%
@endforeach @else
{{ __('No market data for this period') }}
@endif
{{-- Row 4: Activity by hour heatmap --}}
{{ __('Activity by hour · UTC') }}
{{ __('Contract events per hour, by day of week') }}
{{ __('Low') }} {{ __('Med') }} {{ __('High') }}
{{-- Hour labels --}}
@for($h = 0; $h < 24; $h++)
{{ $h % 3 === 0 ? str_pad($h, 2, '0', STR_PAD_LEFT) : '' }}
@endfor {{-- Day rows --}} @php $dayNames = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; @endphp @for($d = 0; $d < 7; $d++)
{{ $dayNames[$d] }}
@for($h = 0; $h < 24; $h++) @php $cnt = $heatmap[$d][$h] ?? 0; $pct = $heatmapMax > 0 ? round($cnt / $heatmapMax * 90) : 0; $bg = $pct > 0 ? "color-mix(in oklch, var(--accent) {$pct}%, var(--surface-2))" : 'var(--surface-2)'; $tip = $dayNames[$d] . ' ' . str_pad($h, 2, '0', STR_PAD_LEFT) . ':00 · ' . $cnt . ' events'; @endphp
@endfor @endfor
@endsection @section('admin_styles') @endsection