1 Commits

Author SHA1 Message Date
Bartłomiej Janczak
e0b7e3f28c feat: add social retargeting pixels, developer REST API, and webhooks system 2026-06-02 09:44:53 +02:00
15 changed files with 694 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
<?php
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::table('short_urls', function (Blueprint $table) {
$table->string('pixel_meta_id', 100)->nullable();
$table->string('pixel_google_id', 100)->nullable();
$table->string('pixel_linkedin_id', 100)->nullable();
$table->string('webhook_url', 2048)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
]);
});
}
};

View File

@@ -338,4 +338,33 @@ return [
'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
'warning_btn_continue' => 'Continue to Destination',
'warning_btn_back' => 'Go Back',
// Marketing & API Tab
'tab_marketing' => 'Marketing & API',
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Link Webhook Integration',
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
'webhook_url' => 'Dedicated Webhook URL',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',
'settings_section_global_webhook' => 'Global Webhook Configuration',
'settings_global_webhook_url' => 'Global Webhook URL',
'settings_global_webhook_url_helper' => 'Destination URL to dispatch event payloads asynchronously in the background for all links.',
'settings_webhook_events' => 'Monitored Webhook Events',
'settings_webhook_events_helper' => 'Select which events should trigger a POST notification.',
'webhook_event_visited' => 'Link Visited (Click)',
'webhook_event_created' => 'Link Created',
'webhook_event_expired' => 'Link Expired (Date)',
'webhook_event_limit_reached' => 'Link Click Limit Reached',
'settings_section_api_keys' => 'Developer API Keys',
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
'settings_api_keys' => 'Active API Keys',
'api_key_name' => 'Key Name (Description)',
'api_key' => 'API Key',
'active' => 'Active',
];

View File

@@ -339,4 +339,33 @@ return [
'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
'warning_btn_continue' => 'Kontynuuj do celu',
'warning_btn_back' => 'Wróć',
// Marketing & API Tab
'tab_marketing' => 'Marketing i API',
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Integracja Webhook linku',
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
'webhook_url' => 'Dedykowany Webhook URL',
// Settings tab additions
'settings_tab_developer' => 'API i Webhooki',
'settings_section_global_webhook' => 'Konfiguracja globalnego Webhooka',
'settings_global_webhook_url' => 'Globalny Webhook URL',
'settings_global_webhook_url_helper' => 'Adres URL, pod który wysyłane będą w tle powiadomienia o zdarzeniach ze wszystkich linków.',
'settings_webhook_events' => 'Monitorowane zdarzenia Webhooka',
'settings_webhook_events_helper' => 'Wybierz zdarzenia, które powinny wyzwalać wysłanie powiadomienia POST.',
'webhook_event_visited' => 'Odwiedziny linku (Wizyta)',
'webhook_event_created' => 'Utworzenie linku',
'webhook_event_expired' => 'Wygaśnięcie linku (Data)',
'webhook_event_limit_reached' => 'Osiągnięcie limitu kliknięć',
'settings_section_api_keys' => 'Klucze API Dewelopera',
'settings_api_keys_description' => 'Zarządzaj kluczami API do integracji z zewnętrznymi systemami (np. CRM, Zapier, Make). Autoryzacja odbywa się za pomocą nagłówka X-Api-Key.',
'settings_api_keys' => 'Aktywne klucze API',
'api_key_name' => 'Nazwa klucza (Opis)',
'api_key' => 'Klucz API',
'active' => 'Aktywny',
];

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connecting...</title>
<!-- Premium Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
<!-- Meta / Facebook Pixel -->
@if(!empty($pixelMetaId))
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '{{ $pixelMetaId }}');
fbq('track', 'PageView');
</script>
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
@endif
<!-- Google Analytics / GTM -->
@if(!empty($pixelGoogleId))
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ $pixelGoogleId }}');
</script>
@endif
<!-- LinkedIn Insight -->
@if(!empty($pixelLinkedinId))
<script type="text/javascript">
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
(function(l) {
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
window.lintrk.q=[]}
var s = document.getElementsByTagName("script")[0];
var b = document.createElement("script");
b.type = "text/javascript";b.async = true;
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
@endif
<!-- Premium Styling -->
<style>
body {
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
color: #f1f5f9;
font-family: 'Outfit', -apple-system, sans-serif;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
.container {
text-align: center;
padding: 2.5rem;
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
animation: fadeIn 0.6s ease-out;
}
.spinner {
position: relative;
width: 72px;
height: 72px;
margin: 0 auto 2rem;
}
.spinner-ring {
position: absolute;
width: 100%;
height: 100%;
border: 4px solid transparent;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.spinner-ring:nth-child(2) {
animation-delay: -0.3s;
border-top-color: #a855f7;
}
.spinner-ring:nth-child(3) {
animation-delay: -0.6s;
border-top-color: #ec4899;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
background: linear-gradient(to right, #818cf8, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.025em;
}
p {
font-size: 0.95rem;
color: #94a3b8;
margin: 0;
font-weight: 300;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="container">
<div class="spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
<h1>Connecting Safely</h1>
<p>Securing connection & forwarding you now...</p>
</div>
<!-- Async non-blocking redirect -->
<script>
setTimeout(function() {
window.location.replace("{!! addslashes($destination) !!}");
}, 250);
</script>
</body>
</html>

View File

@@ -11,3 +11,11 @@ Route::match(
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
Route::prefix('api/short-url')
->middleware([\Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi::class])
->group(function () {
Route::get('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'index']);
Route::post('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'destroy']);
});

View File

@@ -74,6 +74,9 @@ class ShortUrlSettingsPage extends Page implements HasForms
'qr_gradient_from' => $mgr->get('qr_gradient_from', '#4f46e5'),
'qr_gradient_to' => $mgr->get('qr_gradient_to', '#06b6d4'),
'qr_gradient_type' => $mgr->get('qr_gradient_type', 'linear'),
'global_webhook_url' => $mgr->get('global_webhook_url'),
'webhook_events' => $mgr->get('webhook_events', ['visited']),
'api_keys' => $mgr->get('api_keys', []),
]);
}
@@ -537,6 +540,56 @@ class ShortUrlSettingsPage extends Page implements HasForms
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
]),
]),
// ── Developer API & Webhooks ────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_developer'))
->icon('heroicon-o-cpu-chip')
->schema([
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
->schema([
TextInput::make('global_webhook_url')
->label(__('filament-short-url::default.settings_global_webhook_url'))
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
->url()
->nullable()
->columnSpanFull(),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
->multiple()
->options([
'visited' => __('filament-short-url::default.webhook_event_visited'),
'created' => __('filament-short-url::default.webhook_event_created'),
'expired' => __('filament-short-url::default.webhook_event_expired'),
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
])
->default(['visited'])
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->schema([
\Filament\Forms\Components\Repeater::make('api_keys')
->label(__('filament-short-url::default.settings_api_keys'))
->schema([
TextInput::make('name')
->label(__('filament-short-url::default.api_key_name'))
->required(),
TextInput::make('key')
->label(__('filament-short-url::default.api_key'))
->disabled()
->dehydrated()
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
])
->columns(3)
->default([]),
]),
]),
]),
])
->statePath('data');

View File

@@ -27,6 +27,7 @@ class ShortUrlForm
static::linkTab(),
static::targetingTab(),
static::trackingTab(),
static::marketingTab(),
static::qrDesignTab(),
])->columnSpanFull(),
]);
@@ -477,4 +478,43 @@ class ShortUrlForm
]),
]);
}
private static function marketingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_marketing'))
->icon('heroicon-o-megaphone')
->schema([
Section::make(__('filament-short-url::default.marketing_pixels_title'))
->description(__('filament-short-url::default.marketing_pixels_desc'))
->schema([
TextInput::make('pixel_meta_id')
->label(__('filament-short-url::default.pixel_meta'))
->placeholder('e.g., 1234567890')
->maxLength(100)
->nullable(),
TextInput::make('pixel_google_id')
->label(__('filament-short-url::default.pixel_google'))
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
->maxLength(100)
->nullable(),
TextInput::make('pixel_linkedin_id')
->label(__('filament-short-url::default.pixel_linkedin'))
->placeholder('e.g., 1234567')
->maxLength(100)
->nullable(),
])->columns(3),
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
->description(__('filament-short-url::default.marketing_webhooks_desc'))
->schema([
TextInput::make('webhook_url')
->label(__('filament-short-url::default.webhook_url'))
->placeholder('https://api.yourcrm.com/webhooks/clicks')
->url()
->maxLength(2048)
->nullable()
->columnSpanFull(),
]),
]);
}
}

View File

@@ -32,6 +32,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'2026_06_01_000004_add_targeting_and_security_to_short_urls_table',
'2026_06_01_000005_create_short_url_daily_stats_table',
'2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table',
'2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table',
])
->hasCommands([
SyncBufferedCountersCommand::class,

View File

@@ -0,0 +1,137 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ShortUrlApiController extends Controller
{
public function __construct(
private readonly ShortUrlService $service
) {}
/**
* Display a listing of short URLs.
*/
public function index(): JsonResponse
{
$links = ShortUrl::orderBy('id', 'desc')->paginate(30);
$transformed = $links->getCollection()->map(fn ($link) => $this->transformLink($link));
return response()->json([
'data' => $transformed,
'meta' => [
'current_page' => $links->currentPage(),
'last_page' => $links->lastPage(),
'per_page' => $links->perPage(),
'total' => $links->total(),
],
]);
}
/**
* Store a newly created short URL.
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'destination_url' => 'required|url|max:2048',
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
'notes' => 'nullable|string|max:1000',
'is_enabled' => 'nullable|boolean',
'redirect_status_code' => 'nullable|integer|in:301,302',
'single_use' => 'nullable|boolean',
'forward_query_params' => 'nullable|boolean',
'max_visits' => 'nullable|integer|min:1',
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'pixel_meta_id' => 'nullable|string|max:100',
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
'webhook_url' => 'nullable|url|max:2048',
]);
$shortUrl = $this->service->create($validated);
// Fire 'created' webhook if active
$this->dispatchCreatedWebhook($shortUrl);
return response()->json([
'message' => 'Short URL created successfully.',
'data' => $this->transformLink($shortUrl),
], 201);
}
/**
* Remove the specified short URL.
*/
public function destroy(int $id): JsonResponse
{
$shortUrl = ShortUrl::findOrFail($id);
$shortUrl->delete();
return response()->json([
'message' => 'Short URL deleted successfully.',
]);
}
/**
* Transform a ShortUrl model to API response array.
*/
private function transformLink(ShortUrl $link): array
{
return [
'id' => $link->id,
'destination_url' => $link->destination_url,
'url_key' => $link->url_key,
'short_url' => $link->getShortUrl(),
'is_enabled' => (bool) $link->is_enabled,
'redirect_status_code' => (int) $link->redirect_status_code,
'total_visits' => (int) $link->total_visits,
'unique_visits' => (int) $link->unique_visits,
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
'pixel_meta_id' => $link->pixel_meta_id,
'pixel_google_id' => $link->pixel_google_id,
'pixel_linkedin_id' => $link->pixel_linkedin_id,
'webhook_url' => $link->webhook_url,
'notes' => $link->notes,
'created_at' => $link->created_at->toIso8601String(),
];
}
/**
* Dispatch webhook if global or per-link webhook is active for 'created' event.
*/
private function dispatchCreatedWebhook(ShortUrl $shortUrl): void
{
$targetUrl = $shortUrl->webhook_url;
$globalUrl = config('filament-short-url.global_webhook_url');
$events = config('filament-short-url.webhook_events', []);
if (empty($targetUrl) && !empty($globalUrl) && in_array('created', $events)) {
$targetUrl = $globalUrl;
}
if (!empty($targetUrl)) {
$connection = config('filament-short-url.queue_connection', 'sync');
dispatch(new SendWebhookJob(
url: $targetUrl,
event: 'created',
payload: [
'event' => 'created',
'timestamp' => now()->toIso8601String(),
'short_url' => $this->transformLink($shortUrl),
]
)->onConnection($connection ?: 'sync'));
}
}
}

View File

@@ -141,6 +141,15 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
if (! empty($shortUrl->pixel_meta_id) || ! empty($shortUrl->pixel_google_id) || ! empty($shortUrl->pixel_linkedin_id)) {
return response(view('filament-short-url::pixel-loading', [
'destination' => $destination,
'pixelMetaId' => $shortUrl->pixel_meta_id,
'pixelGoogleId' => $shortUrl->pixel_google_id,
'pixelLinkedinId' => $shortUrl->pixel_linkedin_id,
]))->header('Content-Type', 'text/html');
}
return redirect()->away($destination, $shortUrl->redirect_status_code);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Middleware;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateShortUrlApi
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$apiKey = $request->header('X-Api-Key');
if (!$apiKey && $auth = $request->header('Authorization')) {
if (str_starts_with(strtolower($auth), 'bearer ')) {
$apiKey = substr($auth, 7);
}
}
if (empty($apiKey)) {
return response()->json([
'error' => 'Unauthorized. API Key is missing.',
], 401);
}
$mgr = app(ShortUrlSettingsManager::class);
$keys = $mgr->get('api_keys', []);
$valid = false;
foreach ($keys as $keyObj) {
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
$valid = true;
break;
}
}
if (!$valid) {
return response()->json([
'error' => 'Unauthorized. Invalid or inactive API Key.',
], 401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Bjanczak\FilamentShortUrl\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SendWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int Max retry attempts if the webhook fails */
public int $tries = 3;
/** @var int Delay between retries (seconds) */
public int $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
public readonly string $url,
public readonly string $event,
public readonly array $payload
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
])
->post($this->url, $this->payload);
if ($response->failed()) {
Log::warning("[FilamentShortUrl] Webhook delivery returned client/server error", [
'url' => $this->url,
'event' => $this->event,
'status' => $response->status(),
]);
// Throw exception to trigger queue retry
throw new \RuntimeException("Webhook failed with status " . $response->status());
}
} catch (\Throwable $e) {
Log::warning("[FilamentShortUrl] Webhook delivery failed", [
'url' => $this->url,
'event' => $this->event,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -90,6 +90,53 @@ class TrackShortUrlVisitJob implements ShouldQueue
// Fire event for user listeners
ShortUrlVisited::dispatch($shortUrl, $visit);
// Trigger Webhook if active
$targetUrl = $shortUrl->webhook_url;
$globalUrl = config('filament-short-url.global_webhook_url');
$events = config('filament-short-url.webhook_events', []);
if (empty($targetUrl) && ! empty($globalUrl) && in_array('visited', $events)) {
$targetUrl = $globalUrl;
}
if (! empty($targetUrl)) {
dispatch(new \Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob(
url: $targetUrl,
event: 'visited',
payload: [
'event' => 'visited',
'timestamp' => now()->toIso8601String(),
'short_url' => [
'id' => $shortUrl->id,
'destination_url' => $shortUrl->destination_url,
'url_key' => $shortUrl->url_key,
'short_url' => $shortUrl->getShortUrl(),
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
'unique_visits' => (int) $shortUrl->unique_visits,
],
'visit' => [
'id' => $visit->id,
'visited_at' => $visit->visited_at->toIso8601String(),
'device_type' => $visit->device_type,
'browser' => $visit->browser,
'browser_version' => $visit->browser_version,
'operating_system' => $visit->operating_system,
'operating_system_version' => $visit->operating_system_version,
'country' => $visit->country,
'country_code' => $visit->country_code,
'city' => $visit->city,
'referer_url' => $visit->referer_url,
'referer_host' => $visit->referer_host,
'utm_source' => $visit->utm_source,
'utm_medium' => $visit->utm_medium,
'utm_campaign' => $visit->utm_campaign,
'utm_term' => $visit->utm_term,
'utm_content' => $visit->utm_content,
],
]
)->onConnection($this->connection ?: 'sync'));
}
// Optional GA4 Measurement Protocol integration
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
$this->sendGa4Hit($shortUrl, $visit);

View File

@@ -80,6 +80,10 @@ class ShortUrl extends Model
'unique_visits',
'max_visits',
'expiration_redirect_url',
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
];
/** @var array<string, string> */
@@ -186,6 +190,19 @@ class ShortUrl extends Model
if ($m->activated_at === null && $m->expires_at === null) {
$m->expiration_redirect_url = null;
}
if (empty($m->pixel_meta_id)) {
$m->pixel_meta_id = null;
}
if (empty($m->pixel_google_id)) {
$m->pixel_google_id = null;
}
if (empty($m->pixel_linkedin_id)) {
$m->pixel_linkedin_id = null;
}
if (empty($m->webhook_url)) {
$m->webhook_url = null;
}
});
static::saved(function (self $m) {

View File

@@ -76,6 +76,9 @@ class ShortUrlSettingsManager
'qr_gradient_from' => config('filament-short-url.qr_defaults.gradient_from', '#4f46e5'),
'qr_gradient_to' => config('filament-short-url.qr_defaults.gradient_to', '#06b6d4'),
'qr_gradient_type' => config('filament-short-url.qr_defaults.gradient_type', 'linear'),
'global_webhook_url' => null,
'webhook_events' => ['visited'],
'api_keys' => [],
], $stored);
return $this->cache;
@@ -142,6 +145,9 @@ class ShortUrlSettingsManager
'qr_gradient_from',
'qr_gradient_to',
'qr_gradient_type',
'global_webhook_url',
'webhook_events',
'api_keys',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -292,6 +298,8 @@ class ShortUrlSettingsManager
'filament-short-url.qr_defaults.gradient_from' => $settings['qr_gradient_from'],
'filament-short-url.qr_defaults.gradient_to' => $settings['qr_gradient_to'],
'filament-short-url.qr_defaults.gradient_type' => $settings['qr_gradient_type'],
'filament-short-url.global_webhook_url' => $settings['global_webhook_url'] ?? null,
'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'],
]);
}
}