- Add password-protected links with session-based unlock flow - Add redirect warning (interstitial) pages before external URLs - Add smart targeting rules: device-based, country/geo, A/B weighted rotation - Add configurable per-IP rate limiting with 429 + Retry-After headers - Add daily stats aggregation & log pruning (short-url:aggregate-and-prune) - Add IncrementVisitJob as queue-based counter buffering fallback - Add ShortUrlDailyStats model with JSON stat columns per day - Add two new migrations: targeting/security fields, daily_stats table - Add password-prompt.blade.php and warning.blade.php views - Extend Settings GUI with Performance & Security tab (aggregation + rate limiting) - Extend ShortUrlForm with Targeting & Security section - Add POST route for password form submission (was GET-only → 405) - Replace enum(device_type) with string(20) for cross-DB compatibility - Remove ->after() MySQL-only hints from ALTER TABLE migrations - Fix aggregation test: use whereDate() instead of assertDatabaseHas for date column - Extend en/pl translations for all new features - Expand README.md with full v1.2.0 documentation (476 lines)
134 lines
4.8 KiB
PHP
134 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
|
|
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\DatePicker;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Resources\Pages\Page;
|
|
use Filament\Schemas\Schema;
|
|
|
|
class ViewShortUrlStats extends Page implements HasForms
|
|
{
|
|
use InteractsWithForms;
|
|
|
|
protected static string $resource = ShortUrlResource::class;
|
|
|
|
protected string $view = 'filament-short-url::stats';
|
|
|
|
protected static ?string $title = 'Statistics';
|
|
|
|
public ShortUrl $record;
|
|
|
|
public int $totalVisits = 0;
|
|
|
|
public ?array $filterData = [];
|
|
|
|
public function mount(ShortUrl $record): void
|
|
{
|
|
$this->record = $record;
|
|
|
|
$this->form->fill([
|
|
'preset' => '30_days',
|
|
'date_from' => now()->subDays(29)->format('Y-m-d'),
|
|
'date_to' => now()->format('Y-m-d'),
|
|
]);
|
|
|
|
if (empty($this->filterData)) {
|
|
$this->filterData = [
|
|
'preset' => '30_days',
|
|
'date_from' => now()->subDays(29)->format('Y-m-d'),
|
|
'date_to' => now()->format('Y-m-d'),
|
|
];
|
|
}
|
|
|
|
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
|
|
}
|
|
|
|
public function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->components([
|
|
Select::make('preset')
|
|
->hiddenLabel()
|
|
->options([
|
|
'24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
|
|
'7_days' => __('filament-short-url::default.stats_preset_7_days'),
|
|
'30_days' => __('filament-short-url::default.stats_preset_30_days'),
|
|
'90_days' => __('filament-short-url::default.stats_preset_90_days'),
|
|
'custom' => __('filament-short-url::default.stats_preset_custom'),
|
|
])
|
|
->live()
|
|
->columnSpan(fn ($get) => $get('preset') === 'custom' ? 1 : 'full')
|
|
->afterStateUpdated(function ($state, $set) {
|
|
$to = now()->format('Y-m-d');
|
|
$from = match ($state) {
|
|
'24_hours' => now()->subDay()->format('Y-m-d'),
|
|
'7_days' => now()->subDays(6)->format('Y-m-d'),
|
|
'30_days' => now()->subDays(29)->format('Y-m-d'),
|
|
'90_days' => now()->subDays(89)->format('Y-m-d'),
|
|
'custom' => now()->subDays(29)->format('Y-m-d'),
|
|
default => null,
|
|
};
|
|
if ($from) {
|
|
$set('date_from', $from);
|
|
$set('date_to', $to);
|
|
}
|
|
}),
|
|
|
|
DatePicker::make('date_from')
|
|
->hiddenLabel()
|
|
->placeholder(__('filament-short-url::default.stats_filter_visited_from'))
|
|
->native(false)
|
|
->live()
|
|
->columnSpan(1)
|
|
->visible(fn ($get) => $get('preset') === 'custom')
|
|
->required(),
|
|
|
|
DatePicker::make('date_to')
|
|
->hiddenLabel()
|
|
->placeholder(__('filament-short-url::default.stats_filter_visited_until'))
|
|
->native(false)
|
|
->live()
|
|
->columnSpan(1)
|
|
->visible(fn ($get) => $get('preset') === 'custom')
|
|
->required(),
|
|
])
|
|
->columns(3)
|
|
->statePath('filterData');
|
|
}
|
|
|
|
protected function getHeaderWidgets(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Action::make('back')
|
|
->label(__('filament-short-url::default.stats_btn_back'))
|
|
->icon('heroicon-o-arrow-left')
|
|
->color('gray')
|
|
->url(ShortUrlResource::getUrl()),
|
|
|
|
Action::make('copy_url')
|
|
->label(__('filament-short-url::default.stats_btn_copy'))
|
|
->icon('heroicon-o-clipboard')
|
|
->color('gray')
|
|
->extraAttributes([
|
|
'x-on:click' => 'navigator.clipboard.writeText("'.$this->record->getShortUrl().'")',
|
|
]),
|
|
];
|
|
}
|
|
|
|
public function getTitle(): string
|
|
{
|
|
return __('filament-short-url::default.stats_title').' — '.$this->record->url_key;
|
|
}
|
|
}
|