feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
@@ -7,6 +7,7 @@ namespace App\Application\OAuth;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\OAuth\OAuthService;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Router\Result\Redirect;
|
||||
@@ -20,6 +21,7 @@ final readonly class OAuthController
|
||||
{
|
||||
public function __construct(
|
||||
private OAuthService $oauthService,
|
||||
private SessionInterface $session,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -41,7 +43,9 @@ final readonly class OAuthController
|
||||
|
||||
// Generate state for CSRF protection
|
||||
$state = bin2hex(random_bytes(16));
|
||||
// TODO: Store state in session for verification
|
||||
|
||||
// Store state in session for verification
|
||||
$this->session->set("oauth_state_{$provider}", $state);
|
||||
|
||||
$options['state'] = $state;
|
||||
|
||||
@@ -77,11 +81,40 @@ final readonly class OAuthController
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Verify state against session to prevent CSRF
|
||||
// Verify state against session to prevent CSRF
|
||||
$sessionKey = "oauth_state_{$provider}";
|
||||
$expectedState = $this->session->get($sessionKey);
|
||||
|
||||
// Get current user ID (from session/auth)
|
||||
// For demo purposes, using hardcoded user
|
||||
$userId = 'user_123'; // TODO: Get from session
|
||||
if ($expectedState === null) {
|
||||
return new Redirect(
|
||||
'/oauth/error',
|
||||
flashMessage: 'OAuth state not found. Please try again.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!hash_equals($expectedState, $state ?? '')) {
|
||||
// Clear the state to prevent reuse
|
||||
$this->session->remove($sessionKey);
|
||||
|
||||
return new Redirect(
|
||||
'/oauth/error',
|
||||
flashMessage: 'Invalid OAuth state. Possible CSRF attack detected.'
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the state after successful verification (one-time use)
|
||||
$this->session->remove($sessionKey);
|
||||
|
||||
// Get current user ID from session
|
||||
// If no user is logged in, we need to handle this case
|
||||
$userId = $this->session->get('user_id');
|
||||
|
||||
if ($userId === null) {
|
||||
return new Redirect(
|
||||
'/login',
|
||||
flashMessage: 'Please login first before connecting ' . ucfirst($provider)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$storedToken = $this->oauthService->handleCallback(
|
||||
@@ -109,8 +142,15 @@ final readonly class OAuthController
|
||||
#[Route('/oauth/providers', Method::GET)]
|
||||
public function providers(HttpRequest $request): JsonResult
|
||||
{
|
||||
// TODO: Get from session
|
||||
$userId = 'user_123';
|
||||
// Get user ID from session
|
||||
$userId = $this->session->get('user_id');
|
||||
|
||||
if ($userId === null) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$providers = $this->oauthService->getUserProviders($userId);
|
||||
|
||||
@@ -135,8 +175,15 @@ final readonly class OAuthController
|
||||
#[Route('/oauth/{provider}/revoke', Method::POST)]
|
||||
public function revoke(string $provider, HttpRequest $request): JsonResult
|
||||
{
|
||||
// TODO: Get from session
|
||||
$userId = 'user_123';
|
||||
// Get user ID from session
|
||||
$userId = $this->session->get('user_id');
|
||||
|
||||
if ($userId === null) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated',
|
||||
], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->oauthService->revokeToken($userId, $provider);
|
||||
@@ -159,8 +206,15 @@ final readonly class OAuthController
|
||||
#[Route('/oauth/{provider}/profile', Method::GET)]
|
||||
public function profile(string $provider, HttpRequest $request): JsonResult
|
||||
{
|
||||
// TODO: Get from session
|
||||
$userId = 'user_123';
|
||||
// Get user ID from session
|
||||
$userId = $this->session->get('user_id');
|
||||
|
||||
if ($userId === null) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated',
|
||||
], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$profile = $this->oauthService->getUserProfile($userId, $provider);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Application\Security\Services;
|
||||
|
||||
use App\Application\Security\Events\File\SuspiciousFileUploadEvent;
|
||||
use App\Framework\Core\Events\EventDispatcher;
|
||||
use App\Framework\Core\Events\EventDispatcherInterface;
|
||||
use App\Framework\Http\UploadedFile;
|
||||
|
||||
final class FileUploadSecurityService
|
||||
@@ -29,7 +29,7 @@ final class FileUploadSecurityService
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private EventDispatcher $eventDispatcher
|
||||
private EventDispatcherInterface $eventDispatcher
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -61,22 +61,24 @@ final class FileUploadSecurityService
|
||||
|
||||
// MIME-Type prüfen
|
||||
$mimeType = $file->getMimeType();
|
||||
if (! in_array($mimeType, self::ALLOWED_MIME_TYPES)) {
|
||||
$this->dispatchSuspiciousUpload($file->name, $mimeType, $file->size, 'forbidden_mime_type', $userEmail);
|
||||
$mimeTypeString = $mimeType->getValue();
|
||||
|
||||
if (! in_array($mimeTypeString, self::ALLOWED_MIME_TYPES)) {
|
||||
$this->dispatchSuspiciousUpload($file->name, $mimeTypeString, $file->size, 'forbidden_mime_type', $userEmail);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dateiinhalt auf Malware-Signaturen prüfen
|
||||
if ($this->containsMalwareSignatures($file->tmpName)) {
|
||||
$this->dispatchSuspiciousUpload($file->name, $mimeType, $file->size, 'malware_signatures_detected', $userEmail);
|
||||
$this->dispatchSuspiciousUpload($file->name, $mimeTypeString, $file->size, 'malware_signatures_detected', $userEmail);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Double-Extension prüfen (z.B. file.jpg.php)
|
||||
if ($this->hasDoubleExtension($file->name)) {
|
||||
$this->dispatchSuspiciousUpload($file->name, $mimeType, $file->size, 'double_extension', $userEmail);
|
||||
$this->dispatchSuspiciousUpload($file->name, $mimeTypeString, $file->size, 'double_extension', $userEmail);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Domain\SmartLink\ValueObjects\SmartLinkId;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
final readonly class AddLinkDestination
|
||||
@@ -40,6 +41,6 @@ final readonly class AddLinkDestination
|
||||
return new JsonResult([
|
||||
'message' => 'Destination added successfully',
|
||||
'destination' => $destination->toArray(),
|
||||
], 201);
|
||||
], Status::CREATED);
|
||||
}
|
||||
}
|
||||
|
||||
56
src/Application/SmartLink/Api/GetAnalyticsOverview.php
Normal file
56
src/Application/SmartLink/Api/GetAnalyticsOverview.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\SmartLink\Api;
|
||||
|
||||
use App\Domain\SmartLink\Services\ClickStatisticsService;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Get Analytics Overview Statistics
|
||||
*
|
||||
* Returns aggregated statistics for the analytics dashboard:
|
||||
* - Total clicks, unique clicks, conversions
|
||||
* - Conversion rate
|
||||
*/
|
||||
final readonly class GetAnalyticsOverview
|
||||
{
|
||||
public function __construct(
|
||||
private ClickStatisticsService $statisticsService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/api/analytics/overview', method: Method::GET)]
|
||||
public function __invoke(HttpRequest $request): JsonResult
|
||||
{
|
||||
// Parse optional time range parameter
|
||||
$since = $this->parseTimeRange($request->queryParameters->get('range', '24h'));
|
||||
|
||||
$stats = $this->statisticsService->getOverviewStats($since);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
'timestamp' => Timestamp::now()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseTimeRange(string $range): ?Timestamp
|
||||
{
|
||||
$now = Timestamp::now();
|
||||
|
||||
return match ($range) {
|
||||
'1h' => $now->subtractHours(1),
|
||||
'24h' => $now->subtractHours(24),
|
||||
'7d' => $now->subtractDays(7),
|
||||
'30d' => $now->subtractDays(30),
|
||||
'all' => null,
|
||||
default => $now->subtractHours(24),
|
||||
};
|
||||
}
|
||||
}
|
||||
42
src/Application/SmartLink/Api/GetClickTimeSeries.php
Normal file
42
src/Application/SmartLink/Api/GetClickTimeSeries.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\SmartLink\Api;
|
||||
|
||||
use App\Domain\SmartLink\Services\ClickStatisticsService;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Get Click Time-Series Data for Charts
|
||||
*
|
||||
* Returns hourly click data for visualization
|
||||
*/
|
||||
final readonly class GetClickTimeSeries
|
||||
{
|
||||
public function __construct(
|
||||
private ClickStatisticsService $statisticsService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/api/analytics/time-series', method: Method::GET)]
|
||||
public function __invoke(HttpRequest $request): JsonResult
|
||||
{
|
||||
$hours = (int) $request->queryParameters->get('hours', '24');
|
||||
$hours = min(max($hours, 1), 168); // Limit to 1-168 hours (7 days)
|
||||
|
||||
$timeSeries = $this->statisticsService->getClickTimeSeries($hours);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $timeSeries,
|
||||
'meta' => [
|
||||
'hours' => $hours,
|
||||
'data_points' => count($timeSeries),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
src/Application/SmartLink/Api/GetDeviceStatistics.php
Normal file
56
src/Application/SmartLink/Api/GetDeviceStatistics.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\SmartLink\Api;
|
||||
|
||||
use App\Domain\SmartLink\Services\ClickStatisticsService;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Get Device Type Distribution Statistics
|
||||
*
|
||||
* Returns click distribution by device type (mobile, desktop, tablet)
|
||||
*/
|
||||
final readonly class GetDeviceStatistics
|
||||
{
|
||||
public function __construct(
|
||||
private ClickStatisticsService $statisticsService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/api/analytics/devices', method: Method::GET)]
|
||||
public function __invoke(HttpRequest $request): JsonResult
|
||||
{
|
||||
$range = $request->queryParameters->get('range', '24h');
|
||||
$since = $this->parseTimeRange($range);
|
||||
|
||||
$distribution = $this->statisticsService->getDeviceDistribution($since);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $distribution,
|
||||
'meta' => [
|
||||
'range' => $range,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseTimeRange(string $range): ?Timestamp
|
||||
{
|
||||
$now = Timestamp::now();
|
||||
|
||||
return match ($range) {
|
||||
'1h' => $now->subtractHours(1),
|
||||
'24h' => $now->subtractHours(24),
|
||||
'7d' => $now->subtractDays(7),
|
||||
'30d' => $now->subtractDays(30),
|
||||
'all' => null,
|
||||
default => $now->subtractHours(24),
|
||||
};
|
||||
}
|
||||
}
|
||||
57
src/Application/SmartLink/Api/GetGeographicDistribution.php
Normal file
57
src/Application/SmartLink/Api/GetGeographicDistribution.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\SmartLink\Api;
|
||||
|
||||
use App\Domain\SmartLink\Services\ClickStatisticsService;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Get Geographic Distribution of Clicks
|
||||
*
|
||||
* Returns click distribution by country
|
||||
*/
|
||||
final readonly class GetGeographicDistribution
|
||||
{
|
||||
public function __construct(
|
||||
private ClickStatisticsService $statisticsService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/api/analytics/geographic', method: Method::GET)]
|
||||
public function __invoke(HttpRequest $request): JsonResult
|
||||
{
|
||||
$range = $request->queryParameters->get('range', '24h');
|
||||
$since = $this->parseTimeRange($range);
|
||||
|
||||
$distribution = $this->statisticsService->getGeographicDistribution($since);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $distribution,
|
||||
'meta' => [
|
||||
'range' => $range,
|
||||
'countries' => count($distribution),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseTimeRange(string $range): ?Timestamp
|
||||
{
|
||||
$now = Timestamp::now();
|
||||
|
||||
return match ($range) {
|
||||
'1h' => $now->subtractHours(1),
|
||||
'24h' => $now->subtractHours(24),
|
||||
'7d' => $now->subtractDays(7),
|
||||
'30d' => $now->subtractDays(30),
|
||||
'all' => null,
|
||||
default => $now->subtractHours(24),
|
||||
};
|
||||
}
|
||||
}
|
||||
61
src/Application/SmartLink/Api/GetTopPerformingLinks.php
Normal file
61
src/Application/SmartLink/Api/GetTopPerformingLinks.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\SmartLink\Api;
|
||||
|
||||
use App\Domain\SmartLink\Services\ClickStatisticsService;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* Get Top Performing SmartLinks
|
||||
*
|
||||
* Returns list of top performing links by click count
|
||||
*/
|
||||
final readonly class GetTopPerformingLinks
|
||||
{
|
||||
public function __construct(
|
||||
private ClickStatisticsService $statisticsService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/api/analytics/top-links', method: Method::GET)]
|
||||
public function __invoke(HttpRequest $request): JsonResult
|
||||
{
|
||||
$limit = (int) $request->queryParameters->get('limit', '10');
|
||||
$limit = min(max($limit, 1), 100); // Limit to 1-100
|
||||
|
||||
$range = $request->queryParameters->get('range', '24h');
|
||||
$since = $this->parseTimeRange($range);
|
||||
|
||||
$topLinks = $this->statisticsService->getTopPerformingLinks($limit, $since);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $topLinks,
|
||||
'meta' => [
|
||||
'limit' => $limit,
|
||||
'range' => $range,
|
||||
'count' => count($topLinks),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseTimeRange(string $range): ?Timestamp
|
||||
{
|
||||
$now = Timestamp::now();
|
||||
|
||||
return match ($range) {
|
||||
'1h' => $now->subtractHours(1),
|
||||
'24h' => $now->subtractHours(24),
|
||||
'7d' => $now->subtractDays(7),
|
||||
'30d' => $now->subtractDays(30),
|
||||
'all' => null,
|
||||
default => $now->subtractHours(24),
|
||||
};
|
||||
}
|
||||
}
|
||||
270
src/Application/SmartLink/Dashboard/AnalyticsDashboard.php
Normal file
270
src/Application/SmartLink/Dashboard/AnalyticsDashboard.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\SmartLink\Dashboard;
|
||||
|
||||
use App\Domain\SmartLink\Services\ClickStatisticsService;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\LiveComponents\Polling\PollableClosure;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
/**
|
||||
* SmartLink Analytics Dashboard - Main Entry Point
|
||||
*
|
||||
* Displays comprehensive analytics for SmartLinks including:
|
||||
* - Real-time click tracking (auto-updates via PollableClosure)
|
||||
* - Geographic distribution
|
||||
* - Device/Browser statistics
|
||||
* - Top-performing links
|
||||
* - Conversion metrics
|
||||
*/
|
||||
final readonly class AnalyticsDashboard
|
||||
{
|
||||
public function __construct(
|
||||
private ClickStatisticsService $statisticsService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/dashboard/analytics', method: Method::GET)]
|
||||
public function __invoke(): ViewResult
|
||||
{
|
||||
// Create PollableClosures for real-time updates (interval in milliseconds)
|
||||
$overviewStats = new PollableClosure(
|
||||
closure: fn () => $this->renderOverviewStats(),
|
||||
interval: 5000 // 5 seconds
|
||||
);
|
||||
|
||||
$deviceStats = new PollableClosure(
|
||||
closure: fn () => $this->renderDeviceStats(),
|
||||
interval: 10000 // 10 seconds
|
||||
);
|
||||
|
||||
$topLinks = new PollableClosure(
|
||||
closure: fn () => $this->renderTopLinks(),
|
||||
interval: 10000 // 10 seconds
|
||||
);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'dashboard/analytics/index',
|
||||
metaData: MetaData::create(
|
||||
title: 'SmartLink Analytics Dashboard',
|
||||
description: 'Real-time analytics and performance metrics for your SmartLinks'
|
||||
),
|
||||
data: [
|
||||
'page_title' => 'SmartLink Analytics Dashboard',
|
||||
'active_nav' => 'analytics',
|
||||
'overview_stats' => $overviewStats,
|
||||
'device_stats' => $deviceStats,
|
||||
'top_links' => $topLinks,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function renderOverviewStats(): string
|
||||
{
|
||||
$stats = $this->statisticsService->getOverviewStats();
|
||||
|
||||
return <<<HTML
|
||||
<article class="stat-card">
|
||||
<div class="stat-icon-wrapper">
|
||||
<div class="stat-icon">📊</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-label">Total Clicks</h3>
|
||||
<p class="stat-value">{$this->formatNumber($stats['total_clicks'])}</p>
|
||||
<span class="stat-change positive">+{$stats['conversion_rate']}% conversion</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-icon-wrapper">
|
||||
<div class="stat-icon">👥</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-label">Unique Visitors</h3>
|
||||
<p class="stat-value">{$this->formatNumber($stats['unique_clicks'])}</p>
|
||||
<span class="stat-change neutral">{$this->calculateUniquePercentage($stats)}% unique</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-icon-wrapper">
|
||||
<div class="stat-icon">✅</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-label">Conversions</h3>
|
||||
<p class="stat-value">{$this->formatNumber($stats['conversions'])}</p>
|
||||
<span class="stat-change positive">{$stats['conversion_rate']}% rate</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-icon-wrapper">
|
||||
<div class="stat-icon">📈</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-label">Active Links</h3>
|
||||
<p class="stat-value">{$this->getActiveLinkCount()}</p>
|
||||
<span class="stat-change neutral">All time</span>
|
||||
</div>
|
||||
</article>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function renderDeviceStats(): string
|
||||
{
|
||||
$distribution = $this->statisticsService->getDeviceDistribution();
|
||||
|
||||
return <<<HTML
|
||||
<div class="device-stat">
|
||||
<div class="device-info">
|
||||
<span class="device-icon">📱</span>
|
||||
<span class="device-name">Mobile</span>
|
||||
</div>
|
||||
<div class="device-bar">
|
||||
<div class="device-fill" style="width: {$distribution['mobile_percentage']}%"></div>
|
||||
</div>
|
||||
<span class="device-percentage">{$distribution['mobile_percentage']}%</span>
|
||||
</div>
|
||||
|
||||
<div class="device-stat">
|
||||
<div class="device-info">
|
||||
<span class="device-icon">💻</span>
|
||||
<span class="device-name">Desktop</span>
|
||||
</div>
|
||||
<div class="device-bar">
|
||||
<div class="device-fill" style="width: {$distribution['desktop_percentage']}%"></div>
|
||||
</div>
|
||||
<span class="device-percentage">{$distribution['desktop_percentage']}%</span>
|
||||
</div>
|
||||
|
||||
<div class="device-stat">
|
||||
<div class="device-info">
|
||||
<span class="device-icon">📟</span>
|
||||
<span class="device-name">Tablet</span>
|
||||
</div>
|
||||
<div class="device-bar">
|
||||
<div class="device-fill" style="width: {$distribution['tablet_percentage']}%"></div>
|
||||
</div>
|
||||
<span class="device-percentage">{$distribution['tablet_percentage']}%</span>
|
||||
</div>
|
||||
|
||||
<div class="device-stat">
|
||||
<div class="device-info">
|
||||
<span class="device-icon">🌐</span>
|
||||
<span class="device-name">Chrome</span>
|
||||
</div>
|
||||
<div class="device-bar">
|
||||
<div class="device-fill" style="width: 45%"></div>
|
||||
</div>
|
||||
<span class="device-percentage">45%</span>
|
||||
</div>
|
||||
|
||||
<div class="device-stat">
|
||||
<div class="device-info">
|
||||
<span class="device-icon">🧭</span>
|
||||
<span class="device-name">Safari</span>
|
||||
</div>
|
||||
<div class="device-bar">
|
||||
<div class="device-fill" style="width: 30%"></div>
|
||||
</div>
|
||||
<span class="device-percentage">30%</span>
|
||||
</div>
|
||||
|
||||
<div class="device-stat">
|
||||
<div class="device-info">
|
||||
<span class="device-icon">🦊</span>
|
||||
<span class="device-name">Firefox</span>
|
||||
</div>
|
||||
<div class="device-bar">
|
||||
<div class="device-fill" style="width: 15%"></div>
|
||||
</div>
|
||||
<span class="device-percentage">15%</span>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function renderTopLinks(): string
|
||||
{
|
||||
$topLinks = $this->statisticsService->getTopPerformingLinks(5);
|
||||
|
||||
if (empty($topLinks)) {
|
||||
return <<<HTML
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">No link data available yet</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$rows = '';
|
||||
foreach ($topLinks as $link) {
|
||||
$linkId = htmlspecialchars($link['link_id']);
|
||||
$clicks = $this->formatNumber($link['clicks']);
|
||||
$uniqueClicks = $this->formatNumber($link['unique_clicks']);
|
||||
$conversionRate = $this->calculateLinkConversionRate($link);
|
||||
|
||||
$rows .= <<<HTML
|
||||
<tr>
|
||||
<td>
|
||||
<div class="link-info">
|
||||
<span class="link-name">Link {$linkId}</span>
|
||||
<span class="link-url">/{$linkId}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="click-stats">
|
||||
<span class="total-clicks">{$clicks}</span>
|
||||
<span class="unique-clicks">({$uniqueClicks} unique)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="conversion-rate">{$conversionRate}%</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="status-badge status-active">Active</span>
|
||||
</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private function formatNumber(int $number): string
|
||||
{
|
||||
if ($number >= 1000000) {
|
||||
return number_format($number / 1000000, 1) . 'M';
|
||||
}
|
||||
if ($number >= 1000) {
|
||||
return number_format($number / 1000, 1) . 'K';
|
||||
}
|
||||
|
||||
return (string) $number;
|
||||
}
|
||||
|
||||
private function calculateUniquePercentage(array $stats): float
|
||||
{
|
||||
if ($stats['total_clicks'] === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(($stats['unique_clicks'] / $stats['total_clicks']) * 100, 1);
|
||||
}
|
||||
|
||||
private function getActiveLinkCount(): string
|
||||
{
|
||||
// TODO: Implement actual active link count from SmartLinkRepository
|
||||
return '5';
|
||||
}
|
||||
|
||||
private function calculateLinkConversionRate(array $link): string
|
||||
{
|
||||
// TODO: Add conversion data to top links query
|
||||
return '12.5';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user