feature(active users graph)
- users active is recorded when users logges in - add active broker and active customer multi axis line chart - add filter option of 30 days and 7 days
This commit is contained in:
parent
aa8ad6b84b
commit
1edfd7b9d4
2371
.phpstorm.meta.php
Normal file
2371
.phpstorm.meta.php
Normal file
File diff suppressed because it is too large
Load Diff
14
app/Actions/RecordUserPageVisitAction.php
Normal file
14
app/Actions/RecordUserPageVisitAction.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
|
||||
final readonly class RecordUserPageVisitAction
|
||||
{
|
||||
public function execute(?User $user, string $page): void
|
||||
{
|
||||
PageVisit::create(['user_id' => $user?->id, 'page' => $page, 'user_type' => $user?->role ?? null, 'created_at' => now()]);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Actions\RecordUserPageVisitAction;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Enums\UserTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
@ -16,7 +17,7 @@ public function create()
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(AuthenticateUserRequest $request)
|
||||
public function store(AuthenticateUserRequest $request, RecordUserPageVisitAction $action)
|
||||
{
|
||||
$data = $request->validated();
|
||||
if (Auth::attempt($data, $data['remember_me'] ?? false)) {
|
||||
@ -35,6 +36,12 @@ public function store(AuthenticateUserRequest $request)
|
||||
UserTypes::Broker->value, UserTypes::User->value => 'explore',
|
||||
};
|
||||
|
||||
try {
|
||||
$action->execute($user, $route);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Error recording user page visit', [$e->getMessage()]);
|
||||
}
|
||||
|
||||
return to_route($route);
|
||||
} else {
|
||||
return back()
|
||||
|
||||
25
app/Http/Controllers/StatsController.php
Normal file
25
app/Http/Controllers/StatsController.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\UserTypes;
|
||||
use App\Http\Resources\ActiveUsersStatsCollection;
|
||||
use App\Queries\PageVisitStatsQuery;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StatsController extends Controller
|
||||
{
|
||||
public function getActiveUsers(Request $request, PageVisitStatsQuery $baseQuery)
|
||||
{
|
||||
$startDay = $request->from ?? now()->subDays(30);
|
||||
$endDay = $request->to ?? now();
|
||||
|
||||
$activeCustomers = $baseQuery->builder(UserTypes::User, $startDay, $endDay)->get();
|
||||
$activeBrokers = $baseQuery->builder(UserTypes::Broker, $startDay, $endDay)->get();
|
||||
|
||||
return response()->json([
|
||||
'activeCustomers' => new ActiveUsersStatsCollection($activeCustomers),
|
||||
'activeBrokers' => new ActiveUsersStatsCollection($activeBrokers),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
app/Http/Resources/ActiveUsersStatsCollection.php
Normal file
16
app/Http/Resources/ActiveUsersStatsCollection.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ActiveUsersStatsCollection extends ResourceCollection
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Resources/ActiveUsersStatsResource.php
Normal file
20
app/Http/Resources/ActiveUsersStatsResource.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ActiveUsersStatsResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array{data: string, userCount: int}
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'date' => $this->date,
|
||||
'userCount' => (int) $this->user_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Models/PageVisit.php
Normal file
32
app/Models/PageVisit.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserTypes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property UserTypes $user_type
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit query()
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class PageVisit extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'page', 'user_type',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_type' => UserTypes::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Queries/PageVisitStatsQuery.php
Normal file
22
app/Queries/PageVisitStatsQuery.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Queries;
|
||||
|
||||
use App\Enums\UserTypes;
|
||||
use App\Models\PageVisit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final readonly class PageVisitStatsQuery
|
||||
{
|
||||
/**
|
||||
* @return Builder<PageVisit>
|
||||
*/
|
||||
public function builder(UserTypes $userType, string $startDay, string $endDay): Builder
|
||||
{
|
||||
return PageVisit::query()
|
||||
->selectRaw('count(distinct user_id) as user_count, created_at as date')
|
||||
->where('user_type', $userType)
|
||||
->whereBetween('created_at', [$startDay.' 00:00:00', $endDay.' 23:59:59'])
|
||||
->groupBy('date');
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,8 @@
|
||||
"blade-ui-kit/blade-heroicons": "^2.6",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"twilio/sdk": "^8.10"
|
||||
"twilio/sdk": "^8.10",
|
||||
"ext-cassandra": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-ide-helper": "^3.6",
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\UserTypes;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('page_visits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignIdFor(User::class)->nullable();
|
||||
$table->string('page');
|
||||
$table->enum('user_type', UserTypes::values())->nullable();
|
||||
$table->date('created_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('page_visits');
|
||||
}
|
||||
};
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@ -4,6 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
@ -505,6 +508,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||
@ -1223,6 +1232,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
|
||||
@ -13,5 +13,8 @@
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,12 +13,14 @@ import {initTabs} from "./tab.js";
|
||||
import {loadModalFromQuery} from "./explore-page.js";
|
||||
import {deleteRecentSearch} from "./deleteRecentSearch.js";
|
||||
import {initNavMenu} from "./nav-menu.js";
|
||||
import {toggleShimmer} from "./shimmer.js";
|
||||
|
||||
document.deleteSearch = deleteRecentSearch;
|
||||
document.like = like;
|
||||
document.favorite = favorite;
|
||||
document.redirect = redirect;
|
||||
document.showReportModal = showReportModal;
|
||||
window.toggleShimmer = toggleShimmer;
|
||||
|
||||
window.addEventListener('load', async () => {
|
||||
const preloader = document.getElementById('preloader');
|
||||
|
||||
4
resources/js/bootstrap.js
vendored
4
resources/js/bootstrap.js
vendored
@ -1,6 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import {Chart, registerables} from "chart.js";
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
window.axios = axios;
|
||||
window.Chart = Chart;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
window.axios.defaults.headers.common['Accept'] = 'application/json';
|
||||
|
||||
@ -2,6 +2,6 @@ export function toggleShimmer(isLoading, parentElement) {
|
||||
const dataElements = parentElement.querySelectorAll('[data-is-loading]');
|
||||
|
||||
dataElements.forEach(el => {
|
||||
el.dataset.isLoading = isLoading ? 'true' : 'false';
|
||||
el.dataset.isLoading = isLoading ? 'false' : 'true';
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
<div id="active-users-chart-parent" class="col-span-2">
|
||||
<x-dashboard.card data-is-loading="false" class="h-75">
|
||||
<x-ui.toggle-button-group class="mb-4">
|
||||
<x-ui.toggle-button :active="request('sortBy') == null">
|
||||
<button onclick="switchGraph(this, 30)" class="graphBtn flex items-center px-2 space-x-2">
|
||||
<p class="font-bold text-xs sm:text-sm md:text-md">30 Days</p>
|
||||
</button>
|
||||
</x-ui.toggle-button>
|
||||
|
||||
<x-ui.toggle-button>
|
||||
<button onclick="switchGraph(this, 7)" class="graphBtn flex items-center px-2 space-x-2">
|
||||
<p class="font-bold text-xs sm:text-sm md:text-md">7 Days</p>
|
||||
</button>
|
||||
</x-ui.toggle-button>
|
||||
|
||||
<x-ui.toggle-button>
|
||||
<button class="flex items-center pt-0.5 px-4 space-x-2">
|
||||
<x-heroicon-o-calendar-date-range class="w-4"/>
|
||||
</button>
|
||||
</x-ui.toggle-button>
|
||||
</x-ui.toggle-button-group>
|
||||
|
||||
<div class="h-50">
|
||||
<canvas id="active-users-chart"></canvas>
|
||||
</div>
|
||||
</x-dashboard.card>
|
||||
</div>
|
||||
@push('scripts')
|
||||
<script>
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
// BY default load 30 days data
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 30);
|
||||
const startDate = start.toISOString().split('T')[0];
|
||||
const endDate = end.toISOString().split('T')[0];
|
||||
|
||||
generateActiveUsersChart(startDate, endDate);
|
||||
})
|
||||
|
||||
const generateActiveUsersChart = async (startDay, endDay) => {
|
||||
const activeChartParent = document.getElementById('active-users-chart-parent');
|
||||
const activeUsersChart = document.getElementById('active-users-chart')
|
||||
|
||||
if (!activeUsersChart || !activeChartParent) {
|
||||
console.error('canvas not defined');
|
||||
return;
|
||||
}
|
||||
|
||||
toggleShimmer(false, activeChartParent);
|
||||
|
||||
// Generate last 30-day labels from today
|
||||
let labels = getDaysArray(startDay, endDay);
|
||||
|
||||
try {
|
||||
const {data: apiData} = await axios.get('/api/stats/active-users/', {
|
||||
params: {from: startDay, to: endDay}
|
||||
});
|
||||
|
||||
const customerDataFromAPI = apiData?.activeCustomers?.data || [];
|
||||
const brokerDataFromAPI = apiData?.activeBrokers?.data || [];
|
||||
|
||||
// Fill the data from api response
|
||||
const customerData = labels.map((date) => {
|
||||
let found = customerDataFromAPI.find(item => item.date === date);
|
||||
return found ? found.userCount : 0;
|
||||
})
|
||||
|
||||
const brokerData = labels.map((date) => {
|
||||
let found = brokerDataFromAPI.find(item => item.date === date);
|
||||
return found ? found.userCount : 0;
|
||||
})
|
||||
|
||||
const data = {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: `Active Customers`,
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.3)',
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
data: customerData,
|
||||
},
|
||||
{
|
||||
label: `Active Brokers`,
|
||||
backgroundColor: 'rgba(91,162,238,0.3)',
|
||||
borderColor: 'rgb(99,102,255)',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
data: brokerData,
|
||||
}
|
||||
]
|
||||
}
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {beginAtZero: true, ticks: {precision: 0}}
|
||||
},
|
||||
interaction: {intersect: false, mode: 'index'},
|
||||
}
|
||||
};
|
||||
|
||||
const existingChart = Chart.getChart(activeUsersChart);
|
||||
if (existingChart) {
|
||||
existingChart.destroy()
|
||||
}
|
||||
|
||||
const chart = new Chart(activeUsersChart, config);
|
||||
|
||||
toggleShimmer(true, activeChartParent);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const switchGraph = (el, daysCount) => {
|
||||
activeBtn(el);
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - daysCount);
|
||||
|
||||
generateActiveUsersChart(
|
||||
start.toISOString().split('T')[0],
|
||||
end.toISOString().split('T')[0]
|
||||
);
|
||||
}
|
||||
|
||||
const activeBtn = (el) => {
|
||||
const graphBtns = document.getElementById('active-users-chart-parent').querySelectorAll('.toggleBtn');
|
||||
graphBtns.forEach(btn => btn.classList.remove('bg-white'));
|
||||
el.closest('.toggleBtn').classList.add('bg-white');
|
||||
}
|
||||
|
||||
const getDaysArray = (start, end) => {
|
||||
let arr = [];
|
||||
for (let dt = new Date(start); dt <= new Date(end); dt.setDate(dt.getDate() + 1)) {
|
||||
arr.push(new Date(dt).toISOString().split('T')[0]);
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
</script>
|
||||
@endpush
|
||||
@ -0,0 +1,54 @@
|
||||
<x-dashboard.card class="col-span-1 h-75">
|
||||
<canvas id="category-wise-deals-pie-chart"></canvas>
|
||||
</x-dashboard.card>
|
||||
@push('scripts')
|
||||
<script async>
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
const dealsPieChart = document.getElementById('category-wise-deals-pie-chart');
|
||||
try {
|
||||
// const response = await axios.get('/api/stats/customer/active/30');
|
||||
// const apiData = response.data.data;
|
||||
|
||||
|
||||
// Fill the data from api response
|
||||
// const chartData = labels.map((date) => {
|
||||
// let found = apiData.find(item => item.date === date);
|
||||
// return found ? found.userCount : 0;
|
||||
// })
|
||||
|
||||
const data = {
|
||||
labels: [
|
||||
'Real Estate',
|
||||
'Food',
|
||||
'Sell & Deal',
|
||||
'Palaces'
|
||||
],
|
||||
datasets: [{
|
||||
label: 'My First Dataset',
|
||||
data: [30, 20, 15, 5],
|
||||
backgroundColor: [
|
||||
'rgb(255, 99, 132)',
|
||||
'rgb(54, 162, 235)',
|
||||
'rgb(255, 205, 86)',
|
||||
'rgb(99,102,255)',
|
||||
],
|
||||
hoverOffset: 4
|
||||
}]
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: 'pie',
|
||||
data: data,
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
}
|
||||
};
|
||||
const chart = new Chart(dealsPieChart, config);
|
||||
// toggleShimmer(false, activeChartParent);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@endpush
|
||||
@ -0,0 +1,4 @@
|
||||
<section class="grid md:grid-cols-3 gap-4 md:gap-8 wrapper ">
|
||||
<x-dashboard.admin.active-users-chart/>
|
||||
<x-dashboard.admin.category-wise-deals-pie/>
|
||||
</section>
|
||||
@ -32,4 +32,5 @@
|
||||
{{$slot}}
|
||||
</div>
|
||||
</body>
|
||||
@stack('scripts')
|
||||
</html>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
@props(['active' => false])
|
||||
@aware(['activeColor' => 'bg-white'])
|
||||
<div
|
||||
{{$attributes->class(["rounded-full hover:border-gray-300 border border-transparent transition-colors duration-300 ease-in-out", $activeColor => $active])}}
|
||||
{{$attributes->class(["toggleBtn rounded-full hover:border-gray-300 border border-transparent transition-colors duration-300 ease-in-out", $activeColor => $active])}}
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@ -3,4 +3,5 @@
|
||||
<x-dashboard.admin.navbar/>
|
||||
</x-slot:heading>
|
||||
<x-dashboard.admin.stats :stats="$stats" />
|
||||
<x-dashboard.admin.charts />
|
||||
</x-dashboard.admin.layout>
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
->group(function () {
|
||||
include __DIR__.'/interactions.php';
|
||||
include __DIR__.'/deals.php';
|
||||
include __DIR__.'/stats/stats.php';
|
||||
|
||||
Route::delete('/recent-search/{recentSearch}', RecentSearchController::class)->name('recent-search.destroy');
|
||||
});
|
||||
|
||||
7
routes/api/stats/stats.php
Normal file
7
routes/api/stats/stats.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\StatsController;
|
||||
|
||||
Route::prefix('/stats')->group(function () {
|
||||
Route::get('/active-users', [StatsController::class, 'getActiveUsers']);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user