Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa05f026b5 | ||
|
|
15d992c5ad | ||
|
|
719901d4c6 |
639
README.md
639
README.md
@@ -19,6 +19,7 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
|
||||
|
||||
### Why choose Filament Short URL?
|
||||
* **Save on SaaS Costs**: Replace expensive Bitly or Rebrandly subscriptions with a self-hosted solution that has zero click or creation limits.
|
||||
* **Custom Domain Branding**: Connect your own custom domains with dynamic DNS checks and automatic routing redirection at the root level.
|
||||
* **Server-Side GA4 Tracking**: Bypasses browser-side ad blockers completely to ensure 100% accurate traffic data.
|
||||
* **Retarget on External Sites**: Inject tracking pixels (Meta, Google Ads, LinkedIn, TikTok, Pinterest) on a beautiful glassmorphic redirect page before forwarding visitors.
|
||||
* **Seamless Mobile App Redirects**: Launch native mobile applications (Instagram, YouTube, Spotify, WhatsApp) automatically using deep links.
|
||||
@@ -47,6 +48,7 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
|
||||
## Features
|
||||
|
||||
- 🔗 **Base62 Short Link Generation** — Create clean, custom short links or let the system auto-generate collision-free Base62 keys.
|
||||
- 🌐 **Custom Domain Branding (new in v4.0.0)** — Register custom domains, show dynamic DNS setup instructions (A/CNAME records), verify records in real-time, and route redirects directly at the root of verified custom domains.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — Route and analyze traffic with offline MaxMind detection, CDN edge headers (Cloudflare's `CF-IPCountry`, CloudFront), or fallback APIs.
|
||||
- 🗺️ **Interactive Visitor World Map** — Showcase geographic click distribution on a beautiful SVG world map widget with real-time hover details.
|
||||
- 📈 **Comprehensive Analytics Dashboard** — Monitor total/unique visits, referrers, operating systems, devices, browsers, and top browser languages in real-time.
|
||||
@@ -68,11 +70,15 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
|
||||
- 🛡️ **Throttling & Rate Limiting** — Protect your redirection routes from flood attacks with configurable per-IP rate limits.
|
||||
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
|
||||
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
|
||||
- 🔌 **Developer REST API** — Full programmatical control with secure API Key authentication to create, list, and delete short links externally.
|
||||
- 🔌 **Developer REST API (updated in v3.5.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for short links externally.
|
||||
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
|
||||
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
|
||||
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
|
||||
- 🎨 **Branded Expiry Pages (new in v3.0.0)** — Display a premium, dark-mode compatible custom expiry page when a link is deactivated or limit-reached, falling back to a clean site-name greeting instead of a generic browser error.
|
||||
- 👤 **User Attribution (new in v3.5.0)** — Automatically assign the authenticated user's ID to every new short URL. Show their avatar and hover card (name + email) directly in the list table.
|
||||
- 🕒 **Relative Time Badges (new in v3.5.0)** — Display compact relative timestamps (e.g., `2h`, `5d`, `3mo`) for link creation dates with rich Alpine.js hover popovers showing precise absolute dates and timezone offsets.
|
||||
- ⌨️ **Keyboard Shortcuts (new in v3.5.0)** — Hover over any table row to enable shortcut keys: `E` (edit), `Q` (QR code), `I` (share/copy), `S` (statistics), `X` (delete). Shortcuts are safely ignored when focus is inside modals or text inputs.
|
||||
- 🔘 **Unified 3-dot Action Dropdown (new in v3.5.0)** — All per-row actions are consolidated under a single icon button that opens a styled dropdown with shortcut key badges on the right.
|
||||
|
||||
---
|
||||
|
||||
@@ -244,6 +250,9 @@ The package comes with a built-in admin settings dashboard. It is accessible dir
|
||||
|
||||
Settings are stored dynamically in the database (`short_url_settings` table), cached indefinitely, and immediately override config defaults. Legacy settings from `filament-short-url-settings.json` are automatically imported on first load.
|
||||
|
||||
> [!NOTE]
|
||||
> **Modular Tab Architecture (New in v3.5.0)**: The settings form is split into 8 independent, single-responsibility tab classes (`LinkTab`, `TargetingTab`, `TrackingTab`, `SecurityTab`, `GeoIpTab`, `VpnDetectionTab`, `QrDesignTab`, `PixelsTab`) to reduce load times, improve code maintainability, and allow clean tab query parameters navigation (e.g. `?tab=qr`).
|
||||
|
||||
The settings panel allows you to configure:
|
||||
|
||||
### 1. General Routing & Queueing
|
||||
@@ -322,6 +331,26 @@ This feature is useful for NSFW links, external partner links, or any URL that l
|
||||
|
||||
---
|
||||
|
||||
## Security & Anti-Fraud v2.0 (new in v1.6.0)
|
||||
|
||||
Protect your application redirection routes and visitor data from malicious activities and automated scrapers.
|
||||
|
||||
### 1. VPN & Proxy Detection
|
||||
Filter out anonymous proxy, VPN, or Tor connections to ensure clean analytics and prevent abuse.
|
||||
* **Driver Selection**: Choose between the free **IP-API** service (default) or the premium **VPNAPI.io** service (requires setting an API key).
|
||||
* **Configurable Action**:
|
||||
* **Flag Only**: Flags VPN/Proxy visits in database statistics for inspection but allows the redirection to continue.
|
||||
* **Block Traffic**: Actively blocks the request, serving a `403 Forbidden` response to the client.
|
||||
* **Verify Key**: An interactive "Verify connection" action is available in settings to check your API credentials.
|
||||
|
||||
### 2. Google Safe Browsing URL Verification
|
||||
Scan and verify all user-provided target URLs against Google's safe browsing lookup API on creation and edit.
|
||||
* **Protection**: Blocks malware, phishing, and social engineering domains.
|
||||
* **Filament UI**: Displays a clean status badge and blocks form saving if the target URL is flagged as unsafe.
|
||||
* **Verify Key**: Includes a "Test API Connection" action on the settings dashboard to validate your Safe Browsing API credentials.
|
||||
|
||||
---
|
||||
|
||||
## Custom Branded Expiry Pages (new in v3.0.0)
|
||||
|
||||
When a short URL is expired, deactivated, or has reached its maximum visit limit, it needs to handle the redirect gracefully:
|
||||
@@ -334,86 +363,130 @@ When a short URL is expired, deactivated, or has reached its maximum visit limit
|
||||
|
||||
---
|
||||
|
||||
## Smart Link Targeting (new in v1.2.0)
|
||||
## Smart Link Targeting (updated in v3.5.0)
|
||||
|
||||
The **Targeting & Security** tab exposes a powerful rule engine that lets you route different visitors to different destinations — all from a single short URL.
|
||||
|
||||
### Available Strategies
|
||||
You can configure multiple rules evaluated sequentially from top to bottom. Each rule contains:
|
||||
- A **Target URL** (the redirect destination if rule matches).
|
||||
- A **Match Strategy**: `AND` (all filters must match) or `OR` (any filter can match).
|
||||
- A list of **Filters**:
|
||||
- **Device**: Filter by `desktop`, `mobile`, `tablet`.
|
||||
- **Platform**: Filter by `windows`, `mac`, `linux`, `ios`, `android`, `fire_os`.
|
||||
- **Country**: Filter by country codes (e.g. `PL`, `US`, `DE`) with flags display.
|
||||
- **Language**: Filter by preferred browser language codes (e.g. `pl`, `en`, `de`).
|
||||
|
||||
#### 1. Device-Based Redirects
|
||||
Route visitors to different URLs based on their device type (detected from User-Agent):
|
||||
### Multi-Filter JSON Schema (v3.3.0+)
|
||||
|
||||
| Device | Detected by User-Agent containing |
|
||||
|--------|-----------------------------------|
|
||||
| iOS (Mobile) | `iphone`, `ipad`, `ipod` |
|
||||
| Android | `android` |
|
||||
| Desktop | Everything else |
|
||||
|
||||
```php
|
||||
// Programmatic example
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'device',
|
||||
'device' => [
|
||||
'ios' => 'https://apps.apple.com/your-app',
|
||||
'android' => 'https://play.google.com/your-app',
|
||||
'desktop' => 'https://example.com/download',
|
||||
],
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
#### 2. Country-Based (Geo-IP) Redirects
|
||||
Route visitors to country-specific URLs. Requires Geo-IP to be enabled in settings. Falls back to the default `destination_url` for unlisted countries.
|
||||
For programmatic or REST API updates, pass an array of rules to `targeting_rules`:
|
||||
|
||||
```php
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'geo',
|
||||
'geo' => [
|
||||
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['country_code' => 'US', 'url' => 'https://us.example.com'],
|
||||
['country_code' => 'DE', 'url' => 'https://de.example.com'],
|
||||
[
|
||||
'match' => 'and',
|
||||
'url' => 'https://ios-pl.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'platform',
|
||||
'data' => ['platforms' => ['ios']]
|
||||
],
|
||||
[
|
||||
'type' => 'language',
|
||||
'data' => ['languages' => ['pl']]
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://fallback-mobile.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile', 'tablet']]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
```
|
||||
|
||||
#### 3. Browser Language-Based Redirects (new in v2.0.5)
|
||||
Route visitors to language-specific URLs based on their browser's language preferences (detected from the `Accept-Language` header). Fully supports matching exact regional locales (e.g., `en-US`, `en-GB`) with automatic fallback to base language codes (e.g., `en`, `pl`).
|
||||
### Supported Filter Options & Formats
|
||||
|
||||
```php
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'language',
|
||||
'language' => [
|
||||
['language_code' => 'pl', 'url' => 'https://pl.example.com'],
|
||||
['language_code' => 'en-US', 'url' => 'https://us.example.com'],
|
||||
['language_code' => 'de', 'url' => 'https://de.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
```
|
||||
| Filter Type | Data Key | Allowed Values |
|
||||
|-------------|----------|----------------|
|
||||
| `device` | `devices` | `desktop`, `mobile`, `tablet` |
|
||||
| `platform` | `platforms` | `windows`, `mac`, `linux`, `ios`, `android`, `fire_os` |
|
||||
| `country` | `countries` | ISO-3166 2-letter country codes (e.g. `PL`, `US`, `DE`), case-insensitive |
|
||||
| `language` | `languages` | ISO-639-1 language codes (e.g. `pl`, `en`, `de`), case-insensitive |
|
||||
|
||||
#### 4. A/B Split Rotation
|
||||
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
|
||||
### Legacy Strategies (v1.2.0 - v2.x)
|
||||
|
||||
```php
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'rotation',
|
||||
'rotation' => [
|
||||
['url' => 'https://variant-a.example.com', 'weight' => 70],
|
||||
['url' => 'https://variant-b.example.com', 'weight' => 30],
|
||||
],
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
> All targeting strategies fall back gracefully to the link's primary `destination_url` if no rule matches.
|
||||
If your database contains legacy single-strategy rules (e.g. `'type' => 'device'` or `'type' => 'geo'`), the plugin handles them automatically:
|
||||
* **Redirection Engine**: The redirect system detects the legacy structure and processes it on-the-fly using the legacy strategy.
|
||||
* **Filament UI**: When loading a link with legacy rules, the Filament Form automatically upgrades and hydrates them to equivalent new multi-filter rules.
|
||||
|
||||
---
|
||||
|
||||
## A/B Split Testing & Weighted Traffic Rotation (new in v3.5.0)
|
||||
|
||||
A single short URL can distribute traffic across 2–5 landing pages using configurable weights. This is useful for comparing conversion rates between different pages without changing the link you've already shared.
|
||||
|
||||
### Two ways to set it up
|
||||
|
||||
**Root-level split test** — Set the **Destination Type** to `A/B Split Test` directly on the short URL. All visitors hitting that link will be distributed across your variants.
|
||||
|
||||
**Nested inside a targeting rule** — Combine split testing with audience targeting. For example: send mobile visitors from Poland into a 50/50 test, while everyone else goes to a single fallback URL.
|
||||
|
||||
### Weights
|
||||
|
||||
Each variant gets a percentage weight. Weights must sum to exactly **100%** and can be adjusted in 1% increments. The admin form shows an interactive split bar — drag the handles or hit **"Balance weights"** to divide traffic evenly. When you add or remove a variant, weights are rebalanced automatically.
|
||||
|
||||
UTM parameters and other query strings on the original click are forwarded to whichever variant is selected.
|
||||
|
||||
### Analytics
|
||||
|
||||
Every visit records which variant was resolved (`selected_variant` column in the visit log). The link's statistics dashboard shows a **Variant Clicks Distribution** bar chart so you can compare actual click shares against your configured weights.
|
||||
|
||||
### REST API
|
||||
|
||||
For root-level split tests, pass `rotation_variants` in the request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"destination_type": "split",
|
||||
"rotation_variants": [
|
||||
{ "label": "Variant A", "url": "https://example.com/page-a", "weight": 70 },
|
||||
{ "label": "Variant B", "url": "https://example.com/page-b", "weight": 30 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To nest a split test inside a targeting rule, use `variants` within the rule object:
|
||||
|
||||
```json
|
||||
{
|
||||
"destination_type": "single",
|
||||
"destination_url": "https://example.com/default",
|
||||
"targeting_rules": [
|
||||
{
|
||||
"match": "or",
|
||||
"destination_type": "split",
|
||||
"variants": [
|
||||
{ "label": "Mobile A", "url": "https://example.com/mob-a", "weight": 50 },
|
||||
{ "label": "Mobile B", "url": "https://example.com/mob-b", "weight": 50 }
|
||||
],
|
||||
"filters": [
|
||||
{ "type": "device", "data": { "devices": ["mobile"] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Native App Linking & Deep Linking (new in v3.0.0)
|
||||
|
||||
This package supports two distinct levels of mobile app integration: **Per-Link App Linking** (client-side redirects using custom schemes) and **Global Deep Linking Files** (domain association files for OS-level native integration).
|
||||
@@ -583,8 +656,133 @@ $shortUrl->pixels()->sync([$pixelId1, $pixelId2]);
|
||||
|
||||
---
|
||||
|
||||
## Custom Domain Branding (new in v4.0.0)
|
||||
|
||||
Branded links (e.g. `go.company.com/abc123` instead of `yourdomain.com/s/abc123`) significantly increase click-through rates and build trust. This package includes a full-featured custom domain manager out-of-the-box.
|
||||
|
||||
### Key Features
|
||||
1. **Dynamic CNAME/A Record Verification**: Resolves DNS records in real-time natively using PHP's `dns_get_record()` (with fallback to `dig`) and matches against the host server IP — supports both CNAME and A record setups.
|
||||
2. **DNS Setup Instructions**: Step-by-step instructions for non-technical users to connect their domain in their registrar (GoDaddy, Cloudflare, Namecheap, etc.) directly in the Filament UI.
|
||||
3. **Verified-Only Assignment**: Only domains that pass DNS verification can be associated with short URLs.
|
||||
4. **Root-Level Redirections**: When a visitor hits a custom domain link (e.g. `go.company.com/abc123`), the request is matched directly at the root path — omitting the default `/s/` prefix automatically.
|
||||
|
||||
### How to use
|
||||
1. Go to the **Custom Domains** page in the Filament admin panel (sidebar → Links → Custom Domains).
|
||||
2. Click **Create** and enter your domain name (e.g. `go.company.com`).
|
||||
3. Follow the DNS Setup instructions shown in the modal to point your DNS records to the application server.
|
||||
4. Click **Setup DNS / Verify** inside the table actions. Once DNS resolves correctly, the domain status changes to **Active**.
|
||||
5. In the Short URL creation form, select your custom domain from the **Custom Domain** dropdown. The generated short URL will use your branded domain automatically.
|
||||
|
||||
### DNS Setup (for users)
|
||||
|
||||
You need to add **one** of the following records at your DNS provider:
|
||||
|
||||
| Type | Name | Value |
|
||||
|-------|--------------------|--------------------------------|
|
||||
| CNAME | `go` (subdomain) | `yourdomain.com.` (main host) |
|
||||
| A | `go` (subdomain) | `123.45.67.89` (server IP) |
|
||||
|
||||
The plugin dynamically resolves the host application's IP at verification time — no hardcoded IP configuration is required.
|
||||
|
||||
### Web Server Configuration (required)
|
||||
|
||||
Custom domains must be configured at the web server level to forward requests to your Laravel application. Below are example configurations:
|
||||
|
||||
#### nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name go.company.com;
|
||||
|
||||
# Proxy to your main Laravel app
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:80; # or your app server socket
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or, if the custom domain points directly to the same server running Laravel:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name go.company.com;
|
||||
|
||||
root /var/www/your-app/public;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy
|
||||
|
||||
```caddyfile
|
||||
go.company.com {
|
||||
root * /var/www/your-app/public
|
||||
php_fastcgi unix//run/php/php8.3-fpm.sock
|
||||
file_server
|
||||
}
|
||||
```
|
||||
|
||||
#### Apache
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName go.company.com
|
||||
DocumentRoot /var/www/your-app/public
|
||||
|
||||
<Directory /var/www/your-app/public>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
> **Note:** The Laravel application automatically detects the incoming `Host` header and routes the request to the correct short URL via the fallback route. No additional application configuration is needed beyond registering the domain in the Filament UI.
|
||||
|
||||
### Server Requirements
|
||||
|
||||
- DNS verification runs natively using PHP's built-in `dns_get_record()` function.
|
||||
- If native DNS functions are disabled or fail, the plugin falls back to using the `dig` CLI tool (`apt install dnsutils` on Debian/Ubuntu, `yum install bind-utils` on CentOS/RHEL).
|
||||
- If neither is available, the plugin falls back to `$_SERVER['SERVER_ADDR']` for A record matching.
|
||||
- The DNS verification runs synchronously in the admin panel when the **Verify** button is clicked. For production use with many domains, consider wrapping it in a queued job.
|
||||
|
||||
### Database Schema
|
||||
|
||||
Custom domains are stored in the `short_url_custom_domains` table:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------------|-----------|---------------------------------------------------|
|
||||
| `id` | bigint | Primary key |
|
||||
| `user_id` | bigint | Owner (nullable, references `users.id`) |
|
||||
| `domain` | string | The domain name (e.g. `go.company.com`) |
|
||||
| `is_verified`| boolean | Whether DNS verification passed |
|
||||
| `is_active` | boolean | Whether the domain is enabled for use |
|
||||
| `created_at` | timestamp | Creation timestamp |
|
||||
| `updated_at` | timestamp | Last update timestamp |
|
||||
|
||||
Short URLs reference their custom domain via `custom_domain_id` (foreign key on `short_urls`).
|
||||
|
||||
---
|
||||
|
||||
## Developer REST API (new in v1.5.0)
|
||||
|
||||
|
||||
The plugin exposes a REST API that allows external systems (CRMs, Zapier, Make, custom integrations) to manage short URLs programmatically.
|
||||
|
||||
### Enabling the API
|
||||
@@ -611,6 +809,8 @@ For security, new API keys are hashed using SHA-256 and stored securely in the d
|
||||
|
||||
### Endpoints
|
||||
|
||||
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
|
||||
|
||||
#### `GET /api/short-url/links`
|
||||
List all short URLs (paginated, 30 per page).
|
||||
|
||||
@@ -639,7 +839,16 @@ curl https://yourdomain.com/api/short-url/links \
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"auto_open_app_mobile": false,
|
||||
"ga_tracking_id": null,
|
||||
"track_visits": true,
|
||||
"track_ip_address": true,
|
||||
"track_browser": true,
|
||||
"track_browser_version": true,
|
||||
"track_operating_system": true,
|
||||
"track_operating_system_version": true,
|
||||
"track_device_type": true,
|
||||
"track_referer_url": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [],
|
||||
"notes": null,
|
||||
@@ -655,6 +864,51 @@ curl https://yourdomain.com/api/short-url/links \
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/short-url/links/{idOrKey}`
|
||||
Retrieve details for a single short URL using either its database `id` or short `url_key`.
|
||||
|
||||
```bash
|
||||
curl https://yourdomain.com/api/short-url/links/abc123 \
|
||||
-H "X-Api-Key: sh_key_your_key_here"
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"destination_url": "https://example.com",
|
||||
"url_key": "abc123",
|
||||
"short_url": "https://yourdomain.com/s/abc123",
|
||||
"is_enabled": true,
|
||||
"redirect_status_code": 302,
|
||||
"total_visits": 47,
|
||||
"unique_visits": 31,
|
||||
"max_visits": null,
|
||||
"activated_at": null,
|
||||
"expires_at": null,
|
||||
"webhook_url": null,
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"auto_open_app_mobile": false,
|
||||
"ga_tracking_id": null,
|
||||
"track_visits": true,
|
||||
"track_ip_address": true,
|
||||
"track_browser": true,
|
||||
"track_browser_version": true,
|
||||
"track_operating_system": true,
|
||||
"track_operating_system_version": true,
|
||||
"track_device_type": true,
|
||||
"track_referer_url": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [],
|
||||
"notes": null,
|
||||
"created_at": "2026-06-01T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/short-url/links`
|
||||
Create a new short URL.
|
||||
|
||||
@@ -673,36 +927,60 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
}'
|
||||
```
|
||||
|
||||
**Accepted fields:**
|
||||
#### `PUT/PATCH /api/short-url/links/{idOrKey}`
|
||||
Update an existing short URL (resolved dynamically by either database `id` or short `url_key`).
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
```bash
|
||||
curl -X PATCH https://yourdomain.com/api/short-url/links/promo26 \
|
||||
-H "X-Api-Key: sh_key_your_key_here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"destination_url": "https://example.com/new-product-page",
|
||||
"notes": "Updated summer campaign description",
|
||||
"is_enabled": true
|
||||
}'
|
||||
```
|
||||
|
||||
**Accepted Fields (for POST & PUT/PATCH requests):**
|
||||
|
||||
| Field | Type | Required (POST) | Description |
|
||||
|---|---|---|---|
|
||||
| `destination_url` | string (URL) | ✅ | Target URL |
|
||||
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
|
||||
| `notes` | string | ❌ | Internal notes |
|
||||
| `destination_url` | string (URL) | ✅ | Target URL (max 2048 chars) |
|
||||
| `custom_domain_id` | integer | ❌ | ID of a verified custom domain record |
|
||||
| `url_key` | string | ❌ | Custom unique slug/key (max 32 chars, alpha-dash; auto-generated if omitted) |
|
||||
| `notes` | string | ❌ | Internal admin notes (max 255 chars) |
|
||||
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
|
||||
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect code |
|
||||
| `single_use` | boolean | ❌ | Expire after first click |
|
||||
| `forward_query_params` | boolean | ❌ | Forward query string to destination |
|
||||
| `max_visits` | integer | ❌ | Click limit before expiry |
|
||||
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
|
||||
| `activated_at` | datetime | ❌ | Activation timestamp |
|
||||
| `expires_at` | datetime | ❌ | Expiration timestamp |
|
||||
| `pixels` | array of integers | ❌ | List of pixel registry IDs to associate with the link |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
|
||||
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
|
||||
| `password` | string | ❌ | Access protection password |
|
||||
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
|
||||
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
|
||||
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
|
||||
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect status code |
|
||||
| `single_use` | boolean | ❌ | Expire the short link immediately after the first visit |
|
||||
| `forward_query_params` | boolean | ❌ | Forward visitor query parameters to target destination |
|
||||
| `max_visits` | integer | ❌ | Maximum click threshold limit |
|
||||
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL to redirect to upon link expiration (max 255 chars) |
|
||||
| `activated_at` | datetime | ❌ | Activation timestamp (must be after or equal to today) |
|
||||
| `expires_at` | datetime | ❌ | Expiration timestamp (must be after or equal to `activated_at`) |
|
||||
| `pixels` | array of integers | ❌ | List of registered retargeting pixel IDs to associate with the link |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook URL for immediate event notifications |
|
||||
| `targeting_rules` | array | ❌ | Advanced Multi-Filter Targeting Rules JSON schema (see [Smart Link Targeting](#smart-link-targeting-updated-in-v340) for schema details) |
|
||||
| `password` | string | ❌ | Password to protect the short URL |
|
||||
| `show_warning_page` | boolean | ❌ | Toggle redirect warning interstitial page |
|
||||
| `auto_open_app_mobile` | boolean | ❌ | Auto open deep link in native application on mobile devices |
|
||||
| `ga_tracking_id` | string | ❌ | Custom Google Analytics 4 Measurement ID for this link (`G-XXXXXXXXXX`, max 50 chars) |
|
||||
| `track_visits` | boolean | ❌ | Toggle tracking/analytics logging for this link (default: `true`) |
|
||||
| `track_ip_address` | boolean | ❌ | Track client IP address (default: `true`) |
|
||||
| `track_browser` | boolean | ❌ | Track client browser name (default: `true`) |
|
||||
| `track_browser_version` | boolean | ❌ | Track client browser version (default: `true`) |
|
||||
| `track_operating_system` | boolean | ❌ | Track client OS (default: `true`) |
|
||||
| `track_operating_system_version` | boolean | ❌ | Track client OS version (default: `true`) |
|
||||
| `track_device_type` | boolean | ❌ | Track client device type (default: `true`) |
|
||||
| `track_referer_url` | boolean | ❌ | Track visitor referrer URL (default: `true`) |
|
||||
| `track_browser_language` | boolean | ❌ | Track visitor preferred browser language (default: `true`) |
|
||||
|
||||
**Response:** `201 Created` with a wrapper message and the created link data:
|
||||
**Response (POST & PUT/PATCH success):** `200 OK` (or `201 Created` for POST) containing:
|
||||
```json
|
||||
{
|
||||
"message": "Short URL created successfully.",
|
||||
"message": "Short URL updated successfully.",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"destination_url": "https://example.com/product",
|
||||
"destination_url": "https://example.com/new-product-page",
|
||||
"url_key": "promo26",
|
||||
"short_url": "https://yourdomain.com/s/promo26",
|
||||
"is_enabled": true,
|
||||
@@ -716,41 +994,105 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"auto_open_app_mobile": false,
|
||||
"ga_tracking_id": null,
|
||||
"track_visits": true,
|
||||
"track_ip_address": true,
|
||||
"track_browser": true,
|
||||
"track_browser_version": true,
|
||||
"track_operating_system": true,
|
||||
"track_operating_system_version": true,
|
||||
"track_device_type": true,
|
||||
"track_referer_url": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Meta Pixel (1234567890)",
|
||||
"type": "meta",
|
||||
"pixel_id": "1234567890",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Google Tag (G-XXXXXXXXXX)",
|
||||
"type": "google",
|
||||
"pixel_id": "G-XXXXXXXXXX",
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"notes": "Summer campaign",
|
||||
"pixels": [],
|
||||
"notes": "Updated summer campaign description",
|
||||
"created_at": "2026-06-04T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/short-url/links/{id}`
|
||||
Permanently delete a short URL by its ID.
|
||||
#### `GET /api/short-url/links/{idOrKey}/stats`
|
||||
Retrieve visit analytics statistics for a single short URL.
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
|
||||
curl https://yourdomain.com/api/short-url/links/promo26/stats \
|
||||
-H "X-Api-Key: sh_key_your_key_here"
|
||||
```
|
||||
|
||||
**Optional Query Parameters:**
|
||||
* `date_from` (string): Filter stats starting from date (e.g. `YYYY-MM-DD`).
|
||||
* `date_to` (string): Filter stats up to date (e.g. `YYYY-MM-DD`).
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"totalVisits": 156,
|
||||
"uniqueVisits": 98,
|
||||
"visitsToday": 14,
|
||||
"visitsThisWeek": 114,
|
||||
"visitsThisMonth": 156,
|
||||
"visitsByDay": {
|
||||
"2026-06-01": 42,
|
||||
"2026-06-02": 50
|
||||
},
|
||||
"visitsByCountry": {
|
||||
"Poland": 100,
|
||||
"United States": 50
|
||||
},
|
||||
"visitsByCity": {
|
||||
"Warsaw (PL)": 80,
|
||||
"Krakow (PL)": 20
|
||||
},
|
||||
"visitsByDevice": {
|
||||
"desktop": 90,
|
||||
"mobile": 66
|
||||
},
|
||||
"visitsByBrowser": {
|
||||
"Chrome": 110,
|
||||
"Safari": 46
|
||||
},
|
||||
"visitsByOs": {
|
||||
"Windows": 70,
|
||||
"macOS": 60
|
||||
},
|
||||
"visitsByReferer": {
|
||||
"linkedin.com": 80,
|
||||
"twitter.com": 40
|
||||
},
|
||||
"utmSources": {
|
||||
"linkedin": 80,
|
||||
"twitter": 40
|
||||
},
|
||||
"utmMediums": {
|
||||
"social": 120
|
||||
},
|
||||
"utmCampaigns": {
|
||||
"spring_sale": 120
|
||||
},
|
||||
"qrScans": 12,
|
||||
"visitsByLanguage": {
|
||||
"pl": 100,
|
||||
"en": 56
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/short-url/links/{idOrKey}`
|
||||
Permanently delete a short URL by its database `id` or short `url_key`.
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://yourdomain.com/api/short-url/links/promo26 \
|
||||
-H "X-Api-Key: sh_key_your_key_here"
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{ "message": "Short URL deleted successfully." }
|
||||
{
|
||||
"message": "Short URL deleted successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
@@ -758,6 +1100,7 @@ curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
|
||||
| HTTP Code | Reason |
|
||||
|---|---|
|
||||
| `401 Unauthorized` | Missing or invalid API key |
|
||||
| `404 Not Found` | Short URL not found |
|
||||
| `422 Unprocessable Entity` | Validation error (see `errors` field in response) |
|
||||
| `503 Service Unavailable` | REST API is disabled in Settings |
|
||||
|
||||
@@ -856,11 +1199,42 @@ You can also pre-configure all parameters via your `.env` file:
|
||||
| Environment Variable | Config Path | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
|
||||
| `SHORT_URL_SITE_NAME` | `site_name` | `null` | Brand/Site name override for warning/interstitial pages. |
|
||||
|
||||
### User Integration Configuration
|
||||
|
||||
The `user` configuration block (new in v3.5.0) connects the package to your application's `User` model so that creator avatars and details are displayed in the admin table:
|
||||
|
||||
```php
|
||||
// config/filament-short-url.php
|
||||
'user' => [
|
||||
'model' => \App\Models\User::class,
|
||||
'name_column' => 'name',
|
||||
'email_column' => 'email',
|
||||
'avatar_column' => 'avatar_url', // attribute name, method name, or null to auto-detect
|
||||
],
|
||||
```
|
||||
|
||||
**Avatar resolution order:**
|
||||
1. Call `$user->{avatar_column}()` if it is a method on the model.
|
||||
2. Read `$user->{avatar_column}` as a direct attribute.
|
||||
3. Call `$user->getFilamentAvatarUrl()` if the model implements `Filament\Models\Contracts\HasAvatar`.
|
||||
4. Fall back to a Gravatar URL derived from the user's email.
|
||||
|
||||
Set `avatar_column` to `null` to skip steps 1–2 and start from the `HasAvatar` interface check.
|
||||
|
||||
The package automatically attaches the currently authenticated user's ID to every newly created short URL via a model `creating` event on `ShortUrl`.
|
||||
|
||||
### Full .env Reference
|
||||
|
||||
| Environment Variable | Config Path | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SHORT_URL_GEO_IP` | `geo_ip.enabled` | `true` | Globally enable/disable Geo-IP tracking. |
|
||||
| `SHORT_URL_GEO_IP_DRIVER` | `geo_ip.driver` | `'headers'` | Geo-IP resolver driver (`headers`, `maxmind`, `ip-api`). |
|
||||
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `storage_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
|
||||
| `SHORT_URL_STATS_CACHE_TTL` | `geo_ip.stats_cache_ttl` | `300` | Caching TTL in seconds for dashboard charts. |
|
||||
| `SHORT_URL_QUEUE` | `queue_connection` | `'sync'` | Queue connection for recording visits. |
|
||||
| `SHORT_URL_QUEUE_NAME` | `queue_name` | `'default'` | Queue name to which visit tracking jobs are dispatched. |
|
||||
| `SHORT_URL_CACHE_TTL` | `cache_ttl` | `3600` | Redirection model caching TTL (set to `0` to disable). |
|
||||
| `GA4_API_SECRET` | `ga4.api_secret` | `null` | Google Analytics 4 Measurement Protocol API Secret. |
|
||||
| `FIREBASE_APP_ID` | `ga4.firebase_app_id` | `null` | Google Analytics 4 Firebase App ID (or uses Measurement ID). |
|
||||
@@ -871,6 +1245,11 @@ You can also pre-configure all parameters via your `.env` file:
|
||||
| `SHORT_URL_RATE_LIMITING` | `rate_limiting.enabled` | `false` | Enable per-IP redirect rate limiting. |
|
||||
| `SHORT_URL_RATE_LIMIT_MAX` | `rate_limiting.max_attempts` | `60` | Max redirect requests within the decay window. |
|
||||
| `SHORT_URL_RATE_LIMIT_DECAY` | `rate_limiting.decay_seconds` | `60` | Rate limiter rolling window in seconds. |
|
||||
| `SHORT_URL_DEEP_LINKING_ENABLED` | `deep_linking.enabled` | `false` | Enable serving domain association files for deep linking. |
|
||||
| `SHORT_URL_ENABLE_FALLBACK` | `enable_fallback_route` | `true` | Toggle registration of the global fallback redirection route. |
|
||||
|
||||
> [!TIP]
|
||||
> **Database Configuration Preferred**: Avoid configuring large, multi-line JSON blocks (such as `apple-app-site-association` and `assetlinks.json`) via `.env` file environment variables as it is error-prone and can cause parsing issues. The recommended approach is to configure them dynamically via the **Filament Settings Panel**, which stores them securely in the database with real-time JSON validation.
|
||||
|
||||
---
|
||||
|
||||
@@ -1015,6 +1394,42 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v4.0.0
|
||||
- **Custom Domains Branding** — Let users connect branded custom domains with real-time CNAME/A record DNS verification and automatic prefix-free root routing.
|
||||
- **Cache Leak Fix** — Solved a critical caching bug where single-use links could be visited multiple times during the cache TTL by passing the host-specific key suffix to cache invalidations.
|
||||
- **Aggregator Performance Tuning** — Restructured daily aggregation SQL queries to filter only active URL IDs, leveraging composite indexes and running native DB aggregations.
|
||||
- **Logo Upload Authorization** — Secured the logo upload API with resource-level permission and policy checks.
|
||||
- **DB-Free Registration Phase** — Deferred settings overrides to the service provider boot phase to prevent database errors during offline configuration caching.
|
||||
- **Conditional Redirection Fallback** — Added configurations to optionally disable global fallback route registration.
|
||||
|
||||
### v3.5.0
|
||||
- **User Attribution** — Short URLs now record the creator. Their avatar and hover card (name + email) are displayed in the list table.
|
||||
- **Relative Date Badges** — Creation dates show compact relative strings (`2h`, `5d`, `3mo`). Hover to see the precise date and timezone.
|
||||
- **Keyboard Shortcuts** — Hover any table row and press `E` (edit), `Q` (QR code), `I` (share), `S` (statistics), or `X` (delete).
|
||||
- **Unified Action Dropdown** — All per-row actions consolidated under a single 3-dot button with keyboard shortcut badges.
|
||||
- **Row Click → Statistics** — Clicking a table row navigates directly to the link's Statistics page.
|
||||
- **A/B Split Testing** — Weighted traffic rotation in root-level links and nested targeting rules, with drag-based sliders and a one-click "Balance weights" action.
|
||||
- **Variant Analytics** — Tracks which A/B variant was served and shows a "Variant Clicks Distribution" chart in the analytics dashboard.
|
||||
- **Query Optimization** — Composite DB index on the visits table; queries filter by indexed `country_code`; Eloquent hydration bypassed with `toBase()->get()` for large sets.
|
||||
- **GDPR IP Anonymization** — Opt-in IP masking (IPv4/IPv6) with SHA-256 hashing for unique visitor identification, managed via Settings.
|
||||
- **Google Safe Browsing in REST API** — Safety checks now apply to all incoming URLs in the API (destination, A/B variants, targeting rules).
|
||||
- **Settings URL Cleanup** — Tab query parameters are now human-readable (e.g. `?tab=qr` instead of `?tab=qr-defaults::data::tab`).
|
||||
- **Analytics Enhancements** — QR Scan Conversion Rate card with period-over-period delta, browser/OS version subtexts, A/B click-share vs. weight comparison.
|
||||
|
||||
### v3.3.0
|
||||
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
|
||||
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
|
||||
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
|
||||
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
|
||||
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
|
||||
|
||||
### v3.2.0
|
||||
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
|
||||
- **REST API Throttling & Rate Limiting** — Configured built-in route middleware to rate limit the Developer REST API to 60 requests per minute (`throttle:60,1`) to protect from abuse.
|
||||
- **Unified Validation System** — Cleaned up and unified API and admin panel form validation, supporting advanced fields like granular logging parameters (`track_visits`, `track_browser_language`, etc.), custom GA4 tracking IDs (`ga_tracking_id`), and auto-open deep linking options (`auto_open_app_mobile`).
|
||||
- **Media Controller Separation** — Refactored internal logo uploads and serving routes out of the public REST API controller into a dedicated `ShortUrlLogoController` (Single Responsibility compliance).
|
||||
- **Alpine.js Webhook Payload Preview** — Replaced the Filament package CodeEditor component with a custom, high-reliability dark-mode HTML/CSS component featuring live code highlighting and copy-to-clipboard functionality powered by Alpine.js.
|
||||
|
||||
### v3.1.0
|
||||
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
|
||||
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.
|
||||
@@ -1031,7 +1446,6 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
|
||||
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
|
||||
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
|
||||
- **Full Localization & WhatsApp Favicon Fix** — Added friendly translation strings in English and Polish across the entire app-linking preview and redirect interfaces, and adjusted domains order to restore the WhatsApp favicon.
|
||||
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
|
||||
|
||||
### v2.0.0
|
||||
@@ -1039,9 +1453,7 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
- **Dedicated QR Code Scan Tracking** — Differentiates visitor clicks from physical QR code scans by dynamically appending source tags (`?source=qr`). Added a new database tracking column (`is_qr_scan` on visits, `qr_scans` on short URLs, and `qr_visits_count` on daily stats). Displays a dedicated scans counter badge in the Filament list table.
|
||||
- **Browser Language Detection & Statistics** — Captures visitor browser preferred language headers (`browser_language` field) and aggregates them into the daily stats table. Displays a new "Top Languages" widget breakdown in the link statistics dashboard.
|
||||
- **High-Traffic Performance Safeguards & Robust Rollbacks** — Atomic database transactions for buffered counter updates with fail-safe rollback that restores cache values in case of DB connection failures. Prevents N+1 queries by preloading request-wide counters in a single batch cache lookup.
|
||||
- **Early Boot & Test Container Safety** — Safe settings caching that checks app container state before resolving `cache`, preventing early boot container exceptions during tests or artisan boots.
|
||||
- **Atomic Duplicate Visit Caching** — Bypasses database exists checks for duplicate visitors by utilizing atomic `cache()->add` keys to prevent database bottlenecking.
|
||||
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`). This allows clean root-level redirection domains. Added defensive slash trimming to standard prefixes.
|
||||
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`).
|
||||
|
||||
### v1.7.0
|
||||
- **Role-based Settings Access Control** — New `authorizeSettingsUsing(Closure)` method on the plugin to restrict who can access the Settings page. Supports any callable returning a `bool`. Also auto-detects a `manageSettings` method on a registered `ShortUrl` policy. The Settings button in the table header is hidden automatically when access is denied.
|
||||
@@ -1050,10 +1462,9 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
|
||||
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
|
||||
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
|
||||
- **Enhanced Caching & Chart Formatting** — Improved analytics caching for high volumes, and clean `d.m` date formatting (removed year) on visitor stats charts.
|
||||
|
||||
### v1.5.1
|
||||
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled. Toggle takes effect immediately without route cache clearing.
|
||||
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled.
|
||||
|
||||
### v1.5.0
|
||||
- **Social Retargeting Pixels** — Attach Meta Pixel, Google Tag (GA4/Ads), and LinkedIn Insight Tag to any short URL. A premium glassmorphic interstitial page executes pixel scripts in the visitor's browser before forwarding them to the destination. Enables building remarketing audiences even on external domains.
|
||||
@@ -1065,16 +1476,12 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active.
|
||||
- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits).
|
||||
- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page.
|
||||
- **Reactive Validity Controls** — Master switch to toggle date limits, including bidirectional datetime picker constraints (Active From cannot exceed Expires At and vice versa) and custom UI field visibility.
|
||||
- **Smart Model Observers** — Automatic cleanup of unused parameters (like clearing `max_visits` if `single_use` is enabled, and clearing expiration fallbacks if date limits are off) to guarantee database consistency.
|
||||
- **Fluent Builder APIs** — Fluent method additions (`activatedAt()`, `deactivatedAt()`, `maxVisits()`, `expirationRedirectUrl()`) in the developer query builder.
|
||||
|
||||
### v1.3.0
|
||||
- **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles).
|
||||
- **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths.
|
||||
- **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments.
|
||||
- **Filament v5 Notification API** — Seamless integration of the new `FilamentNotification` class API inside client-side JS.
|
||||
- **Asset Compilation Guide** — Explains Tailwind CLI CSS compilation and Filament asset publishing workflows for package extensions.
|
||||
|
||||
### v1.2.0
|
||||
- **Password-protected links** — Session-based unlock flow with a styled prompt page.
|
||||
@@ -1082,8 +1489,6 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
- **Smart targeting** — Device-based, Country/Geo-based, and A/B weighted rotation rules per link.
|
||||
- **Rate limiting** — Configurable per-IP redirect throttling with 429 responses.
|
||||
- **Daily stats aggregation** — Nightly `short-url:aggregate-and-prune` command for scalable log management.
|
||||
- **Counter buffering fallback** — `IncrementVisitJob` as queue-based fallback when Redis is unavailable.
|
||||
- **Database compatibility** — Replaced `ENUM` with `VARCHAR`, removed MySQL-only `->after()` calls.
|
||||
- **Extended Settings GUI** — New "Performance & Security" tab for aggregation and rate limiting configuration.
|
||||
- **Polish translations** — Full `pl` locale support for all new features.
|
||||
|
||||
|
||||
@@ -42,6 +42,24 @@ return [
|
||||
*/
|
||||
'key_length' => 6,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lock URL Key
|
||||
|--------------------------------------------------------------------------
|
||||
| Globally disable changing the short key after a link is created.
|
||||
|
|
||||
*/
|
||||
'lock_url_key' => env('SHORT_URL_LOCK_KEY', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Disable Default Domain
|
||||
|--------------------------------------------------------------------------
|
||||
| Disable the default app domain for short links.
|
||||
|
|
||||
*/
|
||||
'disable_default_domain' => env('SHORT_URL_DISABLE_DEFAULT_DOMAIN', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Geo-IP Settings
|
||||
@@ -70,6 +88,7 @@ return [
|
||||
*/
|
||||
'tracking' => [
|
||||
'enabled' => true,
|
||||
'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false),
|
||||
'fields' => [
|
||||
'ip_address' => true,
|
||||
'browser' => true,
|
||||
@@ -204,6 +223,15 @@ return [
|
||||
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enable Fallback Route
|
||||
|--------------------------------------------------------------------------
|
||||
| Register a fallback route to catch redirects for custom domains.
|
||||
| Set to false if you wish to define custom domain routing manually.
|
||||
*/
|
||||
'enable_fallback_route' => env('SHORT_URL_ENABLE_FALLBACK', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Route Middleware
|
||||
@@ -226,37 +254,22 @@ return [
|
||||
*/
|
||||
'deep_linking' => [
|
||||
'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false),
|
||||
'aasa_json' => env('SHORT_URL_AASA_JSON', <<<'JSON'
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
|
||||
"paths": [
|
||||
"/s/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON),
|
||||
'assetlinks_json' => env('SHORT_URL_ASSETLINKS_JSON', <<<'JSON'
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.yourcompany.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"14:6D:E9:..."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
JSON),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Integration Settings
|
||||
|--------------------------------------------------------------------------
|
||||
| Configure how the package interacts with the User model for showing avatars
|
||||
| and user details (name, email) in the Filament admin panel.
|
||||
|
|
||||
*/
|
||||
'user' => [
|
||||
'model' => \App\Models\User::class,
|
||||
'name_column' => 'name',
|
||||
'email_column' => 'email',
|
||||
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -6,15 +6,21 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_settings', function (Blueprint $table): void {
|
||||
Schema::create('short_url_settings', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->longText('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_settings');
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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::create('short_url_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
|
||||
$table->string('pixel_id', 100);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['type', 'pixel_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_pixels');
|
||||
}
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_visits', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('short_url_id')
|
||||
->constrained('short_urls')
|
||||
->cascadeOnDelete();
|
||||
|
||||
// Visitor fingerprint
|
||||
$table->ipAddress('ip_address')->nullable();
|
||||
$table->string('ip_hash', 64)->nullable()->index(); // SHA-256 of IP for unique counting
|
||||
|
||||
// Browser detection
|
||||
$table->string('browser', 100)->nullable();
|
||||
$table->string('browser_version', 50)->nullable();
|
||||
|
||||
// OS detection
|
||||
$table->string('operating_system', 100)->nullable();
|
||||
$table->string('operating_system_version', 50)->nullable();
|
||||
|
||||
// Device classification ('desktop', 'mobile', 'tablet', 'robot') — validated at PHP level
|
||||
$table->string('device_type', 20)->nullable()->index();
|
||||
|
||||
// Traffic source
|
||||
$table->text('referer_url')->nullable();
|
||||
|
||||
// Geo-location
|
||||
$table->string('country', 100)->nullable()->index();
|
||||
$table->char('country_code', 2)->nullable()->index();
|
||||
|
||||
$table->timestamp('visited_at')->useCurrent()->index();
|
||||
|
||||
// Composite indexes for performance on millions of visits
|
||||
$table->index(['short_url_id', 'ip_hash']);
|
||||
$table->index(['short_url_id', 'visited_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_visits');
|
||||
}
|
||||
};
|
||||
@@ -6,13 +6,18 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_urls', function (Blueprint $table): void {
|
||||
Schema::create('short_urls', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Core
|
||||
$table->text('destination_url');
|
||||
$table->string('destination_type', 20)->default('single'); // single, split
|
||||
$table->text('destination_url')->nullable();
|
||||
$table->json('rotation_variants')->nullable();
|
||||
$table->string('url_key', 32)->unique()->index();
|
||||
$table->string('notes')->nullable();
|
||||
|
||||
@@ -22,10 +27,19 @@ return new class extends Migration
|
||||
$table->timestamp('activated_at')->nullable();
|
||||
$table->timestamp('deactivated_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->unsignedInteger('max_visits')->nullable();
|
||||
$table->string('expiration_redirect_url')->nullable();
|
||||
|
||||
// Behaviour
|
||||
$table->boolean('single_use')->default(false);
|
||||
$table->boolean('forward_query_params')->default(false);
|
||||
$table->boolean('auto_open_app_mobile')->default(false);
|
||||
|
||||
// Security, Webhooks & QR Counters
|
||||
$table->string('password')->nullable();
|
||||
$table->boolean('show_warning_page')->default(false);
|
||||
$table->string('webhook_url', 2048)->nullable();
|
||||
$table->integer('qr_scans')->default(0);
|
||||
|
||||
// Tracking — master switch
|
||||
$table->boolean('track_visits')->default(true);
|
||||
@@ -38,23 +52,38 @@ return new class extends Migration
|
||||
$table->boolean('track_operating_system_version')->default(true);
|
||||
$table->boolean('track_device_type')->default(true);
|
||||
$table->boolean('track_referer_url')->default(true);
|
||||
$table->boolean('track_browser_language')->default(true);
|
||||
|
||||
// QR code design (JSON blob)
|
||||
// QR code options & branding logo
|
||||
$table->json('qr_options')->nullable();
|
||||
$table->string('qr_logo')->nullable();
|
||||
|
||||
// Google Analytics 4 integration
|
||||
$table->string('ga_tracking_id', 50)->nullable();
|
||||
|
||||
// Advanced targeting rules (AND/OR, Multi-filter)
|
||||
$table->json('targeting_rules')->nullable();
|
||||
|
||||
// Denormalized counters for fast reads (updated atomically)
|
||||
$table->unsignedBigInteger('total_visits')->default(0);
|
||||
$table->unsignedBigInteger('unique_visits')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
|
||||
$table->primary(['short_url_id', 'pixel_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_pixel');
|
||||
Schema::dropIfExists('short_urls');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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::create('short_url_visits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
|
||||
// Client info
|
||||
$table->ipAddress('ip_address')->nullable();
|
||||
$table->string('ip_hash', 64)->nullable()->index();
|
||||
$table->char('country_code', 2)->nullable()->index();
|
||||
$table->string('country', 100)->nullable();
|
||||
$table->string('city', 100)->nullable();
|
||||
$table->string('browser', 100)->nullable();
|
||||
$table->string('browser_version', 50)->nullable();
|
||||
$table->string('operating_system', 100)->nullable();
|
||||
$table->string('operating_system_version', 50)->nullable();
|
||||
$table->string('device_type', 50)->nullable()->index();
|
||||
$table->string('browser_language', 10)->nullable();
|
||||
|
||||
// Referrer
|
||||
$table->text('referer_url')->nullable();
|
||||
$table->string('referer_host', 150)->nullable()->index();
|
||||
|
||||
// Marketing UTM Tracing
|
||||
$table->string('utm_source', 100)->nullable()->index();
|
||||
$table->string('utm_medium', 100)->nullable()->index();
|
||||
$table->string('utm_campaign', 100)->nullable()->index();
|
||||
$table->string('utm_term', 100)->nullable();
|
||||
$table->string('utm_content', 100)->nullable();
|
||||
|
||||
// Context flags
|
||||
$table->boolean('is_qr_scan')->default(false)->index();
|
||||
$table->boolean('is_bot')->default(false)->index();
|
||||
$table->boolean('is_proxy')->default(false)->index();
|
||||
|
||||
// A/B Testing chosen variant label/URL
|
||||
$table->string('selected_variant', 255)->nullable()->index();
|
||||
|
||||
$table->timestamp('visited_at')->useCurrent()->index();
|
||||
$table->index(['short_url_id', 'is_bot', 'is_proxy', 'visited_at'], 'visits_performance_index');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_visits');
|
||||
}
|
||||
};
|
||||
@@ -6,31 +6,48 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_daily_stats', function (Blueprint $table): void {
|
||||
Schema::create('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('short_url_id')
|
||||
->constrained('short_urls')
|
||||
->cascadeOnDelete();
|
||||
$table->date('date');
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->date('date')->index();
|
||||
|
||||
// Aggregated click counts
|
||||
$table->integer('visits_count')->default(0);
|
||||
$table->integer('unique_visits_count')->default(0);
|
||||
$table->integer('qr_visits_count')->default(0);
|
||||
|
||||
// JSON dimensions aggregates
|
||||
$table->json('device_stats')->nullable();
|
||||
$table->json('browser_stats')->nullable();
|
||||
$table->json('os_stats')->nullable();
|
||||
$table->json('country_stats')->nullable();
|
||||
$table->json('city_stats')->nullable();
|
||||
$table->json('referer_stats')->nullable();
|
||||
$table->json('language_stats')->nullable();
|
||||
|
||||
// Marketing aggregates
|
||||
$table->json('utm_source_stats')->nullable();
|
||||
$table->json('utm_medium_stats')->nullable();
|
||||
$table->json('utm_campaign_stats')->nullable();
|
||||
|
||||
// A/B testing variant aggregates
|
||||
$table->json('variant_stats')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// Enforce unique record per short URL and date
|
||||
$table->unique(['short_url_id', 'date']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_daily_stats');
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->string('city', 100)->nullable();
|
||||
$table->string('referer_host', 255)->nullable()->index();
|
||||
$table->string('utm_source', 100)->nullable()->index();
|
||||
$table->string('utm_medium', 100)->nullable()->index();
|
||||
$table->string('utm_campaign', 100)->nullable()->index();
|
||||
$table->string('utm_term', 100)->nullable();
|
||||
$table->string('utm_content', 100)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'city',
|
||||
'referer_host',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->string('password', 255)->nullable();
|
||||
$table->boolean('show_warning_page')->default(false);
|
||||
$table->json('targeting_rules')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'password',
|
||||
'show_warning_page',
|
||||
'targeting_rules',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('max_visits')->nullable();
|
||||
$table->string('expiration_redirect_url', 2048)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn(['max_visits', 'expiration_redirect_url']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
<?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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->boolean('is_bot')->default(false)->index();
|
||||
$table->boolean('is_proxy')->default(false)->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropColumn(['is_bot', 'is_proxy']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->integer('qr_scans')->default(0);
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->boolean('is_qr_scan')->default(false)->index();
|
||||
$table->string('browser_language', 10)->nullable()->index();
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->integer('qr_visits_count')->default(0);
|
||||
$table->json('language_stats')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn('qr_scans');
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropIndex(['is_qr_scan']);
|
||||
$table->dropColumn('is_qr_scan');
|
||||
$table->dropIndex(['browser_language']);
|
||||
$table->dropColumn('browser_language');
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->dropColumn(['qr_visits_count', 'language_stats']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->boolean('track_browser_language')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn('track_browser_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create short_url_pixels table
|
||||
Schema::create('short_url_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
|
||||
$table->string('pixel_id', 100);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['type', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 2. Create short_url_pixel pivot table
|
||||
Schema::create('short_url_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
|
||||
$table->primary(['short_url_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
|
||||
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
|
||||
$oldUrls = DB::table('short_urls')
|
||||
->whereNotNull('pixel_meta_id')
|
||||
->orWhereNotNull('pixel_google_id')
|
||||
->orWhereNotNull('pixel_linkedin_id')
|
||||
->get();
|
||||
|
||||
foreach ($oldUrls as $url) {
|
||||
// Migrate Meta Pixel
|
||||
if (! empty($url->pixel_meta_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'meta')
|
||||
->where('pixel_id', $url->pixel_meta_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $url->pixel_meta_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate Google Tag / GA4
|
||||
if (! empty($url->pixel_google_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'google')
|
||||
->where('pixel_id', $url->pixel_google_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Google Tag ('.$url->pixel_google_id.')',
|
||||
'type' => 'google',
|
||||
'pixel_id' => $url->pixel_google_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate LinkedIn Insight
|
||||
if (! empty($url->pixel_linkedin_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'linkedin')
|
||||
->where('pixel_id', $url->pixel_linkedin_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $url->pixel_linkedin_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop the old columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 1. Re-add the columns
|
||||
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();
|
||||
});
|
||||
|
||||
// 2. Re-populate the old columns from pivot data
|
||||
$associations = DB::table('short_url_pixel')
|
||||
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
|
||||
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
|
||||
->get();
|
||||
|
||||
foreach ($associations as $assoc) {
|
||||
if ($assoc->type === 'meta') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'google') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'linkedin') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop tables
|
||||
Schema::dropIfExists('short_url_pixel');
|
||||
Schema::dropIfExists('short_url_pixels');
|
||||
}
|
||||
};
|
||||
@@ -12,7 +12,7 @@ return new class extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('qr_logo')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->after('id')->index();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ return new class extends Migration
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('qr_logo');
|
||||
$table->dropColumn('user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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::create('short_url_custom_domains', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->string('domain', 191)->unique()->index();
|
||||
$table->boolean('is_verified')->default(false);
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_custom_domains');
|
||||
}
|
||||
};
|
||||
@@ -6,17 +6,23 @@ 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->boolean('auto_open_app_mobile')->default(false)->after('forward_query_params');
|
||||
$table->unsignedBigInteger('custom_domain_id')->nullable()->after('user_id')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_open_app_mobile');
|
||||
$table->dropColumn('custom_domain_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,14 @@
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.short-url-card:hover {
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.short-url-card:focus-within {
|
||||
z-index: 30 !important;
|
||||
}
|
||||
|
||||
.short-url-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1.25rem !important;
|
||||
@@ -15,3 +23,66 @@
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ab-test-slider {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ab-test-slider .noUi-tooltip {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
bottom: inherit !important;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100px;
|
||||
font-size: 12px;
|
||||
background: #ffffff26;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 3-dot button trigger styling */
|
||||
.action-trigger-btn {
|
||||
border: 1px solid transparent !important;
|
||||
background-color: transparent !important;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* On card hover, show a subtle border */
|
||||
.short-url-card:hover .action-trigger-btn {
|
||||
border-color: #e5e7eb !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.dark .short-url-card:hover .action-trigger-btn {
|
||||
border-color: #374151 !important;
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* When the dropdown is open (trigger has focus or aria-expanded="true"), show active border */
|
||||
.action-trigger-btn[aria-expanded="true"] {
|
||||
border-color: #9ca3af !important;
|
||||
background-color: #f3f4f6 !important;
|
||||
box-shadow: 0 0 0 4px rgba(229, 230, 235, 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .action-trigger-btn[aria-expanded="true"] {
|
||||
border-color: #4b5563 !important;
|
||||
background-color: #111827 !important;
|
||||
box-shadow: 0 0 0 4px rgba(55, 65, 81, 0.5) !important;
|
||||
}
|
||||
|
||||
.custom-fused .fi-input-wrp {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* Ukrycie strzałki rozwijania selecta, gdy jest zablokowany */
|
||||
select.fi-select-input:disabled,
|
||||
.fi-select-input .fi-select-input-btn[disabled],
|
||||
.fi-select-input .fi-select-input-btn.fi-disabled {
|
||||
background-image: none !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
2
resources/dist/filament-short-url.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,9 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Short Links',
|
||||
'navigation_label' => 'Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'resource_title' => 'Short URL',
|
||||
'empty_state_heading' => 'No links yet',
|
||||
'empty_state_description' => 'Start creating short links for your marketing campaigns, referral programs, and more.',
|
||||
'empty_state_action' => 'Create link',
|
||||
'empty_state_pixel_heading' => 'No pixels yet',
|
||||
'empty_state_pixel_description' => 'Add tracking pixels from Meta, Google, TikTok and more to retarget visitors who click your links.',
|
||||
'empty_state_pixel_action' => 'Create pixel',
|
||||
|
||||
// Form Tabs
|
||||
'tab_link' => 'Link Details',
|
||||
@@ -21,6 +27,9 @@ return [
|
||||
'matched' => 'Matched',
|
||||
|
||||
// Link Form Fields
|
||||
'destination_type' => 'Destination Type',
|
||||
'destination_type_single' => 'Single URL',
|
||||
'destination_type_split' => 'A/B Split Test',
|
||||
'destination_url' => 'Destination URL',
|
||||
'destination_url_helper' => 'The original URL you want to redirect visitors to.',
|
||||
'url_key' => 'Short Key',
|
||||
@@ -144,6 +153,8 @@ return [
|
||||
'action_qr' => 'QR Code',
|
||||
'action_edit' => 'Edit URL',
|
||||
'action_delete' => 'Delete URL',
|
||||
'url_key_change_confirmation_heading' => 'Replace link?',
|
||||
'url_key_change_confirmation' => "You've modified the back-half of this link's URL. Saving these changes will update the key. Any references to the original link will break.",
|
||||
'action_share' => 'Share Link',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
@@ -184,6 +195,8 @@ return [
|
||||
'stats_table_title' => 'Visit Logs',
|
||||
'stats_btn_back' => 'Back to list',
|
||||
'stats_btn_copy' => 'Copy Short URL',
|
||||
'stats_btn_refresh' => 'Refresh',
|
||||
'stats_refresh_success' => 'Statistics refreshed in real-time!',
|
||||
'stats_col_time' => 'Time',
|
||||
'stats_col_country' => 'Country',
|
||||
'stats_col_device' => 'Device',
|
||||
@@ -210,6 +223,10 @@ return [
|
||||
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
|
||||
'settings_route_prefix' => 'Route Prefix',
|
||||
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
|
||||
'settings_lock_url_key' => 'Lock Short Key',
|
||||
'settings_lock_url_key_helper' => 'Globally disable changing the short key after a link is created.',
|
||||
'settings_disable_default_domain' => 'Disable Default Domain',
|
||||
'settings_disable_default_domain_helper' => 'Prevent users from using the default app domain for new short links.',
|
||||
'settings_redirect_code_helper' => 'Temporary redirect (302) forces browsers to request the short URL every time, ensuring accurate tracking of all visits. Permanent redirect (301) is cached by browsers and search engines (better for SEO), but subsequent visits from the same device might not be logged in statistics.',
|
||||
'settings_key_length' => 'Auto-generated Key Length',
|
||||
'settings_key_length_helper' => 'Number of characters for auto-generated short keys (base62). 6 chars = ~56 billion unique keys.',
|
||||
@@ -231,7 +248,7 @@ return [
|
||||
'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.',
|
||||
'settings_maxmind_path' => 'MaxMind Database Path',
|
||||
'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.',
|
||||
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
|
||||
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
|
||||
'settings_maxmind_verify' => 'Verify file',
|
||||
'settings_maxmind_verify_ok' => '✅ File found & readable',
|
||||
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
|
||||
@@ -250,7 +267,7 @@ return [
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
|
||||
@@ -269,6 +286,15 @@ return [
|
||||
'stats_csv_browser' => 'Browser',
|
||||
'stats_csv_os' => 'OS',
|
||||
'stats_csv_referer' => 'Referer',
|
||||
'stats_csv_utm_source' => 'UTM Source',
|
||||
'stats_csv_utm_medium' => 'UTM Medium',
|
||||
'stats_csv_utm_campaign' => 'UTM Campaign',
|
||||
'stats_csv_utm_term' => 'UTM Term',
|
||||
'stats_csv_utm_content' => 'UTM Content',
|
||||
'stats_csv_variant' => 'A/B Variant',
|
||||
'stats_csv_qr_scan' => 'QR Scan',
|
||||
'stats_csv_bot' => 'Bot',
|
||||
'stats_csv_proxy' => 'Proxy/VPN',
|
||||
|
||||
// Bulk Actions Localization
|
||||
'action_enable_selected' => 'Enable selected',
|
||||
@@ -292,9 +318,11 @@ return [
|
||||
'stats_preset_custom' => 'Custom Range',
|
||||
'stats_breakdown_cities' => 'Top Cities',
|
||||
'stats_no_city_data' => 'No city data.',
|
||||
'stats_breakdown_utm_source' => 'Campaign Sources',
|
||||
'stats_breakdown_utm_medium' => 'Campaign Mediums',
|
||||
'stats_breakdown_utm_campaign' => 'Campaign Names',
|
||||
'stats_breakdown_utm_source' => 'Source',
|
||||
'stats_breakdown_utm_medium' => 'Medium',
|
||||
'stats_breakdown_utm_campaign' => 'Campaign',
|
||||
'stats_breakdown_utm_term' => 'Term',
|
||||
'stats_breakdown_utm_content' => 'Content',
|
||||
'stats_no_utm_data' => 'No UTM data recorded.',
|
||||
|
||||
// New Targeting & Security Tab and fields
|
||||
@@ -302,6 +330,16 @@ return [
|
||||
'security_section_title' => 'Security Controls',
|
||||
'password' => 'Access Password',
|
||||
'password_helper' => 'Require visitors to enter a password before being redirected.',
|
||||
'confirm_password' => 'Confirm Password',
|
||||
'new_password' => 'New Password',
|
||||
'change_password' => 'Change Password',
|
||||
'remove_password' => 'Remove Password',
|
||||
'password_status_active' => 'Password protection is enabled.',
|
||||
'set_password' => 'Set Password',
|
||||
'cancel' => 'Cancel',
|
||||
'confirm' => 'Confirm',
|
||||
'password_required_error' => 'Password is required.',
|
||||
'password_mismatch_error' => 'Passwords do not match.',
|
||||
'show_warning_page' => 'Show Redirect Warning Page',
|
||||
'show_warning_page_helper' => 'Show an intermediate screen warning visitors about external redirection (NSFW/phishing protection).',
|
||||
'targeting_type' => 'Targeting Strategy',
|
||||
@@ -322,6 +360,31 @@ return [
|
||||
'rotation_url' => 'Destination URL',
|
||||
'rotation_weight' => 'Traffic Weight',
|
||||
'rotation_weight_helper' => 'Percentage of traffic to route to this URL (e.g. 50 for 50%). Weights are balanced proportionally.',
|
||||
'rotation_variants' => 'A/B Split Variants',
|
||||
'rotation_variants_helper' => 'Define the URLs and weight distribution for the A/B Split Test.',
|
||||
'variant_label' => 'Variant Label',
|
||||
'variant_url' => 'Variant URL',
|
||||
'variant_weight' => 'Traffic Share (%)',
|
||||
'safe_browsing_error' => 'This URL has been flagged by Google Safe Browsing as unsafe.',
|
||||
|
||||
// New Advanced Targeting Builder
|
||||
'targeting_rules' => 'Targeting Rules',
|
||||
'add_filter' => 'Add a filter',
|
||||
'match' => 'Match',
|
||||
'match_or' => 'Any of the following filters (OR)',
|
||||
'match_and' => 'All the following filters (AND)',
|
||||
'filter_device' => 'Device',
|
||||
'filter_platform' => 'Platform',
|
||||
'filter_country' => 'Country',
|
||||
'filter_language' => 'Browser Language',
|
||||
'direct_to_url' => 'Direct to URL',
|
||||
'select_devices' => 'Select devices',
|
||||
'select_platforms' => 'Select operating systems',
|
||||
'select_countries' => 'Select countries',
|
||||
'select_languages' => 'Select browser languages',
|
||||
'device_desktop_label' => 'Desktop',
|
||||
'device_mobile_label' => 'Smartphone',
|
||||
'device_tablet_label' => 'Tablet',
|
||||
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Performance & Security',
|
||||
@@ -347,7 +410,7 @@ return [
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Queue Name',
|
||||
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
|
||||
|
||||
@@ -364,6 +427,9 @@ return [
|
||||
'settings_track_referer_default' => 'Default Referer URL Tracking',
|
||||
'settings_track_device_type_default' => 'Default Device Type Tracking',
|
||||
'settings_track_browser_language_default' => 'Default Browser Language Tracking',
|
||||
'settings_track_anonymize_ips' => 'IP Address Anonymization (GDPR/RODO)',
|
||||
'settings_track_anonymize_ips_helper' => 'Replace the last octet of IPv4 addresses or last 80 bits of IPv6 addresses with zeros before saving them to the database to comply with GDPR/RODO regulations. The raw IP address is still hashed privately to ensure unique click statistics remain accurate.',
|
||||
'stats_filter_limit_exceeded' => 'Maximum of 5 filters can be applied simultaneously.',
|
||||
|
||||
// QR Defaults Tab
|
||||
'settings_tab_qr' => 'QR Defaults',
|
||||
@@ -402,6 +468,8 @@ return [
|
||||
'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',
|
||||
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
|
||||
'webhook_show_payload' => 'Show example JSON payload',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API & Webhooks',
|
||||
@@ -425,6 +493,8 @@ return [
|
||||
'api_key_name' => 'Key Name (Description)',
|
||||
'api_key' => 'API Key',
|
||||
'active' => 'Active',
|
||||
'api_key_generated' => 'API Key Generated',
|
||||
'api_key_warning' => "Please copy this key now. You won't be able to see it again!",
|
||||
|
||||
// Security v2.0
|
||||
'settings_section_security_v2' => 'Security & Anti-Fraud v2.0',
|
||||
@@ -461,6 +531,7 @@ return [
|
||||
'world_map_no_data' => 'No geographic data yet.',
|
||||
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
|
||||
'stats_card_qr_scans' => 'QR Code Scans',
|
||||
'stats_card_qr_rate' => 'QR Scan Conversion Rate',
|
||||
'stats_breakdown_languages' => 'Top Languages',
|
||||
'stats_no_language_data' => 'No language data.',
|
||||
'badge_clicks' => 'clicks',
|
||||
@@ -511,4 +582,97 @@ return [
|
||||
'expired_title' => 'Link Inactive or Expired',
|
||||
'expired_description' => 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.',
|
||||
'expired_btn_home' => 'Go to Homepage',
|
||||
|
||||
'weights_sum_error' => 'The sum of all variant weights must be exactly 100%. Current sum: :sum%.',
|
||||
'balance_weights' => 'Adjust to 100%',
|
||||
'url_key_locked_error' => 'The URL key is locked and cannot be changed.',
|
||||
'custom_domain_locked_error' => 'The custom domain is locked and cannot be changed.',
|
||||
|
||||
// Stats View & Granularity & PoP Trends
|
||||
'stats_prev_period' => 'prev period',
|
||||
'stats_granularity_hourly' => 'Hourly',
|
||||
'stats_granularity_daily' => 'Daily',
|
||||
'stats_granularity_weekly' => 'Weekly',
|
||||
'stats_granularity_monthly' => 'Monthly',
|
||||
|
||||
// Stats Security Widget
|
||||
'stats_security_title' => 'Security & Bot Analytics',
|
||||
'stats_security_real_users' => 'Real Users',
|
||||
'stats_security_bots' => 'Bots & Crawlers',
|
||||
'stats_security_vpn_blocked' => 'Blocked VPN/Proxy Traffic',
|
||||
'stats_security_no_vpn_blocks' => 'No blocked VPN visits',
|
||||
'stats_security_bot_ratio' => 'Bot Traffic Ratio',
|
||||
'stats_security_human_clicks' => 'human clicks',
|
||||
'stats_security_bot_clicks' => 'bot clicks',
|
||||
'stats_security_proxy_clicks' => 'VPN/Proxy detected',
|
||||
|
||||
// Visit Logs Column & Modal translation enhancements
|
||||
'stats_col_link' => 'Link',
|
||||
'stats_col_referrer' => 'Referrer',
|
||||
'stats_col_language' => 'Language',
|
||||
'stats_col_utm_source' => 'UTM Source',
|
||||
'stats_col_utm_medium' => 'UTM Medium',
|
||||
'stats_col_utm_campaign' => 'UTM Campaign',
|
||||
'stats_col_utm_term' => 'UTM Term',
|
||||
'stats_col_utm_content' => 'UTM Content',
|
||||
'stats_col_variant' => 'A/B Variant',
|
||||
'stats_col_qr_scan' => 'QR Scan',
|
||||
'stats_col_bot' => 'Bot',
|
||||
'stats_col_proxy' => 'Proxy/VPN',
|
||||
'stats_referer_direct' => '(direct)',
|
||||
'stats_device_desktop' => 'Desktop',
|
||||
'stats_device_mobile' => 'Mobile',
|
||||
'stats_device_tablet' => 'Tablet',
|
||||
'stats_device_bot' => 'Robot / Bot',
|
||||
'stats_action_view_details' => 'View Details',
|
||||
'stats_modal_visit_details' => 'Visit Details',
|
||||
'stats_detail_location' => 'Location',
|
||||
'stats_detail_is_bot' => 'Is Bot?',
|
||||
'stats_detail_is_proxy' => 'Is Proxy/VPN?',
|
||||
'stats_detail_is_qr' => 'Is QR Scan?',
|
||||
'stats_detail_visited_at' => 'Visited At',
|
||||
'stats_yes' => 'Yes',
|
||||
'stats_no' => 'No',
|
||||
'add_url' => 'Add URL',
|
||||
'traffic_split' => 'Traffic Split',
|
||||
'delete_confirmation_desc' => 'Are you sure you want to delete the following link? Deleting these links will remove all of their analytics. This action cannot be undone – proceed with caution.',
|
||||
'delete_verification_label' => 'To verify, type <strong>:short_url</strong> below',
|
||||
'delete_verification_error' => 'The entered URL does not match.',
|
||||
|
||||
// Custom Domains
|
||||
'empty_state_domain_heading' => 'No custom domains yet',
|
||||
'empty_state_domain_description' => 'Add your own custom domains to serve short links using your brand identity (e.g. links.acme.com) instead of the default app domain.',
|
||||
'empty_state_domain_action' => 'Add domain',
|
||||
'dns_configuration_guide' => 'DNS Configuration Guide',
|
||||
'dns_for_subdomains' => 'For Subdomains (CNAME)',
|
||||
'dns_for_root_domains' => 'For Root Domains (A Record)',
|
||||
'copied' => 'Copied!',
|
||||
'domains_navigation_label' => 'Custom Domains',
|
||||
'domain_resource_title' => 'Custom Domain',
|
||||
'domain_label' => 'Domain Name',
|
||||
'domain_status_active' => 'Active',
|
||||
'domain_status_verified' => 'DNS Verified',
|
||||
'domain_verification_status' => 'Verification Status',
|
||||
'action_verify_dns' => 'Verify DNS',
|
||||
'dns_verify_success' => 'DNS record verified successfully!',
|
||||
'dns_verify_fail' => 'DNS verification failed. Please check your configuration and try again.',
|
||||
'default_domain_label' => 'Default App Domain',
|
||||
'dns_tab_a' => 'A Record (recommended)',
|
||||
'dns_tab_cname' => 'CNAME Record',
|
||||
'dns_instructions_apex' => 'To configure your apex domain :domain, set the following A record on your DNS provider:',
|
||||
'dns_instructions_subdomain' => 'To configure your subdomain :domain, set the following CNAME record on your DNS provider:',
|
||||
'dns_table_type' => 'Type',
|
||||
'dns_table_name' => 'Name',
|
||||
'dns_table_value' => 'Value',
|
||||
'dns_table_ttl' => 'TTL',
|
||||
'dns_warning_ttl' => 'If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.',
|
||||
'dns_setup_title' => ':domain - DNS Setup',
|
||||
'domain_status_valid' => 'Valid',
|
||||
'domain_status_invalid' => 'Invalid',
|
||||
'domain_no_redirects' => 'No redirects configured',
|
||||
'domain_mapped_links' => 'Mapped to :count short link|Mapped to :count short links',
|
||||
'action_delete_domain' => 'Delete Domain',
|
||||
'delete_domain_confirmation_desc' => 'Are you sure you want to delete this custom domain? This action cannot be undone – proceed with caution.',
|
||||
'short_link_label' => 'Short Link',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Krótkie linki',
|
||||
'navigation_label' => 'Linki',
|
||||
'navigation_group' => 'Narzędzia',
|
||||
'resource_title' => 'Krótki URL',
|
||||
'empty_state_heading' => 'Brak linków',
|
||||
'empty_state_description' => 'Zacznij tworzyć krótkie linki dla swoich kampanii marketingowych, programów partnerskich i nie tylko.',
|
||||
'empty_state_action' => 'Utwórz link',
|
||||
'empty_state_pixel_heading' => 'Brak pikseli',
|
||||
'empty_state_pixel_description' => 'Dodaj piksele śledzące z Meta, Google, TikTok i innych platform, aby kierować reklamy do użytkowników klikających Twoje linki.',
|
||||
'empty_state_pixel_action' => 'Utwórz piksel',
|
||||
|
||||
// Form Tabs
|
||||
'tab_link' => 'Szczegóły linku',
|
||||
@@ -17,6 +23,9 @@ return [
|
||||
'auto_open_app_mobile_helper' => 'Włącz tę opcję, jeśli chcesz, aby po kliknięciu linku na telefonie automatycznie otwierała się dedykowana aplikacja mobilna zamiast przeglądarki.',
|
||||
|
||||
// Link Form Fields
|
||||
'destination_type' => 'Typ docelowy',
|
||||
'destination_type_single' => 'Pojedynczy URL',
|
||||
'destination_type_split' => 'Rotator / Test A/B',
|
||||
'destination_url' => 'Docelowy URL',
|
||||
'destination_url_helper' => 'Oryginalny adres URL, na który chcesz przekierować odwiedzających.',
|
||||
'url_key' => 'Krótki klucz',
|
||||
@@ -140,6 +149,8 @@ return [
|
||||
'action_qr' => 'Kod QR',
|
||||
'action_edit' => 'Edytuj URL',
|
||||
'action_delete' => 'Usuń URL',
|
||||
'url_key_change_confirmation_heading' => 'Zastąpić link?',
|
||||
'url_key_change_confirmation' => 'Zmieniłeś krótki klucz (Short Key) tego linku. Zapisanie tych zmian spowoduje, że dotychczasowy krótki URL przestanie działać, a wszelkie udostępnione wcześniej linki z poprzednim kluczem wygasną.',
|
||||
'action_share' => 'Udostępnij link',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
@@ -180,6 +191,8 @@ return [
|
||||
'stats_table_title' => 'Logi odwiedzin',
|
||||
'stats_btn_back' => 'Wróć do listy',
|
||||
'stats_btn_copy' => 'Kopiuj krótki URL',
|
||||
'stats_btn_refresh' => 'Odśwież',
|
||||
'stats_refresh_success' => 'Statystyki zostały zaktualizowane w czasie rzeczywistym!',
|
||||
'stats_col_time' => 'Czas',
|
||||
'stats_col_country' => 'Kraj',
|
||||
'stats_col_device' => 'Urządzenie',
|
||||
@@ -207,6 +220,10 @@ return [
|
||||
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
|
||||
'settings_route_prefix' => 'Prefiks trasy',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
|
||||
'settings_lock_url_key' => 'Blokuj krótki klucz (Short Key)',
|
||||
'settings_lock_url_key_helper' => 'Globalnie wyłącz możliwość edycji krótkiego klucza po utworzeniu linku.',
|
||||
'settings_disable_default_domain' => 'Wyłącz domyślną domenę',
|
||||
'settings_disable_default_domain_helper' => 'Ukryj domyślną domenę aplikacji na liście wyboru podczas tworzenia i edycji linków.',
|
||||
'settings_redirect_code_helper' => 'Przekierowanie tymczasowe (302) zmusza przeglądarki do każdorazowego odpytywania serwera, co pozwala na dokładne zliczanie wszystkich wizyt. Przekierowanie stałe (301) jest zapisywane w pamięci podręcznej przeglądarek i wyszukiwarek (lepsze pod SEO), przez co kolejne przejścia tego samego użytkownika mogą nie być rejestrowane w statystykach.',
|
||||
'settings_key_length' => 'Długość auto-generowanego klucza',
|
||||
'settings_key_length_helper' => 'Liczba znaków klucza (base62). 6 znaków = ~56 miliardów unikalnych kluczy.',
|
||||
@@ -228,7 +245,7 @@ return [
|
||||
'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.',
|
||||
'settings_maxmind_path' => 'Ścieżka bazy MaxMind',
|
||||
'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.',
|
||||
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
|
||||
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
|
||||
'settings_maxmind_verify' => 'Weryfikuj plik',
|
||||
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
|
||||
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
|
||||
@@ -247,7 +264,7 @@ return [
|
||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
||||
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
|
||||
@@ -266,6 +283,15 @@ return [
|
||||
'stats_csv_browser' => 'Przeglądarka',
|
||||
'stats_csv_os' => 'System operacyjny',
|
||||
'stats_csv_referer' => 'Źródło (Referer)',
|
||||
'stats_csv_utm_source' => 'Źródło UTM (utm_source)',
|
||||
'stats_csv_utm_medium' => 'Medium UTM (utm_medium)',
|
||||
'stats_csv_utm_campaign' => 'Kampania UTM (utm_campaign)',
|
||||
'stats_csv_utm_term' => 'Słowo kluczowe UTM (utm_term)',
|
||||
'stats_csv_utm_content' => 'Treść UTM (utm_content)',
|
||||
'stats_csv_variant' => 'Wariant A/B',
|
||||
'stats_csv_qr_scan' => 'Skan QR',
|
||||
'stats_csv_bot' => 'Bot',
|
||||
'stats_csv_proxy' => 'Proxy/VPN',
|
||||
|
||||
// Bulk Actions Localization
|
||||
'action_enable_selected' => 'Włącz zaznaczone',
|
||||
@@ -289,9 +315,11 @@ return [
|
||||
'stats_preset_custom' => 'Własny zakres',
|
||||
'stats_breakdown_cities' => 'Najpopularniejsze miasta',
|
||||
'stats_no_city_data' => 'Brak danych o miastach.',
|
||||
'stats_breakdown_utm_source' => 'Źródła kampanii',
|
||||
'stats_breakdown_utm_medium' => 'Media kampanii',
|
||||
'stats_breakdown_utm_campaign' => 'Nazwy kampanii',
|
||||
'stats_breakdown_utm_source' => 'Źródło',
|
||||
'stats_breakdown_utm_medium' => 'Medium',
|
||||
'stats_breakdown_utm_campaign' => 'Kampania',
|
||||
'stats_breakdown_utm_term' => 'Słowo kluczowe',
|
||||
'stats_breakdown_utm_content' => 'Treść',
|
||||
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
|
||||
|
||||
// New Targeting & Security Tab and fields
|
||||
@@ -299,6 +327,16 @@ return [
|
||||
'security_section_title' => 'Kontrola bezpieczeństwa',
|
||||
'password' => 'Hasło dostępu',
|
||||
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
|
||||
'confirm_password' => 'Potwierdź hasło',
|
||||
'new_password' => 'Nowe hasło',
|
||||
'change_password' => 'Zmień hasło',
|
||||
'remove_password' => 'Usuń hasło',
|
||||
'password_status_active' => 'Ochrona hasłem jest włączona.',
|
||||
'set_password' => 'Ustaw hasło',
|
||||
'cancel' => 'Anuluj',
|
||||
'confirm' => 'Potwierdź',
|
||||
'password_required_error' => 'Hasło jest wymagane.',
|
||||
'password_mismatch_error' => 'Hasła nie są identyczne.',
|
||||
'show_warning_page' => 'Pokaż stronę ostrzegającą przed przekierowaniem',
|
||||
'show_warning_page_helper' => 'Pokaż ekran pośredni ostrzegający o przekierowaniu na zewnętrzny adres (ochrona przed phishingiem/bezpieczeństwo NSFW).',
|
||||
'targeting_type' => 'Strategia targetowania',
|
||||
@@ -319,6 +357,31 @@ return [
|
||||
'rotation_url' => 'Docelowy URL',
|
||||
'rotation_weight' => 'Waga ruchu',
|
||||
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',
|
||||
'rotation_variants' => 'Warianty rotacji A/B',
|
||||
'rotation_variants_helper' => 'Zdefiniuj adresy URL oraz podział ruchu dla testów split A/B.',
|
||||
'variant_label' => 'Etykieta wariantu',
|
||||
'variant_url' => 'Docelowy URL wariantu',
|
||||
'variant_weight' => 'Udział w ruchu (%)',
|
||||
'safe_browsing_error' => 'Ten adres URL został oznaczony przez Google Safe Browsing jako niebezpieczny.',
|
||||
|
||||
// New Advanced Targeting Builder
|
||||
'targeting_rules' => 'Reguły targetowania',
|
||||
'add_filter' => 'Dodaj filtr',
|
||||
'match' => 'Warunek dopasowania',
|
||||
'match_or' => 'Dowolny z poniższych filtrów (OR)',
|
||||
'match_and' => 'Wszystkie poniższe filtry (AND)',
|
||||
'filter_device' => 'Urządzenie',
|
||||
'filter_platform' => 'Platforma (System operacyjny)',
|
||||
'filter_country' => 'Kraj',
|
||||
'filter_language' => 'Język przeglądarki',
|
||||
'direct_to_url' => 'Przekieruj na URL',
|
||||
'select_devices' => 'Wybierz urządzenia',
|
||||
'select_platforms' => 'Wybierz systemy operacyjne',
|
||||
'select_countries' => 'Wybierz kraje',
|
||||
'select_languages' => 'Wybierz języki przeglądarki',
|
||||
'device_desktop_label' => 'Komputer stacjonarny',
|
||||
'device_mobile_label' => 'Smartfon',
|
||||
'device_tablet_label' => 'Tablet',
|
||||
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
|
||||
@@ -344,7 +407,7 @@ return [
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Nazwa kolejki',
|
||||
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
|
||||
|
||||
@@ -361,6 +424,9 @@ return [
|
||||
'settings_track_referer_default' => 'Domyślne śledzenie adresu referera',
|
||||
'settings_track_device_type_default' => 'Domyślne śledzenie typu urządzenia',
|
||||
'settings_track_browser_language_default' => 'Domyślne śledzenie języka przeglądarki',
|
||||
'settings_track_anonymize_ips' => 'Anonimizacja adresów IP (RODO/GDPR)',
|
||||
'settings_track_anonymize_ips_helper' => 'Zastępuje ostatni oktet adresów IPv4 lub ostatnie 80 bitów adresów IPv6 zerami przed zapisaniem ich w bazie danych w celu zapewnienia zgodności z RODO/GDPR. Surowy adres IP jest nadal prywatnie haszowany (SHA-256), co gwarantuje poprawność zliczania unikalnych odwiedzin.',
|
||||
'stats_filter_limit_exceeded' => 'Maksymalnie 5 filtrów może być nałożonych jednocześnie.',
|
||||
|
||||
// QR Defaults Tab
|
||||
'settings_tab_qr' => 'Domyślne QR',
|
||||
@@ -399,6 +465,8 @@ return [
|
||||
'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',
|
||||
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Przekazuje ono szczegółowe metadane wizyty w formacie JSON (klucz linku, adres IP, kraj, przeglądarka, system operacyjny, referer i parametry UTM).',
|
||||
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API i Webhooki',
|
||||
@@ -422,6 +490,8 @@ return [
|
||||
'api_key_name' => 'Nazwa klucza (Opis)',
|
||||
'api_key' => 'Klucz API',
|
||||
'active' => 'Aktywny',
|
||||
'api_key_generated' => 'Wygenerowano klucz API',
|
||||
'api_key_warning' => 'Skopiuj ten klucz teraz. Nie będzie możliwości ponownego jego wyświetlenia!',
|
||||
|
||||
// Ochrona v2.0
|
||||
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami v2.0',
|
||||
@@ -458,6 +528,7 @@ return [
|
||||
'world_map_no_data' => 'Brak danych geograficznych.',
|
||||
'world_map_no_data_sub' => 'Włącz wykrywanie Geo-IP, aby zacząć rejestrować lokalizacje odwiedzających.',
|
||||
'stats_card_qr_scans' => 'Zeskanowania QR',
|
||||
'stats_card_qr_rate' => 'Konwersja kodów QR',
|
||||
'stats_breakdown_languages' => 'Najpopularniejsze języki',
|
||||
'stats_no_language_data' => 'Brak danych o językach.',
|
||||
'badge_clicks' => 'kliknięć',
|
||||
@@ -508,4 +579,98 @@ return [
|
||||
'expired_title' => 'Link nieaktywny lub wygasł',
|
||||
'expired_description' => 'Ten link jest już nieaktywny. Mógł osiągnąć limit kliknięć, minął jego termin ważności lub został wyłączony.',
|
||||
'expired_btn_home' => 'Przejdź do strony głównej',
|
||||
|
||||
'weights_sum_error' => 'Suma udziałów w ruchu musi wynosić dokładnie 100%. Obecna suma: :sum%.',
|
||||
'balance_weights' => 'Wyreguluj do 100%',
|
||||
'url_key_locked_error' => 'Klucz URL jest zablokowany i nie może być zmieniony.',
|
||||
'custom_domain_locked_error' => 'Domena niestandardowa jest zablokowana i nie może być zmieniona.',
|
||||
|
||||
// Stats View & Granularity & PoP Trends
|
||||
'stats_prev_period' => 'poprzedni okres',
|
||||
'stats_granularity_hourly' => 'Godzinowo',
|
||||
'stats_granularity_daily' => 'Dziennie',
|
||||
'stats_granularity_weekly' => 'Tygodniowo',
|
||||
'stats_granularity_monthly' => 'Miesięcznie',
|
||||
|
||||
// Stats Security Widget
|
||||
'stats_security_title' => 'Bezpieczeństwo i ruch botów',
|
||||
'stats_security_real_users' => 'Prawdziwi użytkownicy',
|
||||
'stats_security_bots' => 'Boty i skanery',
|
||||
'stats_security_vpn_blocked' => 'Zablokowany ruch VPN/Proxy',
|
||||
'stats_security_no_vpn_blocks' => 'Brak zablokowanych wejść z VPN',
|
||||
'stats_security_bot_ratio' => 'Wskaźnik ruchu botów',
|
||||
'stats_security_human_clicks' => 'kliknięć ludzi',
|
||||
'stats_security_bot_clicks' => 'kliknięć botów',
|
||||
'stats_security_proxy_clicks' => 'wykrytych VPN/Proxy',
|
||||
|
||||
// Visit Logs Column & Modal translation enhancements
|
||||
'stats_col_link' => 'Link',
|
||||
'stats_col_referrer' => 'Źródło (Referer)',
|
||||
'stats_col_language' => 'Język',
|
||||
'stats_col_utm_source' => 'Źródło UTM (utm_source)',
|
||||
'stats_col_utm_medium' => 'Medium UTM (utm_medium)',
|
||||
'stats_col_utm_campaign' => 'Kampania UTM (utm_campaign)',
|
||||
'stats_col_utm_term' => 'Słowo kluczowe UTM (utm_term)',
|
||||
'stats_col_utm_content' => 'Treść UTM (utm_content)',
|
||||
'stats_col_variant' => 'Wariant A/B',
|
||||
'stats_col_qr_scan' => 'Skan QR',
|
||||
'stats_col_bot' => 'Bot',
|
||||
'stats_col_proxy' => 'Proxy/VPN',
|
||||
'stats_referer_direct' => '(bezpośrednie)',
|
||||
'stats_device_desktop' => 'Komputer stacjonarny',
|
||||
'stats_device_mobile' => 'Smartfon',
|
||||
'stats_device_tablet' => 'Tablet',
|
||||
'stats_device_bot' => 'Robot / Bot',
|
||||
'stats_action_view_details' => 'Szczegóły',
|
||||
'stats_modal_visit_details' => 'Szczegóły wizyty',
|
||||
'stats_detail_location' => 'Lokalizacja',
|
||||
'stats_detail_is_bot' => 'Czy bot?',
|
||||
'stats_detail_is_proxy' => 'Czy Proxy/VPN?',
|
||||
'stats_detail_is_qr' => 'Czy skan QR?',
|
||||
'stats_detail_visited_at' => 'Czas wizyty',
|
||||
'stats_yes' => 'Tak',
|
||||
'stats_no' => 'Nie',
|
||||
'add_url' => 'Dodaj URL',
|
||||
'traffic_split' => 'Podział ruchu',
|
||||
'delete_confirmation_desc' => 'Czy na pewno chcesz usunąć ten link? Usunięcie spowoduje bezpowrotne skasowanie wszystkich statystyk i analiz. Tej operacji nie można cofnąć – postępuj ostrożnie.',
|
||||
'delete_verification_label' => 'Aby zweryfikować, wpisz poniżej <strong>:short_url</strong>',
|
||||
'delete_verification_error' => 'Wpisany adres URL się nie zgadza.',
|
||||
|
||||
// Custom Domains
|
||||
'empty_state_domain_heading' => 'Brak własnych domen',
|
||||
'empty_state_domain_description' => 'Dodaj własne domeny, aby skrócone linki były serwowane pod Twoją marką (np. links.acme.com) zamiast domyślnej domeny.',
|
||||
'empty_state_domain_action' => 'Dodaj domenę',
|
||||
'dns_configuration_guide' => 'Przewodnik konfiguracji DNS',
|
||||
'dns_for_subdomains' => 'Dla subdomen (CNAME)',
|
||||
'dns_for_root_domains' => 'Dla domen głównych (Rekord A)',
|
||||
'copied' => 'Skopiowano!',
|
||||
'domains_navigation_label' => 'Własne domeny',
|
||||
'domain_resource_title' => 'Własna domena',
|
||||
'domain_label' => 'Nazwa domeny',
|
||||
'domain_status_active' => 'Aktywna',
|
||||
'domain_status_verified' => 'DNS Zweryfikowany',
|
||||
'domain_verification_status' => 'Status weryfikacji',
|
||||
'action_verify_dns' => 'Zweryfikuj DNS',
|
||||
'dns_verify_success' => 'Rekord DNS zweryfikowany pomyślnie!',
|
||||
'dns_verify_fail' => 'Weryfikacja DNS nie powiodła się. Sprawdź konfigurację i spróbuj ponownie.',
|
||||
'dns_instructions_title' => 'Instrukcje DNS',
|
||||
'default_domain_label' => 'Domyślna domena aplikacji',
|
||||
'dns_tab_a' => 'Rekord A (rekomendowany)',
|
||||
'dns_tab_cname' => 'Rekord CNAME',
|
||||
'dns_instructions_apex' => 'Aby skonfigurować domenę główną (apex) :domain, ustaw następujący rekord A u swojego dostawcy DNS:',
|
||||
'dns_instructions_subdomain' => 'Aby skonfigurować subdomenę :domain, ustaw następujący rekord CNAME u swojego dostawcy DNS:',
|
||||
'dns_table_type' => 'Typ',
|
||||
'dns_table_name' => 'Nazwa',
|
||||
'dns_table_value' => 'Wartość',
|
||||
'dns_table_ttl' => 'TTL',
|
||||
'dns_warning_ttl' => 'Jeśli wartość TTL 86400 nie jest dostępna, wybierz najwyższą dostępną wartość. Propagacja domeny może potrwać do 12 godzin.',
|
||||
'dns_setup_title' => ':domain - Konfiguracja DNS',
|
||||
'domain_status_valid' => 'Zweryfikowana',
|
||||
'domain_status_invalid' => 'Niezweryfikowana',
|
||||
'domain_no_redirects' => 'Brak przypisanych linków',
|
||||
'domain_mapped_links' => 'Przypisano :count krótki link|Przypisano :count krótkie linki|Przypisano :count krótkich linków',
|
||||
'action_delete_domain' => 'Usuń domenę',
|
||||
'delete_domain_confirmation_desc' => 'Czy na pewno chcesz usunąć tę własną domenę? Ta akcja nie może zostać cofnięta – postępuj ostrożnie.',
|
||||
'short_link_label' => 'Krótki link',
|
||||
];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.app_redirect_title') ?? 'Opening App...' }}</title>
|
||||
<title>{{ __('filament-short-url::default.app_redirect_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -154,10 +154,10 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-bold text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) ?? "Opening in {$appName}" }}
|
||||
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-2 max-w-[280px]">
|
||||
{{ __('filament-short-url::default.app_redirect_waiting_text') ?? 'We are redirecting you to the native application. Please wait a moment...' }}
|
||||
{{ __('filament-short-url::default.app_redirect_waiting_text') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
onmouseover="this.style.filter='brightness(1.05)';"
|
||||
onmouseout="this.style.filter='brightness(0.95)';"
|
||||
id="deep-link-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_open') ?? 'Open Native App' }}</span>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_open') }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
@@ -181,7 +181,7 @@
|
||||
<a href="{{ $destination }}"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400 font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm"
|
||||
id="fallback-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') ?? 'Open in Browser' }}</span>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -191,10 +191,10 @@
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') ?? 'In-App Browser Detected' }}</span>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') }}</span>
|
||||
</p>
|
||||
<p class="text-[11px] leading-relaxed opacity-95">
|
||||
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') ?? 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.' }}
|
||||
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}</title>
|
||||
<title>{{ __('filament-short-url::default.expired_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -50,10 +50,10 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}
|
||||
{{ __('filament-short-url::default.expired_title') }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.expired_description') ?? 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.' }}
|
||||
{{ __('filament-short-url::default.expired_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
{{-- Action Button --}}
|
||||
<a href="{{ url('/') }}" class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.expired_btn_home') ?? 'Go to Homepage' }}</span>
|
||||
<span>{{ __('filament-short-url::default.expired_btn_home') }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
|
||||
159
resources/views/forms/components/traffic-splitter.blade.php
Normal file
159
resources/views/forms/components/traffic-splitter.blade.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<x-dynamic-component
|
||||
:component="$getFieldWrapperView()"
|
||||
:field="$field"
|
||||
>
|
||||
<div
|
||||
x-data="{
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getTargetStatePath()}')") }},
|
||||
get items() {
|
||||
if (!this.state) return [];
|
||||
if (Array.isArray(this.state)) {
|
||||
return this.state;
|
||||
}
|
||||
return Object.entries(this.state).map(([key, value]) => ({
|
||||
key,
|
||||
...value
|
||||
}));
|
||||
},
|
||||
get totalSum() {
|
||||
return this.items.reduce((sum, item) => sum + parseInt(item.weight || 0), 0);
|
||||
},
|
||||
init() {
|
||||
this.$watch('state', () => {
|
||||
this.checkAndNormalize();
|
||||
});
|
||||
this.checkAndNormalize();
|
||||
},
|
||||
checkAndNormalize() {
|
||||
const items = this.items;
|
||||
if (items.length < 2) return;
|
||||
const sum = items.reduce((s, i) => s + parseInt(i.weight || 0), 0);
|
||||
if (sum !== 100) {
|
||||
const m = items.length;
|
||||
const base = Math.floor(100 / m);
|
||||
const remainder = 100 % m;
|
||||
|
||||
// Re-balance equally using 1% resolution
|
||||
items.forEach((item, index) => {
|
||||
const newWeight = base + (index < remainder ? 1 : 0);
|
||||
this.updateWeight(item, newWeight);
|
||||
});
|
||||
}
|
||||
},
|
||||
updateWeight(item, newWeight) {
|
||||
if (Array.isArray(this.state)) {
|
||||
const index = this.state.findIndex(i => i === item || (i.key && i.key === item.key));
|
||||
if (index !== -1) {
|
||||
this.state[index].weight = newWeight;
|
||||
}
|
||||
} else if (this.state && typeof this.state === 'object') {
|
||||
if (this.state[item.key]) {
|
||||
this.state[item.key].weight = newWeight;
|
||||
}
|
||||
}
|
||||
},
|
||||
startDrag(index, event) {
|
||||
event.preventDefault();
|
||||
const isTouch = event.type.startsWith('touch');
|
||||
const startX = isTouch ? event.touches[0].clientX : event.clientX;
|
||||
|
||||
const rect = this.$refs.container.getBoundingClientRect();
|
||||
const containerWidth = rect.width;
|
||||
const containerLeft = rect.left;
|
||||
|
||||
if (!isTouch) {
|
||||
document.body.style.cursor = 'col-resize';
|
||||
}
|
||||
|
||||
// Get cumulative weights up to index
|
||||
let sumBefore = 0;
|
||||
const items = this.items;
|
||||
for (let j = 0; j < index; j++) {
|
||||
sumBefore += parseInt(items[j].weight || 0);
|
||||
}
|
||||
|
||||
// Total sum of current and next segment
|
||||
const segmentSum = parseInt(items[index].weight || 0) + parseInt(items[index+1].weight || 0);
|
||||
|
||||
const onMove = (e) => {
|
||||
const currentX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX;
|
||||
const offsetPercent = ((currentX - containerLeft) / containerWidth) * 100;
|
||||
|
||||
const minWeight = 1;
|
||||
const minPercent = sumBefore + minWeight;
|
||||
const maxPercent = sumBefore + segmentSum - minWeight;
|
||||
|
||||
let targetPercent = Math.round(offsetPercent);
|
||||
targetPercent = Math.max(minPercent, Math.min(maxPercent, targetPercent));
|
||||
|
||||
const newWeightCurrent = targetPercent - sumBefore;
|
||||
const newWeightNext = segmentSum - newWeightCurrent;
|
||||
|
||||
this.updateWeight(items[index], newWeightCurrent);
|
||||
this.updateWeight(items[index+1], newWeightNext);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (!isTouch) {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
if (isTouch) {
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onEnd);
|
||||
}
|
||||
};
|
||||
|
||||
if (isTouch) {
|
||||
window.addEventListener('touchmove', onMove, { passive: true });
|
||||
window.addEventListener('touchend', onEnd);
|
||||
} else {
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
}
|
||||
}
|
||||
}"
|
||||
class="traffic-splitter-component mt-2"
|
||||
>
|
||||
|
||||
<div
|
||||
x-ref="container"
|
||||
class="relative h-10 w-full select-none"
|
||||
x-show="items.length >= 2"
|
||||
>
|
||||
<div class="absolute inset-0 flex h-full">
|
||||
<template x-for="(item, index) in items" :key="item.key || index">
|
||||
<!-- Segment container -->
|
||||
<div
|
||||
class="@container pointer-events-none relative flex h-full"
|
||||
:style="'width: ' + item.weight + '%;'"
|
||||
>
|
||||
<!-- Left Spacer (only if not first item) -->
|
||||
<div class="w-1.5" x-show="index > 0"></div>
|
||||
|
||||
<!-- Segment Box -->
|
||||
<div class="flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs shadow-sm select-none">
|
||||
<span class="text-xs font-semibold text-neutral-900 dark:text-neutral-100" x-text="index + 1"></span>
|
||||
<span class="font-medium text-neutral-600 dark:text-neutral-400" x-show="item.weight >= 12" x-text="item.weight + '%'"></span>
|
||||
</div>
|
||||
|
||||
<!-- Right Spacer (only if not last item) -->
|
||||
<div class="w-1.5" x-show="index < items.length - 1"></div>
|
||||
|
||||
<!-- Drag Handle (visible only if not the last item) -->
|
||||
<div
|
||||
x-show="index < items.length - 1"
|
||||
class="group pointer-events-auto absolute -right-1.5 flex h-full w-3 cursor-col-resize items-center px-1 z-30"
|
||||
@mousedown.stop="startDrag(index, $event)"
|
||||
@touchstart.stop="startDrag(index, $event)"
|
||||
>
|
||||
<div class="h-2/3 w-1 rounded-full bg-neutral-300 dark:bg-neutral-600 group-hover:bg-neutral-400 dark:group-hover:bg-neutral-400 group-active:bg-neutral-500 transition-colors duration-150"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}</title>
|
||||
<title>{{ __('filament-short-url::default.password_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -49,10 +49,10 @@
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}
|
||||
{{ __('filament-short-url::default.password_title') }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }}
|
||||
{{ __('filament-short-url::default.password_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex flex-col justify-center px-3 py-2 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900 dark:focus-within:ring-white transition duration-200">
|
||||
<label for="password" class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
{{ __('filament-short-url::default.password_placeholder') ?? 'Password' }}
|
||||
{{ __('filament-short-url::default.password_placeholder') }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -85,7 +85,7 @@
|
||||
type="submit"
|
||||
class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2"
|
||||
>
|
||||
<span>{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}</span>
|
||||
<span>{{ __('filament-short-url::default.password_btn_unlock') }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}</title>
|
||||
<title>{{ __('filament-short-url::default.pixel_loading_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -148,10 +148,10 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}
|
||||
{{ __('filament-short-url::default.pixel_loading_title') }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.pixel_loading_description') ?? 'Preparing your connection and forwarding you now.' }}
|
||||
{{ __('filament-short-url::default.pixel_loading_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
if (isset($component) && method_exists($component, 'getStatePath')) {
|
||||
$statePath = $component->getStatePath();
|
||||
\Illuminate\Support\Facades\Log::info('QR DESIGNER STATE PATH: ' . $statePath);
|
||||
if ($statePath) {
|
||||
$parts = explode('.', $statePath);
|
||||
if (end($parts) === 'qr_designer') {
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
$dateFrom = $this->filterData['date_from'] ?? null;
|
||||
$dateTo = $this->filterData['date_to'] ?? null;
|
||||
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
|
||||
$activeFilters = $this->activeFilters ?? [];
|
||||
$filtersHash = md5(json_encode($activeFilters));
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -31,34 +33,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! empty($activeFilters))
|
||||
<div class="flex flex-wrap items-center gap-2 p-3 bg-gray-50/50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl">
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mr-1">Active Filters:</span>
|
||||
@foreach ($activeFilters as $key => $value)
|
||||
@php
|
||||
$displayValue = $value;
|
||||
if ($key === 'country_code') {
|
||||
$translatedCountry = __('filament-short-url::countries.' . strtoupper($value));
|
||||
if ($translatedCountry && $translatedCountry !== 'filament-short-url::countries.' . strtoupper($value)) {
|
||||
$displayValue = $translatedCountry;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="flex items-center gap-1.5 px-3 py-1 bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-100 dark:border-indigo-900/60 rounded-full text-xs font-semibold text-indigo-700 dark:text-indigo-300">
|
||||
<span class="opacity-75 uppercase text-[10px] tracking-wider">{{ $key === 'country_code' ? 'country' : str_replace('_', ' ', $key) }}:</span>
|
||||
<span>{{ $displayValue }}</span>
|
||||
<button type="button"
|
||||
wire:click="clearStatsFilter('{{ $key }}')"
|
||||
class="hover:text-indigo-900 dark:hover:text-indigo-100 transition-colors focus:outline-none ml-1">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
<button type="button"
|
||||
wire:click="clearAllStatsFilters"
|
||||
class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors focus:outline-none ml-auto px-2.5 py-1 rounded-md hover:bg-gray-100 dark:hover:bg-white/5">
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-overview-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-chart-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGeoBreakdownWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-geo-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlDeviceBreakdownWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-device-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
</div>
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-world-map-' . $dateFrom . '-' . $dateTo))
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-world-map-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSourcesBreakdownWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-sources-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSecurityBreakdownWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-security-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
</div>
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVariantsWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
'filters' => $activeFilters,
|
||||
], key('stats-variants-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
247
resources/views/table/custom-domain-row.blade.php
Normal file
247
resources/views/table/custom-domain-row.blade.php
Normal file
@@ -0,0 +1,247 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?: request()->getHost();
|
||||
|
||||
// Dynamically resolve target IP for A Record
|
||||
$serverIp = null;
|
||||
try {
|
||||
$serverIp = gethostbyname($appHost);
|
||||
if ($serverIp === $appHost) {
|
||||
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
|
||||
}
|
||||
if (!$serverIp || $serverIp === '127.0.0.1') {
|
||||
$serverIp = '76.76.21.21'; // Dummy IP for demonstration if local env has loopback
|
||||
}
|
||||
|
||||
// Extract subdomain name
|
||||
$domainParts = explode('.', $record->domain);
|
||||
$isSubdomain = count($domainParts) > 2;
|
||||
$subdomainName = $isSubdomain ? $domainParts[0] : '@';
|
||||
|
||||
$shortUrlsCount = $record->short_urls_count ?? 0;
|
||||
$clicksCount = $record->total_clicks ?? 0;
|
||||
@endphp
|
||||
|
||||
<div x-data="{ open: false, activeTab: 'A' }" class="w-full text-left">
|
||||
<!-- Header / Collapsed Row -->
|
||||
<div class="px-5 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<!-- Left Section: Icon & Info -->
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<!-- Globe Icon -->
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/40 flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-neutral-500 dark:text-neutral-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- Domain and Primary Badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[15px] font-bold text-neutral-800 dark:text-neutral-200 truncate leading-tight">
|
||||
{{ $record->domain }}
|
||||
</span>
|
||||
@if($record->is_active)
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-blue-50/80 dark:bg-blue-950/30 text-blue-650 dark:text-blue-400 border border-blue-100/60 dark:border-blue-900/40">
|
||||
Primary
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<!-- Subtitle -->
|
||||
<div class="text-[12px] text-neutral-450 dark:text-neutral-500 mt-1 flex items-center gap-1.5">
|
||||
<span>↳</span>
|
||||
@if($shortUrlsCount === 0)
|
||||
<span>No redirect configured</span>
|
||||
@else
|
||||
<span>Mapped to {{ $shortUrlsCount }} short {{ Str::plural('link', $shortUrlsCount) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Section: Clicks & Verification Status -->
|
||||
<div class="flex items-center gap-3 sm:ml-auto">
|
||||
<!-- Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 text-[12px] font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
<svg class="w-3.5 h-3.5 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.303.197-1.593 1.593M21.75 12H19.5m-.197 5.303-1.593-1.593M12 21.75V19.5m-5.303-.197 1.593-1.593M2.25 12H4.5m.197-5.303 1.593 1.593" />
|
||||
</svg>
|
||||
{{ $clicksCount }} clicks
|
||||
</span>
|
||||
|
||||
<!-- Status Badge -->
|
||||
@if($record->is_verified)
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-150 dark:border-emerald-900/40 text-[12px] font-semibold text-emerald-650 dark:text-emerald-450">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
|
||||
</svg>
|
||||
Valid
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-150 dark:border-red-900/40 text-[12px] font-semibold text-red-650 dark:text-red-400">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
Invalid
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Right Section: Actions Buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Settings Toggle Button with red indicator when invalid -->
|
||||
<button @click="open = !open" type="button" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-950 transition-all shadow-sm focus:outline-none cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.43l-1.003.828c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.936 6.936 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.33-.183.58-.495.643-.869L9.594 3.94Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<!-- Live Red Dot Indicator -->
|
||||
@if(!$record->is_verified)
|
||||
<span class="absolute top-0.5 right-0.5 flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
@endif
|
||||
<!-- Chevron -->
|
||||
<svg class="w-3 h-3 text-neutral-400 transition-transform duration-200" :class="open ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Actions Dropdown -->
|
||||
<div x-data="{ dropdownOpen: false }" class="relative">
|
||||
<button @click="dropdownOpen = !dropdownOpen" type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-950 transition-all shadow-sm focus:outline-none cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="dropdownOpen" @click.away="dropdownOpen = false" x-cloak class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-neutral-850 border border-neutral-200/80 dark:border-neutral-800 z-50 py-1.5 text-left transition-all">
|
||||
<!-- Verify DNS / Refresh -->
|
||||
<button @click="$wire.callTableAction('verify', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
|
||||
<svg class="w-4 h-4 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
Verify / Refresh DNS
|
||||
</button>
|
||||
<!-- Edit Action -->
|
||||
<button @click="$wire.mountTableAction('edit', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
|
||||
<svg class="w-4 h-4 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
Edit Domain
|
||||
</button>
|
||||
<hr class="border-neutral-100 dark:border-neutral-800 my-1">
|
||||
<!-- Delete Action -->
|
||||
<button @click="$wire.mountTableAction('delete', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-655 dark:text-red-400 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
|
||||
<svg class="w-4 h-4 text-red-450 dark:text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
Delete Domain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded DNS Panel -->
|
||||
<div x-show="open" x-collapse x-cloak class="border-t border-neutral-100 dark:border-neutral-800/80 px-6 py-5 bg-neutral-50/20 dark:bg-neutral-950/5">
|
||||
<div class="pt-2">
|
||||
<!-- Tabs Menu -->
|
||||
<div class="-ml-1.5 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div class="flex text-sm">
|
||||
<!-- Tab A -->
|
||||
<div class="relative">
|
||||
<button @click="activeTab = 'A'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'A' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
|
||||
A Record (recommended)
|
||||
</button>
|
||||
<div x-show="activeTab === 'A'" class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
|
||||
<div class="h-0.5 rounded-t-full bg-current"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab CNAME -->
|
||||
<div class="relative">
|
||||
<button @click="activeTab = 'CNAME'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'CNAME' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
|
||||
CNAME Record
|
||||
</button>
|
||||
<div x-show="activeTab === 'CNAME'" x-cloak class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
|
||||
<div class="h-0.5 rounded-t-full bg-current"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="mt-3 text-left text-neutral-600">
|
||||
<!-- Instruction Paragraph -->
|
||||
<div class="my-5">
|
||||
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
|
||||
To configure your <span x-text="activeTab === 'A' ? 'apex domain' : 'subdomain'"></span> <code>{{ $record->domain }}</code>, set the following <span x-text="activeTab === 'A' ? 'A record' : 'CNAME record'"></span> on your DNS provider:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Parameters Table (CSS Grid format) -->
|
||||
<div class="scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 dark:bg-neutral-950/40 p-4 text-sm grid-cols-[repeat(4,max-content)]">
|
||||
<!-- Table Headers -->
|
||||
<p class="font-medium text-neutral-950 dark:text-white">Type</p>
|
||||
<p class="font-medium text-neutral-950 dark:text-white">Name</p>
|
||||
<p class="font-medium text-neutral-950 dark:text-white">Value</p>
|
||||
<p class="font-medium text-neutral-950 dark:text-white">TTL</p>
|
||||
|
||||
<!-- Tab A content -->
|
||||
<div x-show="activeTab === 'A'" class="contents">
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">A</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">@</p>
|
||||
<p class="flex items-center gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
|
||||
<span>{{ $serverIp }}</span>
|
||||
<button @click="navigator.clipboard.writeText('{{ $serverIp }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
|
||||
<span class="sr-only">Copy</span>
|
||||
<svg x-show="!copied" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-450 dark:text-neutral-500">
|
||||
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab CNAME content -->
|
||||
<div x-show="activeTab === 'CNAME'" x-cloak class="contents">
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">CNAME</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">{{ $subdomainName }}</p>
|
||||
<p class="flex items-center gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
|
||||
<span>{{ $appHost }}</span>
|
||||
<button @click="navigator.clipboard.writeText('{{ $appHost }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
|
||||
<span class="sr-only">Copy</span>
|
||||
<svg x-show="!copied" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-450 dark:text-neutral-500">
|
||||
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Alert Block -->
|
||||
<div class="mt-4 flex items-center gap-2 rounded-lg p-3 bg-indigo-50 dark:bg-indigo-950/20 text-indigo-650 dark:text-indigo-400 border border-indigo-100/30 dark:border-indigo-900/30">
|
||||
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0">
|
||||
<g fill="currentColor">
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
<line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" x1="9" x2="9" y1="12.819" y2="8.25"></line>
|
||||
<path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="currentColor" stroke="none"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
|
||||
If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
22
resources/views/table/destination-column.blade.php
Normal file
22
resources/views/table/destination-column.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
style="transform: scaleY(-1);"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
|
||||
</svg>
|
||||
@if(($record->destination_type ?? 'single') === 'split')
|
||||
<span class="truncate max-w-[50ch] text-[#273144] dark:text-gray-300 text-[15px] leading-[16px] font-semibold italic">
|
||||
{{ __('filament-short-url::default.destination_type_split') }}
|
||||
</span>
|
||||
@else
|
||||
<span onclick="event.preventDefault(); event.stopPropagation(); window.open('{{ e($record->destination_url) }}', '_blank', 'noopener,noreferrer');"
|
||||
class="truncate max-w-[50ch] hover:underline text-[#273144] dark:text-gray-350 text-[15px] leading-[16px] font-medium cursor-pointer"
|
||||
title="{{ e($record->destination_url) }}">
|
||||
{{ $record->destination_url }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
122
resources/views/table/dns-guide-panel.blade.php
Normal file
122
resources/views/table/dns-guide-panel.blade.php
Normal file
@@ -0,0 +1,122 @@
|
||||
@php
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?: request()->getHost();
|
||||
|
||||
// Dynamically resolve target IP for A Record
|
||||
$serverIp = null;
|
||||
try {
|
||||
$serverIp = gethostbyname($appHost);
|
||||
if ($serverIp === $appHost) {
|
||||
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
|
||||
}
|
||||
if (!$serverIp || $serverIp === '127.0.0.1') {
|
||||
$serverIp = '76.76.21.21'; // Dummy IP for demonstration
|
||||
}
|
||||
|
||||
// Extract subdomain name
|
||||
$domainParts = explode('.', $record->domain);
|
||||
$isSubdomain = count($domainParts) > 2;
|
||||
$subdomainName = $isSubdomain ? $domainParts[0] : '@';
|
||||
@endphp
|
||||
|
||||
<div x-data="{ activeTab: 'A' }" class="p-6 bg-neutral-50/50 dark:bg-neutral-950/20 border-t border-neutral-100 dark:border-neutral-800 text-left">
|
||||
<!-- Tabs Menu -->
|
||||
<div class="-ml-1.5 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div class="flex text-sm">
|
||||
<!-- Tab A -->
|
||||
<div class="relative">
|
||||
<button @click="activeTab = 'A'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'A' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
|
||||
{{ __('filament-short-url::default.dns_tab_a') }}
|
||||
</button>
|
||||
<div x-show="activeTab === 'A'" class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
|
||||
<div class="h-0.5 rounded-t-full bg-current"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab CNAME -->
|
||||
<div class="relative">
|
||||
<button @click="activeTab = 'CNAME'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'CNAME' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
|
||||
{{ __('filament-short-url::default.dns_tab_cname') }}
|
||||
</button>
|
||||
<div x-show="activeTab === 'CNAME'" x-cloak class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
|
||||
<div class="h-0.5 rounded-t-full bg-current"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="mt-3 text-left text-neutral-600">
|
||||
<!-- Instruction Paragraph -->
|
||||
<div class="my-5">
|
||||
<p x-show="activeTab === 'A'" class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
|
||||
{!! __('filament-short-url::default.dns_instructions_apex', ['domain' => '<code>' . e($record->domain) . '</code>']) !!}
|
||||
</p>
|
||||
<p x-show="activeTab === 'CNAME'" x-cloak class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
|
||||
{!! __('filament-short-url::default.dns_instructions_subdomain', ['domain' => '<code>' . e($record->domain) . '</code>']) !!}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Parameters Table (CSS Grid format) -->
|
||||
<div class="scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 dark:bg-neutral-950/40 p-4 text-sm grid-cols-[repeat(4,max-content)]">
|
||||
<!-- Table Headers -->
|
||||
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_type') }}</p>
|
||||
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_name') }}</p>
|
||||
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_value') }}</p>
|
||||
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_ttl') }}</p>
|
||||
|
||||
<!-- Tab A content -->
|
||||
<div x-show="activeTab === 'A'" class="contents">
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">A</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">@</p>
|
||||
<p class="flex items-end gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
|
||||
<span>{{ $serverIp }}</span>
|
||||
<button @click="navigator.clipboard.writeText('{{ $serverIp }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 active:bg-neutral-200 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
|
||||
<span class="sr-only">Copy</span>
|
||||
<svg x-show="!copied" fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500">
|
||||
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab CNAME content -->
|
||||
<div x-show="activeTab === 'CNAME'" x-cloak class="contents">
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">CNAME</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">{{ $subdomainName }}</p>
|
||||
<p class="flex items-end gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
|
||||
<span>{{ $appHost }}</span>
|
||||
<button @click="navigator.clipboard.writeText('{{ $appHost }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 active:bg-neutral-200 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
|
||||
<span class="sr-only">Copy</span>
|
||||
<svg x-show="!copied" fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500">
|
||||
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Alert Block -->
|
||||
<div class="mt-4 flex items-center gap-2 rounded-lg p-3 bg-orange-50 dark:bg-orange-950/20 text-orange-600 dark:text-orange-400 border border-orange-100/30 dark:border-orange-900/30">
|
||||
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0">
|
||||
<g fill="currentColor">
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
<line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" x1="9" x2="9" y1="12.819" y2="8.25"></line>
|
||||
<path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="currentColor" stroke="none"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
|
||||
{{ __('filament-short-url::default.dns_warning_ttl') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
38
resources/views/table/domain-column.blade.php
Normal file
38
resources/views/table/domain-column.blade.php
Normal file
@@ -0,0 +1,38 @@
|
||||
@php
|
||||
$shortUrlsCount = $record->short_urls_count ?? $record->shortUrls()->count();
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-3.5 min-w-0 py-2">
|
||||
<!-- Globe Icon -->
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/40 flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-neutral-500 dark:text-neutral-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- Domain -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[15px] font-bold text-neutral-800 dark:text-neutral-200 truncate leading-tight">
|
||||
{{ $record->domain }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Subtitle -->
|
||||
<div class="text-[12px] text-neutral-450 dark:text-neutral-500 mt-1 flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
style="transform: scaleY(-1);"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
|
||||
</svg>
|
||||
@if($shortUrlsCount === 0)
|
||||
<span>{{ __('filament-short-url::default.domain_no_redirects') }}</span>
|
||||
@else
|
||||
<span>{{ trans_choice('filament-short-url::default.domain_mapped_links', $shortUrlsCount, ['count' => $shortUrlsCount]) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
67
resources/views/table/empty-state-domains.blade.php
Normal file
67
resources/views/table/empty-state-domains.blade.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Custom Domain Visual / Logo Grid -->
|
||||
<div class="relative flex items-center justify-center h-28 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
|
||||
<div class="relative flex items-center gap-4 z-10">
|
||||
<!-- Icon 1: Domain Globe -->
|
||||
<div class="relative flex items-center justify-center w-12 h-12 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-sm transition-transform duration-300 hover:-translate-y-1">
|
||||
<!-- Ping green dot in the corner to show activity -->
|
||||
<span class="absolute -top-1 -right-1 flex h-2.5 w-2.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
||||
</span>
|
||||
<svg class="w-6 h-6 text-neutral-600 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Connection Line with flowing green dash animation -->
|
||||
<div class="flex items-center justify-center w-12">
|
||||
<svg class="w-full h-2" viewBox="0 0 48 8" fill="none">
|
||||
<!-- Base grey dashes -->
|
||||
<line x1="0" y1="4" x2="48" y2="4" stroke="#e5e5e5" class="dark:stroke-neutral-800" stroke-width="2" stroke-linecap="round" stroke-dasharray="4 4" />
|
||||
<!-- Green flowing dashes on top -->
|
||||
<line x1="0" y1="4" x2="48" y2="4" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 8" style="animation: fsu-flow-green 1.4s linear infinite;" />
|
||||
</svg>
|
||||
<style>
|
||||
@keyframes fsu-flow-green {
|
||||
to {
|
||||
stroke-dashoffset: -12;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
<!-- Icon 2: Verification Badge -->
|
||||
<div class="relative flex items-center justify-center w-12 h-12 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-sm transition-transform duration-300 hover:-translate-y-1">
|
||||
<svg class="w-6 h-6 text-emerald-500 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-md text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ __('filament-short-url::default.empty_state_domain_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_domain_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
@if(!isset($hideCreateButton) || !$hideCreateButton)
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_domain_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
173
resources/views/table/empty-state-pixels.blade.php
Normal file
173
resources/views/table/empty-state-pixels.blade.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[500px]">
|
||||
<style>
|
||||
@keyframes infinite-scroll-y {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
.animate-infinite-scroll-y {
|
||||
animation: infinite-scroll-y 22s linear infinite;
|
||||
}
|
||||
.animate-infinite-scroll-y:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Scrolling Mock Pixels Container -->
|
||||
<div class="animate-fade-in h-40 w-full max-w-xs md:max-w-sm overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)] select-none pointer-events-auto">
|
||||
<div class="animate-infinite-scroll-y flex flex-col">
|
||||
<!-- Set 1 -->
|
||||
<!-- Card 1: Meta -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/facebook.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Meta Ads Pixel</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 9876543210</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-blue-50 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
Meta
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Google -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/google.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Google Analytics 4</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: G-9X8Y7Z6W5V</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
Google
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: LinkedIn -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/linkedin.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">LinkedIn Insight Tag</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 1029384756</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-600 dark:text-indigo-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
LinkedIn
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: TikTok -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/tiktok.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">TikTok Pixel</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: C1234567890</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
TikTok
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Set 2 (Duplicated for infinite looping) -->
|
||||
<!-- Card 1: Meta -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/facebook.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Meta Ads Pixel</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 9876543210</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-blue-50 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
Meta
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Google -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/google.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Google Analytics 4</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: G-9X8Y7Z6W5V</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
Google
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: LinkedIn -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/linkedin.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">LinkedIn Insight Tag</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 1029384756</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-600 dark:text-indigo-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
LinkedIn
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: TikTok -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/tiktok.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">TikTok Pixel</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: C1234567890</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
|
||||
TikTok
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-sm text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">{{ __('filament-short-url::default.empty_state_pixel_heading') }}</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_pixel_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_pixel_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
245
resources/views/table/empty-state.blade.php
Normal file
245
resources/views/table/empty-state.blade.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[500px]">
|
||||
<style>
|
||||
@keyframes infinite-scroll-y {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
.animate-infinite-scroll-y {
|
||||
animation: infinite-scroll-y 25s linear infinite;
|
||||
}
|
||||
.animate-infinite-scroll-y:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Scrolling Mock Cards Container -->
|
||||
<div class="animate-fade-in h-40 w-full max-w-xs md:max-w-sm overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)] select-none pointer-events-auto">
|
||||
<div class="animate-infinite-scroll-y flex flex-col">
|
||||
<!-- Set 1 -->
|
||||
<!-- Card 1: YouTube -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/youtube.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/youtube-tour</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">youtube.com/watch...</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
1.4k
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Maps -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/google.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">demo.to/monaco-map</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">maps.google.com/place...</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
890
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: WhatsApp -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/whatsapp.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">link.xyz/whatsapp-chat</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">wa.me/385912345678</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
340
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: Brochure PDF -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/dropbox.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">example.com/brochure-pdf</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">dropbox.com/s/spec-brochure...</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
620
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 5: A/B Split Test -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/facebook.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/promo-campaign</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium italic truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">A/B Split Traffic</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
2.1k
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Set 2 (Duplicated for infinite looping) -->
|
||||
<!-- Card 1: YouTube -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/youtube.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/youtube-tour</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">youtube.com/watch...</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
1.4k
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Maps -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/google.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">demo.to/monaco-map</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">maps.google.com/place...</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
890
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: WhatsApp -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/whatsapp.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">link.xyz/whatsapp-chat</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">wa.me/385912345678</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
340
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: Brochure PDF -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/dropbox.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">example.com/brochure-pdf</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">dropbox.com/s/spec-brochure...</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
620
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card 5: A/B Split Test -->
|
||||
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/facebook.com.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/promo-campaign</div>
|
||||
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium italic truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">A/B Split Traffic</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
|
||||
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
2.1k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-sm text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">{{ __('filament-short-url::default.empty_state_heading') }}</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
229
resources/views/table/metadata-badges.blade.php
Normal file
229
resources/views/table/metadata-badges.blade.php
Normal file
@@ -0,0 +1,229 @@
|
||||
@php
|
||||
// Get the user relationship config
|
||||
$userModel = config('filament-short-url.user.model', \App\Models\User::class);
|
||||
$nameColumn = config('filament-short-url.user.name_column', 'name');
|
||||
$emailColumn = config('filament-short-url.user.email_column', 'email');
|
||||
$avatarColumn = config('filament-short-url.user.avatar_column', 'avatar_url');
|
||||
|
||||
$user = $record->user;
|
||||
$userName = null;
|
||||
$userEmail = null;
|
||||
$avatarUrl = null;
|
||||
|
||||
if ($user) {
|
||||
$userName = $user->{$nameColumn} ?? null;
|
||||
$userEmail = $user->{$emailColumn} ?? null;
|
||||
|
||||
// Try to get avatar URL
|
||||
if ($avatarColumn && method_exists($user, $avatarColumn)) {
|
||||
$avatarUrl = $user->{$avatarColumn}();
|
||||
} elseif ($avatarColumn && isset($user->{$avatarColumn})) {
|
||||
$avatarUrl = $user->{$avatarColumn};
|
||||
} elseif ($user instanceof \Filament\Models\Contracts\HasAvatar) {
|
||||
$avatarUrl = $user->getFilamentAvatarUrl();
|
||||
} elseif (method_exists($user, 'getFilamentAvatarUrl')) {
|
||||
$avatarUrl = $user->getFilamentAvatarUrl();
|
||||
}
|
||||
|
||||
// Fallback to Gravatar if no avatar URL found
|
||||
if (empty($avatarUrl) && !empty($userEmail)) {
|
||||
$avatarUrl = 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($userEmail))) . '?d=mp&s=80';
|
||||
}
|
||||
}
|
||||
|
||||
// Relative Date Calculations for "1h", "2d" etc.
|
||||
$diff = $record->created_at->diff(now());
|
||||
if ($diff->y > 0) {
|
||||
$shortTime = $diff->y . 'y';
|
||||
} elseif ($diff->m > 0) {
|
||||
$shortTime = $diff->m . 'mo';
|
||||
} elseif ($diff->d > 0) {
|
||||
$shortTime = $diff->d . 'd';
|
||||
} elseif ($diff->h > 0) {
|
||||
$shortTime = $diff->h . 'h';
|
||||
} elseif ($diff->i > 0) {
|
||||
$shortTime = $diff->i . 'm';
|
||||
} else {
|
||||
$shortTime = max(1, $diff->s) . 's';
|
||||
}
|
||||
|
||||
// Detailed relative format for hover: "1 hour, 52 minutes, 16 seconds ago"
|
||||
$parts = [];
|
||||
if ($diff->y > 0) {
|
||||
$parts[] = $diff->y . ' ' . str('year')->plural($diff->y);
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
$parts[] = $diff->m . ' ' . str('month')->plural($diff->m);
|
||||
}
|
||||
if ($diff->d > 0) {
|
||||
$parts[] = $diff->d . ' ' . str('day')->plural($diff->d);
|
||||
}
|
||||
if ($diff->h > 0) {
|
||||
$parts[] = $diff->h . ' ' . str('hour')->plural($diff->h);
|
||||
}
|
||||
if ($diff->i > 0) {
|
||||
$parts[] = $diff->i . ' ' . str('minute')->plural($diff->i);
|
||||
}
|
||||
if ($diff->s > 0) {
|
||||
$parts[] = $diff->s . ' ' . str('second')->plural($diff->s);
|
||||
}
|
||||
|
||||
// Slice to top 3 units for a readable detailed string
|
||||
$detailedParts = array_slice($parts, 0, 3);
|
||||
$detailedRelative = !empty($detailedParts)
|
||||
? implode(', ', $detailedParts) . ' ago'
|
||||
: 'just now';
|
||||
|
||||
// Timezone offset badge (e.g. GMT+2)
|
||||
$offsetHours = $record->created_at->offsetHours;
|
||||
$timezoneBadge = 'GMT' . ($offsetHours >= 0 ? '+' : '') . $offsetHours;
|
||||
|
||||
// Absolute date (e.g. Jun 5, 2026, 1:09:29 AM)
|
||||
$absoluteDate = $record->created_at->format('M j, Y, g:i:s A');
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<!-- Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{ number_format($record->total_visits) }} {{ __('filament-short-url::default.badge_clicks') }}
|
||||
</span>
|
||||
|
||||
<!-- Unique Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{{ number_format($record->unique_visits) }} {{ __('filament-short-url::default.badge_unique') }}
|
||||
</span>
|
||||
|
||||
<!-- QR Scans Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
{{ number_format($record->qr_scans) }} {{ __('filament-short-url::default.badge_qr_scans') }}
|
||||
</span>
|
||||
|
||||
<!-- Date Added Badge (with Hover Card) -->
|
||||
<div class="relative inline-block" x-data="{ open: false }">
|
||||
<div @mouseenter="open = true" @mouseleave="open = false"
|
||||
class="cursor-pointer inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ $shortTime }}
|
||||
</div>
|
||||
|
||||
<!-- Popover Card for Date -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-1 scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-1 scale-95"
|
||||
class="absolute z-[100] bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 pointer-events-none overflow-hidden rounded-xl shadow-sm min-w-max"
|
||||
x-cloak>
|
||||
<div class="flex max-w-[360px] flex-col gap-2 px-2.5 py-2 text-left text-xs">
|
||||
<span class="text-neutral-500 dark:text-neutral-400 cursor-default">{{ $detailedRelative }}</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr class="before:bg-bg-emphasis relative select-none before:absolute before:-inset-x-1 before:inset-y-0 before:rounded before:opacity-0 before:content-[''] hover:cursor-copy hover:before:opacity-60 active:before:opacity-100">
|
||||
<td class="relative py-0.5">
|
||||
<span class="text-neutral-500 dark:text-neutral-400 truncate bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 rounded px-1 font-mono text-[11px]" title="{{ config('app.timezone') }}">{{ $timezoneBadge }}</span>
|
||||
</td>
|
||||
<td class="text-neutral-800 dark:text-neutral-200 relative whitespace-nowrap py-0.5 pl-2">{{ $absoluteDate }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($user)
|
||||
<!-- User Avatar with Hover Card -->
|
||||
<div class="relative inline-block" x-data="{ open: false }">
|
||||
<div @mouseenter="open = true" @mouseleave="open = false" class="cursor-pointer flex items-center justify-center">
|
||||
<img src="{{ $avatarUrl }}"
|
||||
alt="{{ $userName }}"
|
||||
class="w-6 h-6 rounded-full border border-neutral-200 dark:border-neutral-700 bg-neutral-100 object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Popover Card for User -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-1 scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-1 scale-95"
|
||||
class="absolute z-[100] bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 pointer-events-none overflow-hidden rounded-xl shadow-sm min-w-max"
|
||||
x-cloak>
|
||||
<div class="w-full p-3 text-left">
|
||||
<img alt="Avatar for {{ $userName }}"
|
||||
referrerpolicy="no-referrer"
|
||||
class="rounded-full border border-neutral-300 h-8 w-8 object-cover"
|
||||
draggable="false"
|
||||
src="{{ $avatarUrl }}">
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<p class="text-sm font-semibold text-neutral-700 dark:text-neutral-200">{{ $userName }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p>{{ $userEmail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Expiry / Single Use Badge -->
|
||||
@if ($record->expires_at)
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ __('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]) }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{-- Keyboard Shortcuts Handler (Alpine.js) --}}
|
||||
<div x-data="{
|
||||
hovered: false,
|
||||
init() {
|
||||
const card = this.$el.closest('.short-url-card');
|
||||
if (card) {
|
||||
this.hovered = card.matches(':hover');
|
||||
card.addEventListener('mouseenter', () => this.hovered = true);
|
||||
card.addEventListener('mouseleave', () => this.hovered = false);
|
||||
}
|
||||
},
|
||||
handleKeyup(event) {
|
||||
if (!this.hovered) return;
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return;
|
||||
if (document.activeElement.closest('.fi-modal-window') || document.activeElement.closest('.fi-modal')) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
|
||||
if (key === 'e') {
|
||||
this.$wire.mountTableAction('edit', '{{ $record->id }}');
|
||||
} else if (key === 'q') {
|
||||
this.$wire.mountTableAction('qrCode', '{{ $record->id }}');
|
||||
} else if (key === 'i') {
|
||||
this.$wire.mountTableAction('share', '{{ $record->id }}');
|
||||
} else if (key === 's') {
|
||||
window.location.href = '{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}';
|
||||
} else if (key === 'x') {
|
||||
this.$wire.mountTableAction('delete', '{{ $record->id }}');
|
||||
}
|
||||
}
|
||||
}"
|
||||
@keyup.window="handleKeyup($event)"
|
||||
class="hidden">
|
||||
</div>
|
||||
</div>
|
||||
261
resources/views/table/qr-code-modal.blade.php
Normal file
261
resources/views/table/qr-code-modal.blade.php
Normal file
@@ -0,0 +1,261 @@
|
||||
@php
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$qrTargetUrl = $shortUrl . '?source=qr';
|
||||
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
|
||||
$urlKey = $record->url_key;
|
||||
$eid = 'fsu_' . substr(md5($shortUrl), 0, 8);
|
||||
|
||||
$qrHelperText = __('filament-short-url::default.qr_modal_helper');
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg');
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png');
|
||||
$closeButtonText = __('filament-short-url::default.close_button');
|
||||
$copyLinkText = __('filament-short-url::default.action_copy');
|
||||
$openLinkText = __('filament-short-url::default.open_link');
|
||||
|
||||
$qrDefaults = $record->getQrOptions();
|
||||
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
|
||||
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
|
||||
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
|
||||
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
|
||||
|
||||
$dotsOptions = $isGrad ? [
|
||||
'type' => $dotStyle,
|
||||
'gradient' => [
|
||||
'type' => $qrDefaults['gradient_type'] ?? 'linear',
|
||||
'colorStops' => [
|
||||
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
|
||||
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
|
||||
],
|
||||
],
|
||||
] : [
|
||||
'type' => $dotStyle,
|
||||
'color' => $fgColor,
|
||||
];
|
||||
|
||||
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
|
||||
|
||||
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
|
||||
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
|
||||
|
||||
$cornersSquareOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeSquareStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$cornersDotOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeDotStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$logo = $qrDefaults['logo'] ?? null;
|
||||
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
|
||||
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
'margin' => $qrDefaults['margin'] ?? 1,
|
||||
'dotsOptions' => $dotsOptions,
|
||||
'backgroundOptions' => ['color' => $bgColor],
|
||||
'cornersSquareOptions' => $cornersSquareOptions,
|
||||
'cornersDotOptions' => $cornersDotOptions,
|
||||
'image' => $logo ?: null,
|
||||
'imageOptions' => [
|
||||
'crossOrigin' => 'anonymous',
|
||||
'hideBackgroundDots' => $logoHideBackground,
|
||||
'imageSize' => $logoSize,
|
||||
'margin' => $logoMargin,
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div data-qr-options="{{ $qrOptionsJson }}" x-data="{
|
||||
init() {
|
||||
const el = this.$el;
|
||||
const eid = '{{ $eid }}';
|
||||
const qrTargetUrl = '{{ $qrTargetUrl }}';
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.loadScript().then(() => {
|
||||
const canvas = document.getElementById(eid + '_qr_canvas');
|
||||
if (canvas && !canvas.innerHTML) {
|
||||
const opts = JSON.parse(el.getAttribute('data-qr-options'));
|
||||
opts.data = qrTargetUrl;
|
||||
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
window['qr_' + eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + eid].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.onerror = () => {
|
||||
opts.image = null;
|
||||
window['qr_' + eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + eid].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_' + eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + eid].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
loadScript() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s2.onload = () => resolve();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){$dispatch('close-modal',{id:m[1]})}else{$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{{ e($closeButtonText) }}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{{ $qrHelperText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{{ e($destHost) }}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $shortUrl }}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{{ e($eid) }}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{{ e($shortUrl) }}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{{ e($eid) }}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ e($copyLinkText) }}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V6a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<a href="{{ $shortUrl }}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ e($openLinkText) }}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (Visible immediately) -->
|
||||
<div id="{{ e($eid) }}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{{ e($eid) }}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ e($eid) }}']?.download({ name: '{{ e($urlKey) }}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadSvgText }}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ e($eid) }}']?.download({ name: '{{ e($urlKey) }}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadPngText }}</button>
|
||||
</div>
|
||||
</div>
|
||||
36
resources/views/table/share-copy-field.blade.php
Normal file
36
resources/views/table/share-copy-field.blade.php
Normal file
@@ -0,0 +1,36 @@
|
||||
@php
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$shareCopiedMsg = __('filament-short-url::default.share_copied');
|
||||
$shareCopyBtnText = __('filament-short-url::default.share_copy');
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-2 relative mt-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="{{ e($shortUrl) }}"
|
||||
id="share_link_input_{{ $record->id }}"
|
||||
class="flex-1 min-w-0 block w-full px-3.5 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-gray-100 text-sm focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 focus:outline-none">
|
||||
|
||||
<button onclick="
|
||||
const input = document.getElementById('share_link_input_{{ $record->id }}');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification()
|
||||
.title('{{ e($shareCopiedMsg) }}')
|
||||
.success()
|
||||
.send();
|
||||
} else if (typeof Alpine !== 'undefined') {
|
||||
Alpine.store('filament-notifications')?.send({
|
||||
status: 'success',
|
||||
title: '{{ e($shareCopiedMsg) }}'
|
||||
});
|
||||
}
|
||||
"
|
||||
class="flex-shrink-0 inline-flex items-center justify-center gap-1.5 px-4 py-2.5 bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-white text-white dark:text-gray-950 font-semibold text-sm rounded-lg shadow-sm hover:shadow transition duration-200">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ $shareCopyBtnText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
61
resources/views/table/share-platforms.blade.php
Normal file
61
resources/views/table/share-platforms.blade.php
Normal file
@@ -0,0 +1,61 @@
|
||||
@php
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$encodedUrl = urlencode($shortUrl);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-6 overflow-x-auto pb-4 pt-1 scroll-smooth" style="scrollbar-width: thin; -ms-overflow-style: none;">
|
||||
<!-- Messenger -->
|
||||
<a href="fb-messenger://share/?link={{ $encodedUrl }}" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-tr from-[#006aff] via-[#00b2ff] to-[#00d6ff] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.14 2 11.25c0 2.9 1.45 5.48 3.73 7.08v3.67c0 .24.23.4.43.27l4.07-2.3c.57.16 1.17.25 1.77.25 5.52 0 10-4.14 10-9.25S17.52 2 12 2zm1.09 11.95l-2.43-2.6-4.73 2.6 5.19-5.52 2.47 2.63 4.7-2.63-5.2 5.52z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Messenger</span>
|
||||
</a>
|
||||
<!-- Facebook -->
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ $encodedUrl }}" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#1877f2] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Facebook</span>
|
||||
</a>
|
||||
<!-- WhatsApp -->
|
||||
<a href="https://api.whatsapp.com/send?text={{ $encodedUrl }}" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#25d366] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">WhatsApp</span>
|
||||
</a>
|
||||
<!-- Twitter/X -->
|
||||
<a href="https://twitter.com/intent/tweet?url={{ $encodedUrl }}" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0f1419] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Twitter (X)</span>
|
||||
</a>
|
||||
<!-- LinkedIn -->
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ $encodedUrl }}" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0077b5] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">LinkedIn</span>
|
||||
</a>
|
||||
<!-- Email -->
|
||||
<a href="mailto:?body={{ $encodedUrl }}" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#6b7280] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Email</span>
|
||||
</a>
|
||||
</div>
|
||||
53
resources/views/table/url-key-column.blade.php
Normal file
53
resources/views/table/url-key-column.blade.php
Normal file
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$copiedMsg = __('filament-short-url::default.qr_copied');
|
||||
$tooltipCopy = __('filament-short-url::default.action_copy');
|
||||
$destHost = parse_url($record->destination_url, PHP_URL_HOST);
|
||||
@endphp
|
||||
|
||||
<div onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = '{{ e($shortUrl) }}';
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textCopyArea = document.createElement('textarea');
|
||||
textCopyArea.value = text;
|
||||
textCopyArea.style.position = 'fixed';
|
||||
textCopyArea.style.left = '-999999px';
|
||||
textCopyArea.style.top = '-999999px';
|
||||
document.body.appendChild(textCopyArea);
|
||||
textCopyArea.focus();
|
||||
textCopyArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (err) {}
|
||||
textCopyArea.remove();
|
||||
}
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification()
|
||||
.title('{{ e($copiedMsg) }}')
|
||||
.success()
|
||||
.send();
|
||||
} else if (window.Alpine) {
|
||||
window.Alpine.store('filament-notifications')?.send({
|
||||
status: 'success',
|
||||
title: '{{ e($copiedMsg) }}'
|
||||
});
|
||||
}
|
||||
" class="flex items-center gap-3 cursor-pointer w-fit">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{{ e($destHost) }}.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<span class="text-[#2a5bd7] text-[16px] font-bold leading-6 break-all line-clamp-1">
|
||||
{{ $shortUrl }}
|
||||
</span>
|
||||
<span title="{{ e($tooltipCopy) }}" class="w-8 h-8 rounded-full flex items-center justify-center bg-[#f4f4f5] hover:bg-[#e4e4e7] dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0 focus:outline-none">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.warning_title') ?? 'Redirect Warning' }}</title>
|
||||
<title>{{ __('filament-short-url::default.warning_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -49,10 +49,10 @@
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }}
|
||||
{{ __('filament-short-url::default.warning_title') }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.warning_description') ?? 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:' }}
|
||||
{{ __('filament-short-url::default.warning_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<!-- Continue Button (Primary Accent) -->
|
||||
<a href="{{ request()->fullUrlWithQuery(['confirmed' => 1]) }}"
|
||||
class="w-full py-3.5 rounded-xl bg-rose-600 hover:bg-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }}</span>
|
||||
<span>{{ __('filament-short-url::default.warning_btn_continue') }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
@@ -99,7 +99,7 @@
|
||||
<!-- Go Back Button (Secondary) -->
|
||||
<button onclick="window.history.back()"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm">
|
||||
{{ __('filament-short-url::default.warning_btn_back') ?? 'Go Back' }}
|
||||
{{ __('filament-short-url::default.warning_btn_back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
108
resources/views/webhook-payload-example.blade.php
Normal file
108
resources/views/webhook-payload-example.blade.php
Normal file
@@ -0,0 +1,108 @@
|
||||
@php
|
||||
$rawJson = '{
|
||||
"event": "visited",
|
||||
"timestamp": "2026-06-04T12:00:00+02:00",
|
||||
"short_url": {
|
||||
"id": 12,
|
||||
"destination_url": "https://example.com/some-page",
|
||||
"url_key": "promo26",
|
||||
"short_url": "https://yoursite.com/s/promo26",
|
||||
"total_visits": 150,
|
||||
"unique_visits": 120
|
||||
},
|
||||
"visit": {
|
||||
"id": 345,
|
||||
"visited_at": "2026-06-04T12:00:00+02:00",
|
||||
"device_type": "mobile",
|
||||
"browser": "Chrome",
|
||||
"browser_version": "120.0",
|
||||
"operating_system": "Android",
|
||||
"operating_system_version": "14",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"city": "Warsaw",
|
||||
"referer_url": "https://t.co/",
|
||||
"referer_host": "t.co",
|
||||
"utm_source": "twitter",
|
||||
"utm_medium": "social",
|
||||
"utm_campaign": "summer_sale",
|
||||
"utm_term": null,
|
||||
"utm_content": "banner_ad",
|
||||
"is_qr_scan": false,
|
||||
"browser_language": "pl"
|
||||
}
|
||||
}';
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2 mt-4">
|
||||
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
|
||||
{{ __('filament-short-url::default.webhook_show_payload') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
copied: false,
|
||||
rawJson: @js($rawJson),
|
||||
copy() {
|
||||
navigator.clipboard.writeText(this.rawJson);
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 2000);
|
||||
}
|
||||
}"
|
||||
style="position: relative; overflow: hidden; border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #18181b; padding: 1.25rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);"
|
||||
>
|
||||
<!-- Copy Button in Top Right -->
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copy"
|
||||
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
|
||||
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
|
||||
style="position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; height: 2rem; width: 2rem; border-radius: 0.5rem; background-color: rgba(255, 255, 255, 0.08); color: #a1a1aa; border: none; cursor: pointer; transition: all 0.2s;"
|
||||
title="Copy payload to clipboard"
|
||||
>
|
||||
<!-- Copy Icon -->
|
||||
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
<!-- Check Icon -->
|
||||
<svg x-show="copied" x-cloak style="height: 1rem; width: 1rem; color: #34d399;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Syntax Highlighted Payload -->
|
||||
<pre style="margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #d4d4d8; overflow-x: auto; white-space: pre-wrap; word-break: break-all; padding-right: 2.5rem;"><code style="font-family: inherit; font-size: inherit; color: inherit;">{
|
||||
<span style="color: #f43f5e;">"event"</span>: <span style="color: #eab308;">"visited"</span>,
|
||||
<span style="color: #f43f5e;">"timestamp"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
|
||||
<span style="color: #f43f5e;">"short_url"</span>: {
|
||||
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">12</span>,
|
||||
<span style="color: #f43f5e;">"destination_url"</span>: <span style="color: #eab308;">"https://example.com/some-page"</span>,
|
||||
<span style="color: #f43f5e;">"url_key"</span>: <span style="color: #eab308;">"promo26"</span>,
|
||||
<span style="color: #f43f5e;">"short_url"</span>: <span style="color: #eab308;">"https://yoursite.com/s/promo26"</span>,
|
||||
<span style="color: #f43f5e;">"total_visits"</span>: <span style="color: #c084fc;">150</span>,
|
||||
<span style="color: #f43f5e;">"unique_visits"</span>: <span style="color: #c084fc;">120</span>
|
||||
},
|
||||
<span style="color: #f43f5e;">"visit"</span>: {
|
||||
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">345</span>,
|
||||
<span style="color: #f43f5e;">"visited_at"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
|
||||
<span style="color: #f43f5e;">"device_type"</span>: <span style="color: #eab308;">"mobile"</span>,
|
||||
<span style="color: #f43f5e;">"browser"</span>: <span style="color: #eab308;">"Chrome"</span>,
|
||||
<span style="color: #f43f5e;">"browser_version"</span>: <span style="color: #eab308;">"120.0"</span>,
|
||||
<span style="color: #f43f5e;">"operating_system"</span>: <span style="color: #eab308;">"Android"</span>,
|
||||
<span style="color: #f43f5e;">"operating_system_version"</span>: <span style="color: #eab308;">"14"</span>,
|
||||
<span style="color: #f43f5e;">"country"</span>: <span style="color: #eab308;">"Poland"</span>,
|
||||
<span style="color: #f43f5e;">"country_code"</span>: <span style="color: #eab308;">"PL"</span>,
|
||||
<span style="color: #f43f5e;">"city"</span>: <span style="color: #eab308;">"Warsaw"</span>,
|
||||
<span style="color: #f43f5e;">"referer_url"</span>: <span style="color: #eab308;">"https://t.co/"</span>,
|
||||
<span style="color: #f43f5e;">"referer_host"</span>: <span style="color: #eab308;">"t.co"</span>,
|
||||
<span style="color: #f43f5e;">"utm_source"</span>: <span style="color: #eab308;">"twitter"</span>,
|
||||
<span style="color: #f43f5e;">"utm_medium"</span>: <span style="color: #eab308;">"social"</span>,
|
||||
<span style="color: #f43f5e;">"utm_campaign"</span>: <span style="color: #eab308;">"summer_sale"</span>,
|
||||
<span style="color: #f43f5e;">"utm_term"</span>: <span style="color: #60a5fa;">null</span>,
|
||||
<span style="color: #f43f5e;">"utm_content"</span>: <span style="color: #eab308;">"banner_ad"</span>,
|
||||
<span style="color: #f43f5e;">"is_qr_scan"</span>: <span style="color: #60a5fa;">false</span>,
|
||||
<span style="color: #f43f5e;">"browser_language"</span>: <span style="color: #eab308;">"pl"</span>
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
16
resources/views/widgets/chart-empty.blade.php
Normal file
16
resources/views/widgets/chart-empty.blade.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section :icon="$icon">
|
||||
<x-slot name="heading">
|
||||
{{ $heading }}
|
||||
</x-slot>
|
||||
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $message }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
164
resources/views/widgets/device-breakdown.blade.php
Normal file
164
resources/views/widgets/device-breakdown.blade.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
<!-- Tabs Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('devices')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'devices' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_devices') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('browsers')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'browsers' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_browsers') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('os')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'os' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_os') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="relative p-6 min-h-[280px]">
|
||||
<!-- Spinner -->
|
||||
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
<!-- Devices List -->
|
||||
@if ($activeTab === 'devices')
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByDevice as $device => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
|
||||
$deviceLabel = match (strtolower($device)) {
|
||||
'desktop' => __('filament-short-url::default.stats_device_desktop'),
|
||||
'mobile' => __('filament-short-url::default.stats_device_mobile'),
|
||||
'tablet' => __('filament-short-url::default.stats_device_tablet'),
|
||||
default => ucfirst($device),
|
||||
};
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ addslashes($device) }}' })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
@if (strtolower($device) === 'desktop')
|
||||
<x-filament::icon icon="heroicon-m-computer-desktop" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($device) === 'mobile')
|
||||
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($device) === 'tablet')
|
||||
<x-filament::icon icon="heroicon-m-device-tablet" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
@else
|
||||
<x-filament::icon icon="heroicon-m-question-mark-circle" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
@endif
|
||||
<span>{{ $deviceLabel }}</span>
|
||||
</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_device_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Browsers List -->
|
||||
@if ($activeTab === 'browsers')
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByBrowser as $browser => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ addslashes($browser) }}' })">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
|
||||
</div>
|
||||
@if (!empty($visitsByBrowserVersion[$browser]))
|
||||
@php
|
||||
$versionsList = [];
|
||||
foreach ($visitsByBrowserVersion[$browser] as $ver => $vCount) {
|
||||
$versionsList[] = "v{$ver} ({$vCount})";
|
||||
}
|
||||
@endphp
|
||||
<span class="text-[10px] text-gray-400 dark:text-gray-500 ml-6 truncate">
|
||||
{{ implode(', ', $versionsList) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_browser_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- OS List -->
|
||||
@if ($activeTab === 'os')
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByOs as $os => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ addslashes($os) }}' })">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-cpu-chip'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
|
||||
</div>
|
||||
@if (!empty($visitsByOsVersion[$os]))
|
||||
@php
|
||||
$versionsList = [];
|
||||
foreach ($visitsByOsVersion[$os] as $ver => $vCount) {
|
||||
$versionsList[] = "v{$ver} ({$vCount})";
|
||||
}
|
||||
@endphp
|
||||
<span class="text-[10px] text-gray-400 dark:text-gray-500 ml-6 truncate">
|
||||
{{ implode(', ', $versionsList) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_os_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
1
resources/views/widgets/empty.blade.php
Normal file
1
resources/views/widgets/empty.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
<div></div>
|
||||
133
resources/views/widgets/geo-breakdown.blade.php
Normal file
133
resources/views/widgets/geo-breakdown.blade.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
<!-- Tabs Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('countries')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'countries' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_countries') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('cities')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'cities' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_cities') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('languages')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'languages' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_languages') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="relative p-6 min-h-[280px]">
|
||||
<!-- Spinner -->
|
||||
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
<!-- Countries List -->
|
||||
@if ($activeTab === 'countries')
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCountry as $code => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
|
||||
$translatedCountry = __('filament-short-url::countries.' . strtoupper($code));
|
||||
if (!$translatedCountry || $translatedCountry === 'filament-short-url::countries.' . strtoupper($code)) {
|
||||
$translatedCountry = strtoupper($code);
|
||||
}
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ addslashes($code) }}' })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
@if ($code)
|
||||
<img src="https://flagcdn.com/h20/{{ strtolower($code) }}.webp" class="w-5 h-auto rounded-sm inline-block" alt="{{ $translatedCountry }}" />
|
||||
@endif
|
||||
<span>{{ $translatedCountry }}</span>
|
||||
</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_country_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Cities List -->
|
||||
@if ($activeTab === 'cities')
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: '{{ addslashes($city) }}' })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_city_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Languages List -->
|
||||
@if ($activeTab === 'languages')
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByLanguage as $langCode => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
|
||||
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: '{{ addslashes($langCode) }}' })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_language_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
107
resources/views/widgets/security-breakdown.blade.php
Normal file
107
resources/views/widgets/security-breakdown.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
<!-- Tabs Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('bots')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'bots' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_security_bot_ratio') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('vpn')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'vpn' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_security_vpn_blocked') }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="relative p-6 min-h-[280px]">
|
||||
<!-- Spinner -->
|
||||
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
<!-- Bot Clicks Tab -->
|
||||
@if ($activeTab === 'bots')
|
||||
<div class="space-y-4">
|
||||
{{-- Human Clicks --}}
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span>{{ __('filament-short-url::default.stats_security_real_users') }}</span>
|
||||
</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{{ number_format($humanClicks) }}
|
||||
<span class="text-gray-400 dark:text-gray-500">({{ $humanPercentage }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $humanPercentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Bot Clicks --}}
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-purple-500"></span>
|
||||
<span>{{ __('filament-short-url::default.stats_security_bots') }}</span>
|
||||
</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{{ number_format($botClicks) }}
|
||||
<span class="text-gray-400 dark:text-gray-500">({{ $botPercentage }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-purple-500 transition-all duration-500" style="width: {{ $botPercentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- VPN / Proxy Blocked Tab -->
|
||||
@if ($activeTab === 'vpn')
|
||||
<div class="mt-2">
|
||||
@if ($proxyClicks === 0)
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 text-emerald-500 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-check-circle" class="h-6 w-6" />
|
||||
</div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||
{{ __('filament-short-url::default.stats_security_no_vpn_blocks') }}
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-rose-600 dark:text-rose-400 font-mono">
|
||||
{{ number_format($proxyClicks) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_security_proxy_clicks') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-rose-50 px-3 py-2 text-rose-700 dark:bg-rose-950/30 dark:text-rose-400">
|
||||
<div class="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<x-filament::icon icon="heroicon-o-exclamation-triangle" class="h-4 w-4" />
|
||||
<span>VPN / Proxy Traffic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-rose-500 transition-all duration-500" style="width: {{ $proxyPercentage }}%"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
205
resources/views/widgets/sources-breakdown.blade.php
Normal file
205
resources/views/widgets/sources-breakdown.blade.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
<!-- Tabs Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('referrers')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'referrers' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_referers') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveTab('utm')"
|
||||
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'utm' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
|
||||
UTM Parameters
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Submenu Header (only for UTM) -->
|
||||
@if ($activeTab === 'utm')
|
||||
<div class="flex items-center gap-1.5 border-b border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 px-6 py-2.5">
|
||||
<button type="button"
|
||||
wire:click="setActiveSubTab('sources')"
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'sources' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_utm_source') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveSubTab('mediums')"
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'mediums' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveSubTab('campaigns')"
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'campaigns' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveSubTab('terms')"
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'terms' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_utm_term') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="setActiveSubTab('contents')"
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'contents' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
|
||||
{{ __('filament-short-url::default.stats_breakdown_utm_content') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Body -->
|
||||
<div class="relative p-6 min-h-[280px]">
|
||||
<!-- Spinner -->
|
||||
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
<!-- Referrers Tab -->
|
||||
@if ($activeTab === 'referrers')
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: '{{ addslashes($referer) }}' })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $referer }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_referer_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- UTM Parameters Tab -->
|
||||
@if ($activeTab === 'utm')
|
||||
<div class="space-y-4">
|
||||
<!-- Sub-tab Content -->
|
||||
<div>
|
||||
@if ($activeSubTab === 'sources')
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmSources as $source => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: '{{ addslashes($source) }}' })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_utm_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif ($activeSubTab === 'mediums')
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmMediums as $medium => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: '{{ addslashes($medium) }}' })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_utm_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif ($activeSubTab === 'campaigns')
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmCampaigns as $campaign => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: '{{ addslashes($campaign) }}' })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_utm_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif ($activeSubTab === 'terms')
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmTerms as $term => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: '{{ addslashes($term) }}' })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $term }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_utm_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif ($activeSubTab === 'contents')
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmContents as $content => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: '{{ addslashes($content) }}' })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $content }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_no_utm_data') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
38
resources/views/widgets/variants.blade.php
Normal file
38
resources/views/widgets/variants.blade.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section icon="heroicon-o-arrow-path-rounded-square">
|
||||
<x-slot name="heading">
|
||||
A/B Testing Variants
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4 mt-2">
|
||||
@foreach ($visitsByVariant as $variant => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
|
||||
$expectedWeight = null;
|
||||
if (is_array($rotationVariants)) {
|
||||
foreach ($rotationVariants as $v) {
|
||||
if (($v['label'] ?? '') === $variant) {
|
||||
$expectedWeight = $v['weight'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: '{{ addslashes($variant) }}' })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $variant }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{{ number_format($count) }}
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
({{ $pct }}%{{ $expectedWeight !== null ? ' vs expected ' . $expectedWeight . '%' : '' }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
@@ -1,257 +0,0 @@
|
||||
@php
|
||||
$deviceIcons = [
|
||||
'desktop' => 'heroicon-m-computer-desktop',
|
||||
'mobile' => 'heroicon-m-device-phone-mobile',
|
||||
'tablet' => 'heroicon-m-device-tablet',
|
||||
'robot' => 'heroicon-m-cpu-chip',
|
||||
];
|
||||
|
||||
$browserIcons = [
|
||||
'Chrome' => 'heroicon-m-globe-alt',
|
||||
'Firefox' => 'heroicon-m-globe-americas',
|
||||
'Safari' => 'heroicon-m-globe-europe-africa',
|
||||
'Edge' => 'heroicon-m-globe-asia-australia',
|
||||
'Opera' => 'heroicon-m-bolt',
|
||||
'Internet Explorer' => 'heroicon-m-wrench',
|
||||
'Samsung Browser' => 'heroicon-m-device-phone-mobile',
|
||||
];
|
||||
|
||||
$osIcons = [
|
||||
'Windows' => 'heroicon-m-computer-desktop',
|
||||
'OS X' => 'heroicon-m-computer-desktop',
|
||||
'macOS' => 'heroicon-m-computer-desktop',
|
||||
'iOS' => 'heroicon-m-device-phone-mobile',
|
||||
'Android' => 'heroicon-m-device-phone-mobile',
|
||||
'Linux' => 'heroicon-m-cpu-chip',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
<div class="space-y-6">
|
||||
{{-- Row 1: Devices, Browsers, Operating Systems --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
|
||||
{{-- Devices --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@forelse ($visitsByDevice as $device => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Browsers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
|
||||
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByBrowser as $browser => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Operating Systems --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByOs as $os => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Row 2: Referrers, Cities --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- Referers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
|
||||
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
@if ($referer === 'Direct')
|
||||
<span class="font-medium text-gray-500 dark:text-gray-400 truncate">{{ $referer }}</span>
|
||||
@else
|
||||
<a href="https://{{ $referer }}" target="_blank" rel="noopener"
|
||||
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
|
||||
{{ $referer }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cities --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-map-pin" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_cities') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- UTM Campaigns Section --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
|
||||
{{-- UTM Sources --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
|
||||
<x-filament::icon icon="heroicon-o-megaphone" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_source') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmSources as $source => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- UTM Mediums --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
|
||||
<x-filament::icon icon="heroicon-o-tag" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmMediums as $medium => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- UTM Campaigns --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
|
||||
<x-filament::icon icon="heroicon-o-flag" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmCampaigns as $campaign => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
102
resources/views/widgets/visits-chart.blade.php
Normal file
102
resources/views/widgets/visits-chart.blade.php
Normal file
@@ -0,0 +1,102 @@
|
||||
@php
|
||||
use Filament\Widgets\View\Components\ChartWidgetComponent;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$color = $this->getColor();
|
||||
$heading = $this->getHeading();
|
||||
$description = $this->getDescription();
|
||||
$filters = $this->getFilters();
|
||||
$isCollapsible = $this->isCollapsible();
|
||||
$type = $this->getType();
|
||||
$maxHeight = $this->getMaxHeight();
|
||||
$hasMaxHeight = filled($maxHeight) && $maxHeight !== '100%';
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget class="fi-wi-chart">
|
||||
<x-filament::section
|
||||
:description="$description"
|
||||
:heading="$heading"
|
||||
:collapsible="$isCollapsible"
|
||||
>
|
||||
<x-slot name="headerEnd">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Metric Selector -->
|
||||
<x-filament::input.wrapper class="w-44">
|
||||
<x-filament::input.select wire:model.live="activeMetric">
|
||||
<option value="total">{{ __('filament-short-url::default.stats_card_total') }}</option>
|
||||
<option value="unique">{{ __('filament-short-url::default.stats_card_unique') }}</option>
|
||||
<option value="qr">{{ __('filament-short-url::default.stats_card_qr_scans') }}</option>
|
||||
</x-filament::input.select>
|
||||
</x-filament::input.wrapper>
|
||||
|
||||
<!-- Granularity Selector -->
|
||||
@if ($filters)
|
||||
<x-filament::input.wrapper class="w-32">
|
||||
<x-filament::input.select wire:model.live="filter">
|
||||
@foreach ($filters as $value => $label)
|
||||
<option value="{{ $value }}">
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-filament::input.select>
|
||||
</x-filament::input.wrapper>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div
|
||||
@if ($pollingInterval = $this->getPollingInterval())
|
||||
wire:poll.{{ $pollingInterval }}="updateChartData"
|
||||
@endif
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('chart', 'filament/widgets') }}"
|
||||
wire:ignore
|
||||
data-chart-type="{{ $type }}"
|
||||
x-data="chart({
|
||||
cachedData: @js($this->getCachedData()),
|
||||
options: @js($this->getOptions()),
|
||||
type: @js($type),
|
||||
})"
|
||||
{{
|
||||
(new ComponentAttributeBag)
|
||||
->color(ChartWidgetComponent::class, $color)
|
||||
->class([
|
||||
'fi-wi-chart-canvas-ctn',
|
||||
'fi-wi-chart-canvas-ctn-no-aspect-ratio' => $hasMaxHeight,
|
||||
])
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
x-ref="canvas"
|
||||
@style([
|
||||
'width: 100%',
|
||||
'height: 100%; max-height: 100%' => ! $hasMaxHeight,
|
||||
"max-height: {$maxHeight}" => $hasMaxHeight,
|
||||
])
|
||||
></canvas>
|
||||
|
||||
<span
|
||||
x-ref="backgroundColorElement"
|
||||
class="fi-wi-chart-bg-color"
|
||||
></span>
|
||||
|
||||
<span
|
||||
x-ref="borderColorElement"
|
||||
class="fi-wi-chart-border-color"
|
||||
></span>
|
||||
|
||||
<span
|
||||
x-ref="gridColorElement"
|
||||
class="fi-wi-chart-grid-color"
|
||||
></span>
|
||||
|
||||
<span
|
||||
x-ref="textColorElement"
|
||||
class="fi-wi-chart-text-color"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
@@ -1,63 +0,0 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- Countries --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
|
||||
<x-filament::icon icon="heroicon-o-globe-alt" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_countries') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCountry as $country => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
|
||||
$translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country);
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $translatedCountry }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_country_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Languages --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
|
||||
<x-filament::icon icon="heroicon-o-language" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_languages') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByLanguage as $langCode => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
|
||||
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getLanguageTranslation($langCode);
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_language_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
@@ -1,10 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||
@@ -24,22 +23,27 @@ Route::match(
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([AuthenticateShortUrlApi::class])
|
||||
->middleware([
|
||||
AuthenticateShortUrlApi::class,
|
||||
'throttle:60,1',
|
||||
])
|
||||
->group(function () {
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
Route::post('links', [ShortUrlApiController::class, 'store']);
|
||||
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
|
||||
Route::get('links/{idOrKey}', [ShortUrlApiController::class, 'show']);
|
||||
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
|
||||
Route::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
|
||||
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::post('admin/short-url/upload-logo', [ShortUrlApiController::class, 'uploadLogo'])
|
||||
Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::class, 'uploadLogo'])
|
||||
->name('short-url.upload-logo')
|
||||
->middleware(['web']);
|
||||
|
||||
Route::post('admin/short-url/log-debug', function (Request $request) {
|
||||
Log::info('QR DESIGNER JS DEBUG: '.json_encode($request->all()));
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
})->middleware(['web']);
|
||||
|
||||
Route::get('short-url/logo/{filename}', [ShortUrlApiController::class, 'serveLogo'])
|
||||
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
|
||||
->name('short-url.logo');
|
||||
|
||||
if (config('filament-short-url.enable_fallback_route', true)) {
|
||||
Route::fallback(ShortUrlRedirectController::class)
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
}
|
||||
|
||||
22
src/Assets/ShortUrlCss.php
Normal file
22
src/Assets/ShortUrlCss.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Assets;
|
||||
|
||||
use Filament\Support\Assets\Css;
|
||||
use Throwable;
|
||||
|
||||
class ShortUrlCss extends Css
|
||||
{
|
||||
public function getVersion(): string
|
||||
{
|
||||
try {
|
||||
$path = $this->getPublicPath();
|
||||
if (file_exists($path)) {
|
||||
return (string) filemtime($path);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
}
|
||||
|
||||
return parent::getVersion();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,13 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
$start = $date.' 00:00:00';
|
||||
$end = $nextDate.' 00:00:00';
|
||||
|
||||
// 1. Get totals and uniques per short_url_id
|
||||
// Driver-aware boolean count: MySQL/SQLite store booleans as TINYINT (= 1),
|
||||
// PostgreSQL uses a native boolean type (cast to int for aggregation).
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$qrExpr = $driver === 'pgsql'
|
||||
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
|
||||
: 'count(case when is_qr_scan = 1 then 1 end) as qr_scans';
|
||||
|
||||
$totals = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
@@ -54,7 +60,7 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
'short_url_id',
|
||||
DB::raw('count(*) as total'),
|
||||
DB::raw('count(distinct ip_hash) as uniques'),
|
||||
DB::raw("count(case when is_qr_scan = 1 or is_qr_scan = true or is_qr_scan = '1' then 1 end) as qr_scans"),
|
||||
DB::raw($qrExpr),
|
||||
])
|
||||
->groupBy('short_url_id')
|
||||
->get();
|
||||
@@ -79,14 +85,21 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'language_stats' => [],
|
||||
'variant_stats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Helper to fetch and populate category stats
|
||||
// Helper to fetch and populate category stats natively in database GROUP BY
|
||||
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
|
||||
$urlIds = array_keys($statsByUrl);
|
||||
if (empty($urlIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->whereIn('short_url_id', $urlIds)
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '');
|
||||
|
||||
@@ -118,17 +131,18 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
}
|
||||
};
|
||||
|
||||
// Populate all categories via 10 quick indexed database aggregations
|
||||
// Populate all categories via 11 quick indexed database aggregations
|
||||
$populateStats('device_type', 'device_stats');
|
||||
$populateStats('browser', 'browser_stats');
|
||||
$populateStats('operating_system', 'os_stats');
|
||||
$populateStats('country', 'country_stats');
|
||||
$populateStats('country_code', 'country_stats');
|
||||
$populateStats('city', 'city_stats');
|
||||
$populateStats('referer_host', 'referer_stats');
|
||||
$populateStats('utm_source', 'utm_source_stats');
|
||||
$populateStats('utm_medium', 'utm_medium_stats');
|
||||
$populateStats('utm_campaign', 'utm_campaign_stats');
|
||||
$populateStats('browser_language', 'language_stats');
|
||||
$populateStats('selected_variant', 'variant_stats');
|
||||
|
||||
// Write aggregated stats to ShortUrlDailyStats
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
@@ -149,6 +163,7 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
'utm_campaign_stats' => $s['utm_campaign_stats'],
|
||||
'qr_visits_count' => $s['qr_scans'],
|
||||
'language_stats' => $s['language_stats'],
|
||||
'variant_stats' => $s['variant_stats'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Cache\RedisStore;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class SyncBufferedCountersCommand extends Command
|
||||
{
|
||||
@@ -21,19 +21,26 @@ class SyncBufferedCountersCommand extends Command
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// Pull the list atomically to avoid race conditions with incoming clicks
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
// Atomically pull the dirty-ID list so incoming increments during this command
|
||||
// are written to a fresh list rather than being lost. Strategy is driver-aware:
|
||||
// Redis uses RENAME + SMEMBERS (O(N)) for true atomicity; all other stores use
|
||||
// Cache::pull() which is atomic on most drivers (file, database, memcached).
|
||||
$store = Cache::store()->getStore();
|
||||
$isRedis = $store instanceof RedisStore;
|
||||
|
||||
if ($isRedis) {
|
||||
$tempKey = "{$dirtyKey}:temp:".time();
|
||||
try {
|
||||
if (Redis::exists($dirtyKey)) {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
$conn = $store->connection();
|
||||
if ($conn->exists($dirtyKey)) {
|
||||
$conn->rename($dirtyKey, $tempKey);
|
||||
$rawIds = $conn->smembers($tempKey);
|
||||
$conn->del($tempKey);
|
||||
$dirtyIds = $rawIds ?: [];
|
||||
} else {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If key does not exist or rename fails, fallback
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} else {
|
||||
@@ -46,7 +53,7 @@ class SyncBufferedCountersCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter($dirtyIds));
|
||||
$dirtyIds = array_unique(array_filter(array_map('intval', $dirtyIds)));
|
||||
$processed = 0;
|
||||
$updatesToMake = [];
|
||||
|
||||
@@ -76,7 +83,11 @@ class SyncBufferedCountersCommand extends Command
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($updatesToMake, &$processed) {
|
||||
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))->get(['id', 'url_key']);
|
||||
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))
|
||||
->with('customDomain')
|
||||
->get(['id', 'url_key', 'custom_domain_id']);
|
||||
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
|
||||
foreach ($updatesToMake as $id => $deltas) {
|
||||
ShortUrl::where('id', $id)->update([
|
||||
@@ -87,12 +98,24 @@ class SyncBufferedCountersCommand extends Command
|
||||
$processed++;
|
||||
}
|
||||
|
||||
// Bust redirect cache for all host-variant keys so subsequent requests
|
||||
// see fresh counters (for max_visits enforcement etc.)
|
||||
foreach ($shortUrls as $url) {
|
||||
Cache::forget("filament-short-url:{$url->url_key}");
|
||||
$hostsToForget = array_unique(array_filter([
|
||||
'default',
|
||||
$appHost,
|
||||
$url->customDomain?->domain,
|
||||
]));
|
||||
|
||||
foreach ($hostsToForget as $host) {
|
||||
Cache::forget("filament-short-url:{$url->url_key}:{$host}");
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// Restore pulled values in cache so no clicks are lost
|
||||
// DB transaction failed — restore pulled cache values so no clicks are lost.
|
||||
// Increments are used (not set) to safely merge with any new clicks that
|
||||
// arrived during the failed transaction window.
|
||||
foreach ($updatesToMake as $id => $deltas) {
|
||||
$totalKey = "{$prefix}total:{$id}";
|
||||
$uniqueKey = "{$prefix}unique:{$id}";
|
||||
@@ -109,9 +132,10 @@ class SyncBufferedCountersCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Put the IDs back into the dirty list
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
Redis::sadd($dirtyKey, ...array_keys($updatesToMake));
|
||||
// Restore dirty IDs using the same driver-aware strategy
|
||||
if ($isRedis) {
|
||||
$conn = $store->connection();
|
||||
$conn->sadd($dirtyKey, ...array_keys($updatesToMake));
|
||||
} else {
|
||||
$lock = Cache::lock("{$prefix}dirty_ids_lock", 2);
|
||||
$lock->get(function () use ($prefix, $updatesToMake) {
|
||||
|
||||
32
src/Filament/Forms/Components/TrafficSplitter.php
Normal file
32
src/Filament/Forms/Components/TrafficSplitter.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
|
||||
|
||||
use Filament\Forms\Components\Field;
|
||||
|
||||
class TrafficSplitter extends Field
|
||||
{
|
||||
protected string $view = 'filament-short-url::forms.components.traffic-splitter';
|
||||
|
||||
protected string $target = 'rotation_variants';
|
||||
|
||||
public function target(string $target): static
|
||||
{
|
||||
$this->target = $target;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTargetStatePath(): string
|
||||
{
|
||||
$containerPath = $this->getContainer()->getStatePath();
|
||||
|
||||
return $containerPath ? $containerPath.'.'.$this->target : $this->target;
|
||||
}
|
||||
}
|
||||
182
src/Filament/Resources/ShortUrlCustomDomainResource.php
Normal file
182
src/Filament/Resources/ShortUrlCustomDomainResource.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource\Pages\ListShortUrlCustomDomains;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\Layout\Split;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Columns\ViewColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ShortUrlCustomDomainResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlCustomDomain::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
|
||||
|
||||
protected static ?int $navigationSort = 52;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'domain';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.domains_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.domain_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.domains_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 2;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('domain')
|
||||
->label(__('filament-short-url::default.domain_label'))
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->placeholder('e.g. links.acme.com')
|
||||
->rules([
|
||||
'regex:/^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/',
|
||||
])
|
||||
->unique(ignoreRecord: true)
|
||||
->dehydrateStateUsing(fn ($state) => strtolower(trim($state))),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.domain_status_active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Split::make([
|
||||
ViewColumn::make('domain')
|
||||
->label(__('filament-short-url::default.domain_label'))
|
||||
->view('filament-short-url::table.domain-column')
|
||||
->searchable()
|
||||
->grow(),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('filament-short-url::default.domain_status_active')),
|
||||
|
||||
TextColumn::make('is_verified')
|
||||
->label(__('filament-short-url::default.domain_verification_status'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state ? 'success' : 'danger')
|
||||
->formatStateUsing(fn ($state) => $state ? __('filament-short-url::default.domain_status_valid') : __('filament-short-url::default.domain_status_invalid')),
|
||||
]),
|
||||
])
|
||||
->modifyQueryUsing(fn ($query) => $query
|
||||
->withCount('shortUrls')
|
||||
->withSum('shortUrls as total_clicks', 'total_visits')
|
||||
)
|
||||
->filters([])
|
||||
->actions([
|
||||
Action::make('dns_settings')
|
||||
->label('')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->badge(fn (ShortUrlCustomDomain $record): ?string => $record->is_verified ? null : '!')
|
||||
->badgeColor('danger')
|
||||
->modalHeading(fn (ShortUrlCustomDomain $record) => __('filament-short-url::default.dns_setup_title', ['domain' => $record->domain]))
|
||||
->modalWidth('2xl')
|
||||
->modalContent(fn (ShortUrlCustomDomain $record) => view('filament-short-url::table.dns-guide-panel', ['record' => $record]))
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->closeModalByClickingAway(false)
|
||||
->extraAttributes([
|
||||
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent sm:group-hover/card:border-neutral-200',
|
||||
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
|
||||
]),
|
||||
|
||||
ActionGroup::make([
|
||||
Action::make('verify')
|
||||
->label(__('filament-short-url::default.action_verify_dns'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->action(function (ShortUrlCustomDomain $record) {
|
||||
$verified = $record->verifyDns();
|
||||
if ($verified) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.dns_verify_success'))
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.dns_verify_fail'))
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
|
||||
ActionGroup::make([
|
||||
DeleteAction::make()
|
||||
->label(__('filament-short-url::default.action_delete_domain'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->modalHeading(__('filament-short-url::default.action_delete_domain'))
|
||||
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
|
||||
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))
|
||||
->closeModalByClickingAway(false),
|
||||
])->dropdown(false),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->extraAttributes([
|
||||
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
|
||||
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
|
||||
])
|
||||
->label(''),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-domains'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlCustomDomains::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
class ListShortUrlCustomDomains extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlCustomDomainResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_domain_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_domain_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->closeModalByClickingAway(false)
|
||||
->after(function ($livewire, $record) {
|
||||
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
|
||||
}),
|
||||
|
||||
Action::make('dns_setup_modal')
|
||||
->modalHeading(function (array $arguments) {
|
||||
$record = ShortUrlCustomDomain::find($arguments['record'] ?? null);
|
||||
|
||||
return $record ? __('filament-short-url::default.dns_setup_title', ['domain' => $record->domain]) : '';
|
||||
})
|
||||
->modalWidth('2xl')
|
||||
->modalContent(function (array $arguments) {
|
||||
$record = ShortUrlCustomDomain::find($arguments['record'] ?? null);
|
||||
|
||||
return $record ? view('filament-short-url::table.dns-guide-panel', ['record' => $record]) : null;
|
||||
})
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->closeModalByClickingAway(false)
|
||||
->extraAttributes(['class' => 'hidden']),
|
||||
];
|
||||
}
|
||||
|
||||
#[On('mount-dns-setup-modal')]
|
||||
public function openDnsSetupModal(int $recordId): void
|
||||
{
|
||||
$this->mountAction('dns_setup_modal', ['record' => $recordId]);
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,17 @@ class ShortUrlPixelResource extends Resource
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
return __('filament-short-url::default.pixels_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
|
||||
return __('filament-short-url::default.pixel_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
return __('filament-short-url::default.pixels_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
@@ -63,13 +63,13 @@ class ShortUrlPixelResource extends Resource
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->label(__('filament-short-url::default.pixel_name'))
|
||||
->required()
|
||||
->maxLength(150)
|
||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||
|
||||
Select::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->label(__('filament-short-url::default.pixel_type'))
|
||||
->options([
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
@@ -81,13 +81,13 @@ class ShortUrlPixelResource extends Resource
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag ID')
|
||||
->label(__('filament-short-url::default.pixel_id_label'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->label(__('filament-short-url::default.pixel_status_active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(1);
|
||||
@@ -98,13 +98,13 @@ class ShortUrlPixelResource extends Resource
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->label(__('filament-short-url::default.pixel_name'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->label(__('filament-short-url::default.pixel_type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'meta' => 'Meta',
|
||||
@@ -124,14 +124,14 @@ class ShortUrlPixelResource extends Resource
|
||||
}),
|
||||
|
||||
TextColumn::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'ID')
|
||||
->label(__('filament-short-url::default.pixel_id_label'))
|
||||
->searchable()
|
||||
->copyable()
|
||||
->fontFamily('mono')
|
||||
->color('gray'),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->label(__('filament-short-url::default.pixel_status_active'))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
@@ -148,7 +148,9 @@ class ShortUrlPixelResource extends Resource
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->bulkActions([]);
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-pixels'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
|
||||
@@ -14,10 +14,13 @@ class ListShortUrlPixels extends ManageRecords
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_pixel_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_pixel_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md'),
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,16 @@ class ListShortUrls extends ManageRecords
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('4xl')
|
||||
->mutateFormDataUsing(function (array $data): array {
|
||||
->modalAutofocus(false)
|
||||
->mutateFormDataUsing(function (array $data, ShortUrlService $service): array {
|
||||
if (empty($data['url_key'])) {
|
||||
$data['url_key'] = app(ShortUrlService::class)->generateKey();
|
||||
$data['url_key'] = $service->generateKey();
|
||||
}
|
||||
|
||||
return $data;
|
||||
@@ -68,16 +71,16 @@ class ListShortUrls extends ManageRecords
|
||||
$encoded = urlencode($shortUrl);
|
||||
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
|
||||
|
||||
$successTitle = __('filament-short-url::default.success_modal_title') ?? 'Your link & QR code are ready!';
|
||||
$successSubtitle = __('filament-short-url::default.success_modal_subtitle') ?? 'Time to get some clicks 🎉';
|
||||
$successHelper = __('filament-short-url::default.success_modal_helper') ?? 'Copy and share manually or choose a platform.';
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
|
||||
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
|
||||
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
|
||||
$qrCodeText = __('filament-short-url::default.action_qr') ?? 'QR Code';
|
||||
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
|
||||
$dontShowAgainText = __('filament-short-url::default.dont_show_again') ?? "Don't show sharing options after creating a link";
|
||||
$successTitle = __('filament-short-url::default.success_modal_title');
|
||||
$successSubtitle = __('filament-short-url::default.success_modal_subtitle');
|
||||
$successHelper = __('filament-short-url::default.success_modal_helper');
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg');
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png');
|
||||
$closeButtonText = __('filament-short-url::default.close_button');
|
||||
$copyLinkText = __('filament-short-url::default.action_copy');
|
||||
$qrCodeText = __('filament-short-url::default.action_qr');
|
||||
$openLinkText = __('filament-short-url::default.open_link');
|
||||
$dontShowAgainText = __('filament-short-url::default.dont_show_again');
|
||||
|
||||
$record = $recordId ? ShortUrl::find($recordId) : null;
|
||||
$qrDefaults = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class AdvancedTab
|
||||
{
|
||||
/**
|
||||
* Build the advanced & security settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_advanced'))
|
||||
->key('advanced')
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_aggregation'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('pruning_enabled')
|
||||
->label(__('filament-short-url::default.settings_aggregation_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_aggregation_enabled_helper'))
|
||||
->default(true)
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
Select::make('pruning_retention_days')
|
||||
->label(__('filament-short-url::default.settings_retention_days'))
|
||||
->helperText(__('filament-short-url::default.settings_retention_days_helper'))
|
||||
->options([
|
||||
30 => __('filament-short-url::default.retention_30_days'),
|
||||
60 => __('filament-short-url::default.retention_60_days'),
|
||||
90 => __('filament-short-url::default.retention_90_days'),
|
||||
180 => __('filament-short-url::default.retention_180_days'),
|
||||
365 => __('filament-short-url::default.retention_365_days'),
|
||||
730 => __('filament-short-url::default.retention_730_days'),
|
||||
])
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('pruning_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
Toggle::make('rate_limiting_enabled')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
|
||||
->default(false)
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
TextInput::make('rate_limiting_max_attempts')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
|
||||
TextInput::make('rate_limiting_decay_seconds')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_security_v2'))
|
||||
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('vpn_detection_enabled')
|
||||
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('vpn_detection_driver')
|
||||
->label(__('filament-short-url::default.settings_vpn_driver'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
|
||||
->options([
|
||||
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
|
||||
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
|
||||
])
|
||||
->default('ip-api')
|
||||
->live()
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
TextInput::make('vpnapi_key')
|
||||
->label(__('filament-short-url::default.settings_vpnapi_key'))
|
||||
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
|
||||
|
||||
Select::make('vpn_block_action')
|
||||
->label(__('filament-short-url::default.settings_vpn_block_action'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
|
||||
->options([
|
||||
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
|
||||
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
|
||||
])
|
||||
->default('flag_only')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
Toggle::make('safe_browsing_enabled')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('google_safe_browsing_api_key')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('AIza••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->suffixAction(
|
||||
Action::make('testSafeBrowsing')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_test'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get, SafeBrowsingService $svc): void {
|
||||
$key = trim($get('google_safe_browsing_api_key') ?? '');
|
||||
if (empty($key)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
|
||||
->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$safe = $svc->isSafeWithKey('https://google.com', $key);
|
||||
Notification::make()
|
||||
->title($safe
|
||||
? __('filament-short-url::default.settings_safe_browsing_test_ok')
|
||||
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
|
||||
->color($safe ? 'success' : 'danger')
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class DeepLinkingTab
|
||||
{
|
||||
/**
|
||||
* Build the deep linking settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
|
||||
->key('deep-linking')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_deep_linking'))
|
||||
->schema([
|
||||
Toggle::make('deep_linking_enabled')
|
||||
->label(__('filament-short-url::default.settings_deep_linking_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_deep_linking_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Textarea::make('aasa_json')
|
||||
->label(__('filament-short-url::default.settings_aasa_json'))
|
||||
->helperText(__('filament-short-url::default.settings_aasa_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
|
||||
Textarea::make('assetlinks_json')
|
||||
->label(__('filament-short-url::default.settings_assetlinks_json'))
|
||||
->helperText(__('filament-short-url::default.settings_assetlinks_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class DeveloperTab
|
||||
{
|
||||
/**
|
||||
* Build the developer API and webhooks settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_developer'))
|
||||
->key('developer')
|
||||
->icon('heroicon-o-cpu-chip')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_rest_api'))
|
||||
->schema([
|
||||
Toggle::make('api_enabled')
|
||||
->label(__('filament-short-url::default.settings_api_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->columnSpanFull()
|
||||
->live(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_api_keys'))
|
||||
->description(__('filament-short-url::default.settings_api_keys_description'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
|
||||
->schema([
|
||||
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([]),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
|
||||
->schema([
|
||||
Toggle::make('global_webhook_enabled')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->afterStateUpdated(function (bool $state, $set) {
|
||||
if (! $state) {
|
||||
$set('global_webhook_url', null);
|
||||
$set('webhook_events', ['visited']);
|
||||
$set('webhook_signing_secret', null);
|
||||
}
|
||||
}),
|
||||
|
||||
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()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
TextInput::make('webhook_signing_secret')
|
||||
->label('Webhook Signing Secret')
|
||||
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
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()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class Ga4Tab
|
||||
{
|
||||
/**
|
||||
* Build the GA4 settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_ga4'))
|
||||
->key('ga4')
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_ga4'))
|
||||
->description(__('filament-short-url::default.settings_ga4_description'))
|
||||
->schema([
|
||||
TextInput::make('ga4_api_secret')
|
||||
->label(__('filament-short-url::default.settings_ga4_api_secret'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••'),
|
||||
|
||||
TextInput::make('ga4_firebase_app_id')
|
||||
->label(__('filament-short-url::default.settings_ga4_firebase_app_id'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
||||
->placeholder('1:1234567890:android:abcdef123456'),
|
||||
|
||||
Actions::make([
|
||||
Action::make('verifyGa4ApiSecret')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$secret = trim($get('ga4_api_secret') ?? '');
|
||||
|
||||
if (empty($secret)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post(
|
||||
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
|
||||
[
|
||||
'client_id' => 'short-url-plugin-verify',
|
||||
'events' => [
|
||||
['name' => 'page_view', 'params' => []],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$body = $response->json();
|
||||
$messages = $body['validationMessages'] ?? [];
|
||||
|
||||
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
|
||||
strtolower($m['description'] ?? ''),
|
||||
'api_secret'
|
||||
));
|
||||
|
||||
if ($hasAuthError || $response->status() === 401) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||
->danger()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class GeneralTab
|
||||
{
|
||||
/**
|
||||
* Build the general settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_general'))
|
||||
->key('general')
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_routing'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label(__('filament-short-url::default.settings_site_name'))
|
||||
->helperText(__('filament-short-url::default.settings_site_name_helper'))
|
||||
->nullable()
|
||||
->maxLength(100)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('route_prefix')
|
||||
->label(__('filament-short-url::default.settings_route_prefix'))
|
||||
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
|
||||
->prefix(new HtmlString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 6px; margin-top: -3px; width: 15px; height: 15px;" class="text-emerald-600 dark:text-emerald-500"><path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v7a2 2 0 00 2 2h10a2 2 0 00 2-2v-7a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 00 10 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /></svg>https://'.request()->getHost().'/'))
|
||||
->nullable()
|
||||
->alphaDash()
|
||||
->maxLength(20),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
Toggle::make('lock_url_key')
|
||||
->label(__('filament-short-url::default.settings_lock_url_key'))
|
||||
->helperText(__('filament-short-url::default.settings_lock_url_key_helper'))
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('disable_default_domain')
|
||||
->label(__('filament-short-url::default.settings_disable_default_domain'))
|
||||
->helperText(__('filament-short-url::default.settings_disable_default_domain_helper'))
|
||||
->inline(false),
|
||||
|
||||
TextInput::make('key_length')
|
||||
->label(__('filament-short-url::default.settings_key_length'))
|
||||
->helperText(__('filament-short-url::default.settings_key_length_helper'))
|
||||
->rules(['required', 'integer', 'between:3,20'])
|
||||
->extraInputAttributes([
|
||||
'maxlength' => 2,
|
||||
'oninput' => "this.value = this.value.replace(/[^0-9]/g, ''); if(this.value !== '') { let val = parseInt(this.value); if(val > 20) this.value = 20; }",
|
||||
'onblur' => "if(this.value !== '') { let val = parseInt(this.value); if(val < 3) this.value = 3; if(val > 20) this.value = 20; }",
|
||||
])
|
||||
->required(),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->suffix('s')
|
||||
->required(),
|
||||
|
||||
Toggle::make('trust_cdn_headers')
|
||||
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
|
||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('trust_cdn_headers_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_trust_cdn_headers_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_queue'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('queue_connection')
|
||||
->label(__('filament-short-url::default.settings_queue_connection'))
|
||||
->helperText(__('filament-short-url::default.settings_queue_connection_helper'))
|
||||
->options(function (): array {
|
||||
$connections = array_keys(config('queue.connections', []));
|
||||
|
||||
return array_combine($connections, $connections) ?: [
|
||||
'sync' => 'sync',
|
||||
'database' => 'database',
|
||||
'redis' => 'redis',
|
||||
];
|
||||
})
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('queue_name')
|
||||
->label(__('filament-short-url::default.settings_queue_name'))
|
||||
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
||||
->default('default')
|
||||
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
|
||||
|
||||
Placeholder::make('queue_worker_info')
|
||||
->content(function (Get $get) {
|
||||
$queueName = $get('queue_name') ?: 'default';
|
||||
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_buffering'))
|
||||
->schema([
|
||||
Toggle::make('counter_buffering_enabled')
|
||||
->label(__('filament-short-url::default.settings_buffering_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_buffering_helper'))
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('counter_buffering_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_buffering_worker_info');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class GeoIpTab
|
||||
{
|
||||
/**
|
||||
* Build the geo-ip settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_geoip'))
|
||||
->key('geoip')
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_geoip'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('geo_ip_enabled')
|
||||
->label(__('filament-short-url::default.settings_geoip_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_enabled_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Select::make('geo_ip_driver')
|
||||
->label(__('filament-short-url::default.settings_geoip_driver'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_driver_helper'))
|
||||
->options([
|
||||
'headers' => __('filament-short-url::default.settings_geoip_driver_headers'),
|
||||
'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'),
|
||||
'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'),
|
||||
])
|
||||
->required()
|
||||
->live()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
Placeholder::make('geoip_headers_warning')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_geoip_headers_warning');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
||||
$get('geo_ip_driver') === 'headers' &&
|
||||
! (bool) $get('trust_cdn_headers')
|
||||
)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('geo_ip_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(31536000)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
TextInput::make('geo_ip_stats_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(86400)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
TextInput::make('geo_ip_timeout')
|
||||
->label(__('filament-short-url::default.settings_geoip_timeout'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(30)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
|
||||
|
||||
Placeholder::make('maxmind_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_maxmind_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('maxmind_database_path')
|
||||
->label(__('filament-short-url::default.settings_maxmind_path'))
|
||||
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
|
||||
->columnSpanFull()
|
||||
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
|
||||
Actions::make([
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$path = trim($get('maxmind_database_path') ?? '');
|
||||
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class QrDefaultsTab
|
||||
{
|
||||
/**
|
||||
* Build the QR defaults settings form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_qr'))
|
||||
->key('qr-defaults')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
|
||||
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('qr_size')
|
||||
->label(__('filament-short-url::default.settings_qr_size'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(100)
|
||||
->maxValue(2000)
|
||||
->required(),
|
||||
|
||||
Select::make('qr_margin')
|
||||
->label(__('filament-short-url::default.settings_qr_margin'))
|
||||
->options(array_combine(range(0, 10), range(0, 10)))
|
||||
->required(),
|
||||
|
||||
Select::make('qr_dot_style')
|
||||
->label(__('filament-short-url::default.settings_qr_dot_style'))
|
||||
->options([
|
||||
'square' => __('filament-short-url::default.qr_option_square'),
|
||||
'dots' => __('filament-short-url::default.qr_option_dots'),
|
||||
'rounded' => __('filament-short-url::default.qr_option_rounded'),
|
||||
'classy' => __('filament-short-url::default.qr_option_classy'),
|
||||
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
|
||||
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('qr_foreground_color')
|
||||
->label(__('filament-short-url::default.settings_qr_foreground_color'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#000000')
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('qr_background_color')
|
||||
->label(__('filament-short-url::default.settings_qr_background_color'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#ffffff')
|
||||
->required(),
|
||||
|
||||
Toggle::make('qr_gradient_enabled')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
ColorPicker::make('qr_gradient_from')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_from'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#4f46e5')
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
|
||||
ColorPicker::make('qr_gradient_to')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_to'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#06b6d4')
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
|
||||
Select::make('qr_gradient_type')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_type'))
|
||||
->options([
|
||||
'linear' => __('filament-short-url::default.qr_gradient_linear'),
|
||||
'radial' => __('filament-short-url::default.qr_gradient_radial'),
|
||||
])
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class TrackingDefaultsTab
|
||||
{
|
||||
/**
|
||||
* Build the tracking defaults form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
|
||||
->key('tracking-defaults')
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
|
||||
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
|
||||
->schema([
|
||||
Toggle::make('tracking_enabled')
|
||||
->label(__('filament-short-url::default.settings_track_visits_default'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('tracking_anonymize_ips')
|
||||
->label(__('filament-short-url::default.settings_track_anonymize_ips'))
|
||||
->helperText(__('filament-short-url::default.settings_track_anonymize_ips_helper'))
|
||||
->inline(false)
|
||||
->columnSpanFull()
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_ip_address')
|
||||
->label(__('filament-short-url::default.settings_track_ip_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser')
|
||||
->label(__('filament-short-url::default.settings_track_browser_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser_version')
|
||||
->label(__('filament-short-url::default.settings_track_browser_version_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_operating_system')
|
||||
->label(__('filament-short-url::default.settings_track_os_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_operating_system_version')
|
||||
->label(__('filament-short-url::default.settings_track_os_version_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser_language')
|
||||
->label(__('filament-short-url::default.settings_track_browser_language_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_referer_url')
|
||||
->label(__('filament-short-url::default.settings_track_referer_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_device_type')
|
||||
->label(__('filament-short-url::default.settings_track_device_type_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
])
|
||||
->columns(4),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,25 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\AdvancedTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\DeepLinkingTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\DeveloperTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\Ga4Tab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\GeneralTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\GeoIpTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\QrDefaultsTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\TrackingDefaultsTab;
|
||||
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlSettingsPage extends Page implements HasForms
|
||||
@@ -41,7 +36,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.settings_nav_label') ?? 'Settings';
|
||||
return __('filament-short-url::default.settings_nav_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
@@ -56,9 +51,9 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 2;
|
||||
return ShortUrlResource::getNavigationSort() + 3;
|
||||
} catch (\Throwable) {
|
||||
return 52;
|
||||
return 53;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +81,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
public function mount(ShortUrlSettingsManager $mgr): void
|
||||
{
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
|
||||
$aasa = $mgr->get('aasa_json');
|
||||
if (! empty($aasa)) {
|
||||
$decoded = json_decode($aasa, true);
|
||||
@@ -108,6 +101,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
$this->form->fill([
|
||||
'route_prefix' => $mgr->get('route_prefix', 's'),
|
||||
'lock_url_key' => $mgr->get('lock_url_key', false),
|
||||
'disable_default_domain' => $mgr->get('disable_default_domain', false),
|
||||
'redirect_status_code' => $mgr->get('redirect_status_code', 302),
|
||||
'key_length' => $mgr->get('key_length', 6),
|
||||
'cache_ttl' => $mgr->get('cache_ttl', 3600),
|
||||
@@ -129,6 +124,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60),
|
||||
'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60),
|
||||
'tracking_enabled' => $mgr->get('tracking_enabled', true),
|
||||
'tracking_anonymize_ips' => $mgr->get('tracking_anonymize_ips', false),
|
||||
'tracking_fields_ip_address' => $mgr->get('tracking_fields_ip_address', true),
|
||||
'tracking_fields_browser' => $mgr->get('tracking_fields_browser', true),
|
||||
'tracking_fields_browser_version' => $mgr->get('tracking_fields_browser_version', true),
|
||||
@@ -175,781 +171,24 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
Tabs::make('ShortUrlSettings')
|
||||
->persistTabInQueryString()
|
||||
->tabs([
|
||||
|
||||
// ── General ──────────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_general'))
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_routing'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label(__('filament-short-url::default.settings_site_name'))
|
||||
->helperText(__('filament-short-url::default.settings_site_name_helper'))
|
||||
->nullable()
|
||||
->maxLength(100)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('route_prefix')
|
||||
->label(__('filament-short-url::default.settings_route_prefix'))
|
||||
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
|
||||
->prefix(new HtmlString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 6px; margin-top: -3px; width: 15px; height: 15px;" class="text-emerald-600 dark:text-emerald-500"><path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v7a2 2 0 00 2 2h10a2 2 0 00 2-2v-7a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 00 10 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /></svg>https://'.request()->getHost().'/'))
|
||||
->nullable()
|
||||
->alphaDash()
|
||||
->maxLength(20),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
TextInput::make('key_length')
|
||||
->label(__('filament-short-url::default.settings_key_length'))
|
||||
->helperText(__('filament-short-url::default.settings_key_length_helper'))
|
||||
->rules(['required', 'integer', 'between:3,20'])
|
||||
->extraInputAttributes([
|
||||
'maxlength' => 2,
|
||||
'oninput' => "this.value = this.value.replace(/[^0-9]/g, ''); if(this.value !== '') { let val = parseInt(this.value); if(val > 20) this.value = 20; }",
|
||||
'onblur' => "if(this.value !== '') { let val = parseInt(this.value); if(val < 3) this.value = 3; if(val > 20) this.value = 20; }",
|
||||
])
|
||||
->required(),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->suffix('s')
|
||||
->required(),
|
||||
|
||||
Toggle::make('trust_cdn_headers')
|
||||
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
|
||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('trust_cdn_headers_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_trust_cdn_headers_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_queue'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('queue_connection')
|
||||
->label(__('filament-short-url::default.settings_queue_connection'))
|
||||
->helperText(__('filament-short-url::default.settings_queue_connection_helper'))
|
||||
->options(function (): array {
|
||||
$connections = array_keys(config('queue.connections', []));
|
||||
|
||||
return array_combine($connections, $connections) ?: [
|
||||
'sync' => 'sync',
|
||||
'database' => 'database',
|
||||
'redis' => 'redis',
|
||||
];
|
||||
})
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('queue_name')
|
||||
->label(__('filament-short-url::default.settings_queue_name'))
|
||||
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
||||
->default('default')
|
||||
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
|
||||
|
||||
Placeholder::make('queue_worker_info')
|
||||
->content(function (Get $get) {
|
||||
$queueName = $get('queue_name') ?: 'default';
|
||||
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_buffering'))
|
||||
->schema([
|
||||
Toggle::make('counter_buffering_enabled')
|
||||
->label(__('filament-short-url::default.settings_buffering_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_buffering_helper'))
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('counter_buffering_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_buffering_worker_info');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Geo-IP ───────────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_geoip'))
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_geoip'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('geo_ip_enabled')
|
||||
->label(__('filament-short-url::default.settings_geoip_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_enabled_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
// ── Driver (only when geo-ip is on) ──
|
||||
Select::make('geo_ip_driver')
|
||||
->label(__('filament-short-url::default.settings_geoip_driver'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_driver_helper'))
|
||||
->options([
|
||||
'headers' => __('filament-short-url::default.settings_geoip_driver_headers'),
|
||||
'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'),
|
||||
'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'),
|
||||
])
|
||||
->required()
|
||||
->live()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
Placeholder::make('geoip_headers_warning')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_geoip_headers_warning');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
||||
$get('geo_ip_driver') === 'headers' &&
|
||||
! (bool) $get('trust_cdn_headers')
|
||||
)
|
||||
->columnSpanFull(),
|
||||
|
||||
// ── Cache TTL (only when geo-ip is on) ──
|
||||
TextInput::make('geo_ip_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(31536000)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
// ── Stats Cache TTL (only when geo-ip is on) ──
|
||||
TextInput::make('geo_ip_stats_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(86400)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
// ── Timeout (only for ip-api driver) ──
|
||||
TextInput::make('geo_ip_timeout')
|
||||
->label(__('filament-short-url::default.settings_geoip_timeout'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(30)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
|
||||
|
||||
// ── MaxMind path (only for maxmind driver) ──
|
||||
Placeholder::make('maxmind_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_maxmind_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('maxmind_database_path')
|
||||
->label(__('filament-short-url::default.settings_maxmind_path'))
|
||||
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
|
||||
->columnSpanFull()
|
||||
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
|
||||
Actions::make([
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$path = trim($get('maxmind_database_path') ?? '');
|
||||
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Google Analytics 4 ───────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_ga4'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_ga4'))
|
||||
->description(__('filament-short-url::default.settings_ga4_description'))
|
||||
->schema([
|
||||
TextInput::make('ga4_api_secret')
|
||||
->label(__('filament-short-url::default.settings_ga4_api_secret'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••'),
|
||||
|
||||
// Firebase App ID — always visible
|
||||
TextInput::make('ga4_firebase_app_id')
|
||||
->label(__('filament-short-url::default.settings_ga4_firebase_app_id'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
||||
->placeholder('1:1234567890:android:abcdef123456'),
|
||||
|
||||
// GA4 connection verify button
|
||||
Actions::make([
|
||||
Action::make('verifyGa4ApiSecret')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$secret = trim($get('ga4_api_secret') ?? '');
|
||||
|
||||
if (empty($secret)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// GA4 debug endpoint — a valid api_secret returns event-level
|
||||
// validation messages; an invalid one returns auth errors.
|
||||
$response = Http::timeout(5)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post(
|
||||
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
|
||||
[
|
||||
'client_id' => 'short-url-plugin-verify',
|
||||
'events' => [
|
||||
['name' => 'page_view', 'params' => []],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$body = $response->json();
|
||||
$messages = $body['validationMessages'] ?? [];
|
||||
|
||||
// A valid secret returns event-level messages, not auth errors
|
||||
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
|
||||
strtolower($m['description'] ?? ''),
|
||||
'api_secret'
|
||||
));
|
||||
|
||||
if ($hasAuthError || $response->status() === 401) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||
->danger()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Advanced & Security ──────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_advanced'))
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_aggregation'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('pruning_enabled')
|
||||
->label(__('filament-short-url::default.settings_aggregation_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_aggregation_enabled_helper'))
|
||||
->default(true)
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
Select::make('pruning_retention_days')
|
||||
->label(__('filament-short-url::default.settings_retention_days'))
|
||||
->helperText(__('filament-short-url::default.settings_retention_days_helper'))
|
||||
->options([
|
||||
30 => __('filament-short-url::default.retention_30_days'),
|
||||
60 => __('filament-short-url::default.retention_60_days'),
|
||||
90 => __('filament-short-url::default.retention_90_days'),
|
||||
180 => __('filament-short-url::default.retention_180_days'),
|
||||
365 => __('filament-short-url::default.retention_365_days'),
|
||||
730 => __('filament-short-url::default.retention_730_days'),
|
||||
])
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('pruning_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
Toggle::make('rate_limiting_enabled')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
|
||||
->default(false)
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
TextInput::make('rate_limiting_max_attempts')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
|
||||
TextInput::make('rate_limiting_decay_seconds')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_security_v2'))
|
||||
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('vpn_detection_enabled')
|
||||
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('vpn_detection_driver')
|
||||
->label(__('filament-short-url::default.settings_vpn_driver'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
|
||||
->options([
|
||||
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
|
||||
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
|
||||
])
|
||||
->default('ip-api')
|
||||
->live()
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
TextInput::make('vpnapi_key')
|
||||
->label(__('filament-short-url::default.settings_vpnapi_key'))
|
||||
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
|
||||
|
||||
Select::make('vpn_block_action')
|
||||
->label(__('filament-short-url::default.settings_vpn_block_action'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
|
||||
->options([
|
||||
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
|
||||
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
|
||||
])
|
||||
->default('flag_only')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
Toggle::make('safe_browsing_enabled')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('google_safe_browsing_api_key')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('AIza••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->suffixAction(
|
||||
Action::make('testSafeBrowsing')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_test'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$key = trim($get('google_safe_browsing_api_key') ?? '');
|
||||
if (empty($key)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
|
||||
->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$svc = app(SafeBrowsingService::class);
|
||||
$safe = $svc->isSafeWithKey('https://google.com', $key);
|
||||
Notification::make()
|
||||
->title($safe
|
||||
? __('filament-short-url::default.settings_safe_browsing_test_ok')
|
||||
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
|
||||
->color($safe ? 'success' : 'danger')
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Tracking Defaults ─────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
|
||||
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
|
||||
->schema([
|
||||
Toggle::make('tracking_enabled')
|
||||
->label(__('filament-short-url::default.settings_track_visits_default'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('tracking_fields_ip_address')
|
||||
->label(__('filament-short-url::default.settings_track_ip_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser')
|
||||
->label(__('filament-short-url::default.settings_track_browser_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser_version')
|
||||
->label(__('filament-short-url::default.settings_track_browser_version_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_operating_system')
|
||||
->label(__('filament-short-url::default.settings_track_os_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_operating_system_version')
|
||||
->label(__('filament-short-url::default.settings_track_os_version_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser_language')
|
||||
->label(__('filament-short-url::default.settings_track_browser_language_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_referer_url')
|
||||
->label(__('filament-short-url::default.settings_track_referer_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_device_type')
|
||||
->label(__('filament-short-url::default.settings_track_device_type_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
])
|
||||
->columns(4),
|
||||
]),
|
||||
|
||||
// ── QR Defaults ──────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_qr'))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
|
||||
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('qr_size')
|
||||
->label(__('filament-short-url::default.settings_qr_size'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(100)
|
||||
->maxValue(2000)
|
||||
->required(),
|
||||
|
||||
Select::make('qr_margin')
|
||||
->label(__('filament-short-url::default.settings_qr_margin'))
|
||||
->options(array_combine(range(0, 10), range(0, 10)))
|
||||
->required(),
|
||||
|
||||
Select::make('qr_dot_style')
|
||||
->label(__('filament-short-url::default.settings_qr_dot_style'))
|
||||
->options([
|
||||
'square' => __('filament-short-url::default.qr_option_square'),
|
||||
'dots' => __('filament-short-url::default.qr_option_dots'),
|
||||
'rounded' => __('filament-short-url::default.qr_option_rounded'),
|
||||
'classy' => __('filament-short-url::default.qr_option_classy'),
|
||||
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
|
||||
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('qr_foreground_color')
|
||||
->label(__('filament-short-url::default.settings_qr_foreground_color'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#000000')
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('qr_background_color')
|
||||
->label(__('filament-short-url::default.settings_qr_background_color'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#ffffff')
|
||||
->required(),
|
||||
|
||||
Toggle::make('qr_gradient_enabled')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
ColorPicker::make('qr_gradient_from')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_from'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#4f46e5')
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
|
||||
ColorPicker::make('qr_gradient_to')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_to'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#06b6d4')
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
|
||||
Select::make('qr_gradient_type')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_type'))
|
||||
->options([
|
||||
'linear' => __('filament-short-url::default.qr_gradient_linear'),
|
||||
'radial' => __('filament-short-url::default.qr_gradient_radial'),
|
||||
])
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->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_rest_api'))
|
||||
->schema([
|
||||
Toggle::make('api_enabled')
|
||||
->label(__('filament-short-url::default.settings_api_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->columnSpanFull()
|
||||
->live(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_api_keys'))
|
||||
->description(__('filament-short-url::default.settings_api_keys_description'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
|
||||
->schema([
|
||||
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([]),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
|
||||
->schema([
|
||||
Toggle::make('global_webhook_enabled')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->afterStateUpdated(function (bool $state, $set) {
|
||||
if (! $state) {
|
||||
$set('global_webhook_url', null);
|
||||
$set('webhook_events', ['visited']);
|
||||
$set('webhook_signing_secret', null);
|
||||
}
|
||||
}),
|
||||
|
||||
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()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
TextInput::make('webhook_signing_secret')
|
||||
->label('Webhook Signing Secret')
|
||||
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
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()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Deep Linking ─────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_deep_linking'))
|
||||
->schema([
|
||||
Toggle::make('deep_linking_enabled')
|
||||
->label(__('filament-short-url::default.settings_deep_linking_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_deep_linking_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Textarea::make('aasa_json')
|
||||
->label(__('filament-short-url::default.settings_aasa_json'))
|
||||
->helperText(__('filament-short-url::default.settings_aasa_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
|
||||
Textarea::make('assetlinks_json')
|
||||
->label(__('filament-short-url::default.settings_assetlinks_json'))
|
||||
->helperText(__('filament-short-url::default.settings_assetlinks_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
GeneralTab::make(),
|
||||
GeoIpTab::make(),
|
||||
Ga4Tab::make(),
|
||||
AdvancedTab::make(),
|
||||
TrackingDefaultsTab::make(),
|
||||
QrDefaultsTab::make(),
|
||||
DeveloperTab::make(),
|
||||
DeepLinkingTab::make(),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
public function save(ShortUrlSettingsManager $mgr): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
app(ShortUrlSettingsManager::class)->set($data);
|
||||
$mgr->set($data);
|
||||
|
||||
// Clear deep linking cache when settings are saved!
|
||||
cache()->forget('fsu:deep-linking:aasa');
|
||||
@@ -963,15 +202,15 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
if ($newKeys = session()->get('fsu_new_api_keys')) {
|
||||
foreach ($newKeys as $newKey) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.api_key_generated') ?? 'API Key Generated')
|
||||
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.(__('filament-short-url::default.api_key_warning') ?? "Please copy this key now. You won't be able to see it again!")."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
|
||||
->title(__('filament-short-url::default.api_key_generated'))
|
||||
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.__('filament-short-url::default.api_key_warning')."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
|
||||
->warning()
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
// Re-mount settings form state to reflect the new masked key names in the UI
|
||||
$this->mount();
|
||||
$this->mount($mgr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
@@ -40,49 +43,153 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('visited_at')
|
||||
->label(__('filament-short-url::default.stats_col_time'))
|
||||
->dateTime()
|
||||
->dateTime('M j, g:i A')
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('shortUrl.url_key')
|
||||
->label(__('filament-short-url::default.stats_col_link'))
|
||||
->icon('heroicon-o-link')
|
||||
->formatStateUsing(function ($state, $record) {
|
||||
if (! $record->shortUrl) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return str_replace(['http://', 'https://'], '', $record->shortUrl->getShortUrl());
|
||||
})
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumm::make('referer_host')
|
||||
->label(__('filament-short-url::default.stats_col_referrer'))
|
||||
->icon('heroicon-o-link')
|
||||
->formatStateUsing(fn ($state) => empty($state) ? __('filament-short-url::default.stats_referer_direct') : $state)
|
||||
->color(fn ($state) => empty($state) ? 'gray' : null)
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('country')
|
||||
->label(__('filament-short-url::default.stats_col_country'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->formatStateUsing(function ($state, $record) {
|
||||
if (! $state) {
|
||||
return '—';
|
||||
}
|
||||
if ($record->country_code) {
|
||||
try {
|
||||
$flag = implode('', array_map(fn ($char) => mb_chr(ord($char) + 127397), str_split(strtoupper($record->country_code))));
|
||||
|
||||
return "{$flag} {$state}";
|
||||
} catch (\Throwable) {
|
||||
return $state;
|
||||
}
|
||||
}
|
||||
|
||||
return $state;
|
||||
})
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('device_type')
|
||||
->label(__('filament-short-url::default.stats_col_device'))
|
||||
->icon(fn ($state) => match (strtolower($state)) {
|
||||
'desktop' => 'heroicon-m-computer-desktop',
|
||||
'mobile' => 'heroicon-m-device-phone-mobile',
|
||||
'tablet' => 'heroicon-m-device-tablet',
|
||||
default => 'heroicon-m-question-mark-circle',
|
||||
})
|
||||
->formatStateUsing(fn ($state) => match (strtolower($state)) {
|
||||
'desktop' => __('filament-short-url::default.stats_device_desktop'),
|
||||
'mobile' => __('filament-short-url::default.stats_device_mobile'),
|
||||
'tablet' => __('filament-short-url::default.stats_device_tablet'),
|
||||
default => ucfirst($state),
|
||||
})
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
// Hidden/toggleable columns for advanced details
|
||||
Tables\Columns\TextColumn::make('ip_address')
|
||||
->label(__('filament-short-url::default.stats_col_ip'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('country')
|
||||
->label(__('filament-short-url::default.stats_col_country'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state, $record) => $record->country_code ? "{$record->country_code} - {$state}" : ($state ?? '—'))
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('device_type')
|
||||
->label(__('filament-short-url::default.stats_col_device'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state) => ucfirst($state))
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('browser')
|
||||
->label(__('filament-short-url::default.stats_col_browser'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state, $record) => $record->browser_version ? "{$state} ({$record->browser_version})" : ($state ?? '—'))
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('operating_system')
|
||||
->label(__('filament-short-url::default.stats_col_os'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state, $record) => $record->operating_system_version ? "{$state} ({$record->operating_system_version})" : ($state ?? '—'))
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('referer_url')
|
||||
->label(__('filament-short-url::default.track_referer'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('browser_language')
|
||||
->label(__('filament-short-url::default.stats_col_language'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30)
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('utm_source')
|
||||
->label(__('filament-short-url::default.stats_col_utm_source'))
|
||||
->searchable()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('utm_medium')
|
||||
->label(__('filament-short-url::default.stats_col_utm_medium'))
|
||||
->searchable()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('utm_campaign')
|
||||
->label(__('filament-short-url::default.stats_col_utm_campaign'))
|
||||
->searchable()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('utm_term')
|
||||
->label(__('filament-short-url::default.stats_col_utm_term'))
|
||||
->searchable()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('utm_content')
|
||||
->label(__('filament-short-url::default.stats_col_utm_content'))
|
||||
->searchable()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('selected_variant')
|
||||
->label(__('filament-short-url::default.stats_col_variant'))
|
||||
->searchable()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_qr_scan')
|
||||
->label(__('filament-short-url::default.stats_col_qr_scan'))
|
||||
->boolean()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_bot')
|
||||
->label(__('filament-short-url::default.stats_col_bot'))
|
||||
->boolean()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_proxy')
|
||||
->label(__('filament-short-url::default.stats_col_proxy'))
|
||||
->boolean()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('visited_at', 'desc')
|
||||
->paginated([10, 25, 50, 100])
|
||||
@@ -90,10 +197,10 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
Tables\Filters\SelectFilter::make('device_type')
|
||||
->label(__('filament-short-url::default.stats_col_device'))
|
||||
->options([
|
||||
'desktop' => 'Desktop',
|
||||
'mobile' => 'Mobile',
|
||||
'tablet' => 'Tablet',
|
||||
'robot' => 'Robot / Bot',
|
||||
'desktop' => __('filament-short-url::default.stats_device_desktop'),
|
||||
'mobile' => __('filament-short-url::default.stats_device_mobile'),
|
||||
'tablet' => __('filament-short-url::default.stats_device_tablet'),
|
||||
'robot' => __('filament-short-url::default.stats_device_bot'),
|
||||
]),
|
||||
|
||||
Tables\Filters\SelectFilter::make('country_code')
|
||||
@@ -122,6 +229,76 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59'));
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
Action::make('details')
|
||||
->label(__('filament-short-url::default.stats_action_view_details'))
|
||||
->icon('heroicon-o-information-circle')
|
||||
->modalHeading(__('filament-short-url::default.stats_modal_visit_details'))
|
||||
->modalWidth('md')
|
||||
->form([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Placeholder::make('ip_address')
|
||||
->label(__('filament-short-url::default.stats_csv_ip'))
|
||||
->content(fn ($record) => $record->ip_address ?? '—'),
|
||||
Placeholder::make('country')
|
||||
->label(__('filament-short-url::default.stats_detail_location'))
|
||||
->content(fn ($record) => $record->country
|
||||
? ($record->country_code ? implode('', array_map(fn ($char) => mb_chr(ord($char) + 127397), str_split(strtoupper($record->country_code)))).' '.$record->country : $record->country)
|
||||
: '—'),
|
||||
Placeholder::make('device')
|
||||
->label(__('filament-short-url::default.stats_col_device'))
|
||||
->content(fn ($record) => $record->device_type ? match (strtolower($record->device_type)) {
|
||||
'desktop' => __('filament-short-url::default.stats_device_desktop'),
|
||||
'mobile' => __('filament-short-url::default.stats_device_mobile'),
|
||||
'tablet' => __('filament-short-url::default.stats_device_tablet'),
|
||||
default => ucfirst($record->device_type)
|
||||
} : '—'),
|
||||
Placeholder::make('browser')
|
||||
->label(__('filament-short-url::default.stats_col_browser'))
|
||||
->content(fn ($record) => $record->browser ? "{$record->browser} ({$record->browser_version})" : '—'),
|
||||
Placeholder::make('os')
|
||||
->label(__('filament-short-url::default.stats_col_os'))
|
||||
->content(fn ($record) => $record->operating_system ? "{$record->operating_system} ({$record->operating_system_version})" : '—'),
|
||||
Placeholder::make('referer')
|
||||
->label(__('filament-short-url::default.stats_col_referrer'))
|
||||
->content(fn ($record) => $record->referer_url ?? '—')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('utm_source')
|
||||
->label(__('filament-short-url::default.stats_col_utm_source'))
|
||||
->content(fn ($record) => $record->utm_source ?? '—'),
|
||||
Placeholder::make('utm_medium')
|
||||
->label(__('filament-short-url::default.stats_col_utm_medium'))
|
||||
->content(fn ($record) => $record->utm_medium ?? '—'),
|
||||
Placeholder::make('utm_campaign')
|
||||
->label(__('filament-short-url::default.stats_col_utm_campaign'))
|
||||
->content(fn ($record) => $record->utm_campaign ?? '—'),
|
||||
Placeholder::make('utm_term')
|
||||
->label(__('filament-short-url::default.stats_col_utm_term'))
|
||||
->content(fn ($record) => $record->utm_term ?? '—'),
|
||||
Placeholder::make('utm_content')
|
||||
->label(__('filament-short-url::default.stats_col_utm_content'))
|
||||
->content(fn ($record) => $record->utm_content ?? '—'),
|
||||
Placeholder::make('selected_variant')
|
||||
->label(__('filament-short-url::default.stats_col_variant'))
|
||||
->content(fn ($record) => $record->selected_variant ?? '—'),
|
||||
Placeholder::make('is_bot')
|
||||
->label(__('filament-short-url::default.stats_detail_is_bot'))
|
||||
->content(fn ($record) => $record->is_bot ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')),
|
||||
Placeholder::make('is_proxy')
|
||||
->label(__('filament-short-url::default.stats_detail_is_proxy'))
|
||||
->content(fn ($record) => $record->is_proxy ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')),
|
||||
Placeholder::make('is_qr_scan')
|
||||
->label(__('filament-short-url::default.stats_detail_is_qr'))
|
||||
->content(fn ($record) => $record->is_qr_scan ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')),
|
||||
Placeholder::make('visited_at')
|
||||
->label(__('filament-short-url::default.stats_detail_visited_at'))
|
||||
->content(fn ($record) => $record->visited_at->toDateTimeString()),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('export_csv')
|
||||
->label(__('filament-short-url::default.stats_action_export'))
|
||||
@@ -141,6 +318,15 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
__('filament-short-url::default.stats_csv_browser'),
|
||||
__('filament-short-url::default.stats_csv_os'),
|
||||
__('filament-short-url::default.stats_csv_referer'),
|
||||
__('filament-short-url::default.stats_csv_utm_source'),
|
||||
__('filament-short-url::default.stats_csv_utm_medium'),
|
||||
__('filament-short-url::default.stats_csv_utm_campaign'),
|
||||
__('filament-short-url::default.stats_csv_utm_term'),
|
||||
__('filament-short-url::default.stats_csv_utm_content'),
|
||||
__('filament-short-url::default.stats_csv_variant'),
|
||||
__('filament-short-url::default.stats_csv_qr_scan'),
|
||||
__('filament-short-url::default.stats_csv_bot'),
|
||||
__('filament-short-url::default.stats_csv_proxy'),
|
||||
]);
|
||||
|
||||
$livewire->getFilteredTableQuery()
|
||||
@@ -151,10 +337,24 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
$visit->visited_at->toDateTimeString(),
|
||||
$visit->ip_address ?? '—',
|
||||
$visit->country ? "{$visit->country_code} - {$visit->country}" : '—',
|
||||
ucfirst($visit->device_type ?? '—'),
|
||||
$visit->device_type ? match (strtolower($visit->device_type)) {
|
||||
'desktop' => __('filament-short-url::default.stats_device_desktop'),
|
||||
'mobile' => __('filament-short-url::default.stats_device_mobile'),
|
||||
'tablet' => __('filament-short-url::default.stats_device_tablet'),
|
||||
default => ucfirst($visit->device_type)
|
||||
} : '—',
|
||||
$visit->browser ? "{$visit->browser} ({$visit->browser_version})" : '—',
|
||||
$visit->operating_system ? "{$visit->operating_system} ({$visit->operating_system_version})" : '—',
|
||||
$visit->referer_url ?? '—',
|
||||
$visit->utm_source ?? '—',
|
||||
$visit->utm_medium ?? '—',
|
||||
$visit->utm_campaign ?? '—',
|
||||
$visit->utm_term ?? '—',
|
||||
$visit->utm_content ?? '—',
|
||||
$visit->selected_variant ?? '—',
|
||||
$visit->is_qr_scan ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'),
|
||||
$visit->is_bot ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'),
|
||||
$visit->is_proxy ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,8 +9,10 @@ use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Schema;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
class ViewShortUrlStats extends Page implements HasForms
|
||||
{
|
||||
@@ -28,6 +30,50 @@ class ViewShortUrlStats extends Page implements HasForms
|
||||
|
||||
public ?array $filterData = [];
|
||||
|
||||
public array $activeFilters = [];
|
||||
|
||||
#[On('set-stats-filter')]
|
||||
public function setStatsFilter(string $key, $value): void
|
||||
{
|
||||
if (count($this->activeFilters) >= 5 && ! isset($this->activeFilters[$key])) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.stats_filter_limit_exceeded'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activeFilters[$key] = $value;
|
||||
$this->totalVisits = $this->record->getCachedStats(
|
||||
$this->filterData['date_from'] ?? null,
|
||||
$this->filterData['date_to'] ?? null,
|
||||
$this->activeFilters
|
||||
)['totalVisits'] ?? 0;
|
||||
}
|
||||
|
||||
#[On('clear-stats-filter')]
|
||||
public function clearStatsFilter(string $key): void
|
||||
{
|
||||
unset($this->activeFilters[$key]);
|
||||
$this->totalVisits = $this->record->getCachedStats(
|
||||
$this->filterData['date_from'] ?? null,
|
||||
$this->filterData['date_to'] ?? null,
|
||||
$this->activeFilters
|
||||
)['totalVisits'] ?? 0;
|
||||
}
|
||||
|
||||
#[On('clear-all-stats-filters')]
|
||||
public function clearAllStatsFilters(): void
|
||||
{
|
||||
$this->activeFilters = [];
|
||||
$this->totalVisits = $this->record->getCachedStats(
|
||||
$this->filterData['date_from'] ?? null,
|
||||
$this->filterData['date_to'] ?? null,
|
||||
$this->activeFilters
|
||||
)['totalVisits'] ?? 0;
|
||||
}
|
||||
|
||||
public function mount(ShortUrl $record): void
|
||||
{
|
||||
$this->record = $record;
|
||||
@@ -46,7 +92,11 @@ class ViewShortUrlStats extends Page implements HasForms
|
||||
];
|
||||
}
|
||||
|
||||
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
|
||||
$this->totalVisits = $record->getCachedStats(
|
||||
$this->filterData['date_from'] ?? null,
|
||||
$this->filterData['date_to'] ?? null,
|
||||
$this->activeFilters
|
||||
)['totalVisits'] ?? 0;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
@@ -116,6 +166,25 @@ class ViewShortUrlStats extends Page implements HasForms
|
||||
->color('gray')
|
||||
->url(ShortUrlResource::getUrl()),
|
||||
|
||||
Action::make('refresh')
|
||||
->label(__('filament-short-url::default.stats_btn_refresh'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action(function () {
|
||||
$this->record->clearStatsCache(
|
||||
$this->filterData['date_from'] ?? null,
|
||||
$this->filterData['date_to'] ?? null,
|
||||
$this->activeFilters
|
||||
);
|
||||
|
||||
$this->dispatch('$refresh');
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.stats_refresh_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('copy_url')
|
||||
->label(__('filament-short-url::default.stats_btn_copy'))
|
||||
->icon('heroicon-o-clipboard')
|
||||
|
||||
@@ -1,598 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\ViewField;
|
||||
use Filament\Schemas\Components\Group;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\AppLinkingTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\LinkTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\MarketingTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\QrDesignTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TargetingTab;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TrackingTab;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ShortUrlForm
|
||||
{
|
||||
/**
|
||||
* Configure the short URL resource form schema.
|
||||
*/
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Tabs::make()->tabs([
|
||||
static::linkTab(),
|
||||
static::targetingTab(),
|
||||
static::appLinkingTab(),
|
||||
static::trackingTab(),
|
||||
static::marketingTab(),
|
||||
static::qrDesignTab(),
|
||||
])->columnSpanFull(),
|
||||
Tabs::make()
|
||||
->tabs([
|
||||
LinkTab::make(),
|
||||
TargetingTab::make(),
|
||||
AppLinkingTab::make(),
|
||||
TrackingTab::make(),
|
||||
MarketingTab::make(),
|
||||
QrDesignTab::make(),
|
||||
])->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function linkTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_link'))
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
Section::make()->schema([
|
||||
TextInput::make('destination_url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->helperText(__('filament-short-url::default.destination_url_helper'))
|
||||
->required()
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
|
||||
$safeBrowsing = app(SafeBrowsingService::class);
|
||||
if (! $safeBrowsing->isSafe($value)) {
|
||||
$fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.');
|
||||
}
|
||||
},
|
||||
])
|
||||
->live(onBlur: true)
|
||||
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
$parts = parse_url($state);
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
$set('utm_source', $query['utm_source'] ?? null);
|
||||
$set('utm_medium', $query['utm_medium'] ?? null);
|
||||
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
||||
$set('utm_term', $query['utm_term'] ?? null);
|
||||
$set('utm_content', $query['utm_content'] ?? null);
|
||||
}
|
||||
})
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
$parts = parse_url($state);
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
$set('utm_source', $query['utm_source'] ?? null);
|
||||
$set('utm_medium', $query['utm_medium'] ?? null);
|
||||
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
||||
$set('utm_term', $query['utm_term'] ?? null);
|
||||
$set('utm_content', $query['utm_content'] ?? null);
|
||||
} else {
|
||||
$set('utm_source', null);
|
||||
$set('utm_medium', null);
|
||||
$set('utm_campaign', null);
|
||||
$set('utm_term', null);
|
||||
$set('utm_content', null);
|
||||
}
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('url_key')
|
||||
->label(__('filament-short-url::default.url_key'))
|
||||
->helperText(__('filament-short-url::default.url_key_helper'))
|
||||
->alphaDash()
|
||||
->maxLength(32)
|
||||
->unique('short_urls', 'url_key', ignoreRecord: true)
|
||||
->suffixAction(
|
||||
Action::make('regenerate')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->tooltip('Generate new key')
|
||||
->action(function (Set $set): void {
|
||||
$set('url_key', app(ShortUrlService::class)->generateKey());
|
||||
})
|
||||
)
|
||||
->placeholder('auto-generated'),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
])
|
||||
->default(fn () => config('filament-short-url.redirect_status_code', 302))
|
||||
->required(),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_options'))->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label(__('filament-short-url::default.status'))
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('forward_query_params')
|
||||
->label(__('filament-short-url::default.forward_query_params'))
|
||||
->helperText(__('filament-short-url::default.forward_query_params_helper'))
|
||||
->default(false)
|
||||
->inline(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_validity'))
|
||||
->schema([
|
||||
Toggle::make('use_date_validity')
|
||||
->label(__('filament-short-url::default.use_date_validity'))
|
||||
->dehydrated(false)
|
||||
->live()
|
||||
->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) {
|
||||
$set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null);
|
||||
})
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state) {
|
||||
$set('activated_at', now()->startOfMinute());
|
||||
} else {
|
||||
$set('activated_at', null);
|
||||
$set('expires_at', null);
|
||||
$set('expiration_redirect_url', null);
|
||||
}
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
DateTimePicker::make('activated_at')
|
||||
->label(__('filament-short-url::default.activated_at'))
|
||||
->nullable()
|
||||
->native(false)
|
||||
->withoutSeconds()
|
||||
->live(onBlur: true)
|
||||
->required(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->minDate(now()->startOfDay())
|
||||
->maxDate(fn (Get $get) => $get('expires_at')),
|
||||
|
||||
DateTimePicker::make('expires_at')
|
||||
->label(__('filament-short-url::default.expires_at'))
|
||||
->nullable()
|
||||
->native(false)
|
||||
->withoutSeconds()
|
||||
->live(onBlur: true)
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()),
|
||||
|
||||
TextInput::make('expiration_redirect_url')
|
||||
->label(__('filament-short-url::default.expiration_redirect_url'))
|
||||
->helperText(__('filament-short-url::default.expiration_redirect_url_helper'))
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('single_use')
|
||||
->label(__('filament-short-url::default.single_use'))
|
||||
->helperText(__('filament-short-url::default.single_use_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
TextInput::make('max_visits')
|
||||
->label(__('filament-short-url::default.max_visits'))
|
||||
->helperText(__('filament-short-url::default.max_visits_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->nullable()
|
||||
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('filament-short-url::default.notes'))
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function syncUtmToDestination(Get $get, Set $set): void
|
||||
{
|
||||
$url = $get('destination_url');
|
||||
if (! $url) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$query = [];
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
}
|
||||
|
||||
// List of UTM parameters we build
|
||||
$utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||
foreach ($utms as $utm) {
|
||||
$val = $get($utm);
|
||||
if ($val !== null && $val !== '') {
|
||||
$query[$utm] = $val;
|
||||
} else {
|
||||
unset($query[$utm]);
|
||||
}
|
||||
}
|
||||
|
||||
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
|
||||
$host = $parts['host'] ?? '';
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
$path = $parts['path'] ?? '';
|
||||
$queryString = ! empty($query) ? '?'.http_build_query($query) : '';
|
||||
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
|
||||
|
||||
$set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment);
|
||||
}
|
||||
|
||||
private static function trackingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_tracking'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_tracking'))
|
||||
->schema([
|
||||
Toggle::make('track_visits')
|
||||
->label(__('filament-short-url::default.track_visits'))
|
||||
->default(fn () => config('filament-short-url.tracking.enabled', true))
|
||||
->live()
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
])->columns(1),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
|
||||
->schema([
|
||||
Toggle::make('track_ip_address')
|
||||
->label(__('filament-short-url::default.track_ip'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.ip_address', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser')
|
||||
->label(__('filament-short-url::default.track_browser'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser_version')
|
||||
->label(__('filament-short-url::default.track_browser_version'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser_version', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_operating_system')
|
||||
->label(__('filament-short-url::default.track_os'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_operating_system_version')
|
||||
->label(__('filament-short-url::default.track_os_version'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_device_type')
|
||||
->label(__('filament-short-url::default.track_device_type'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.device_type', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_referer_url')
|
||||
->label(__('filament-short-url::default.track_referer'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser_language')
|
||||
->label(__('filament-short-url::default.track_browser_language'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser_language', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
])
|
||||
->columns(4)
|
||||
->hidden(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Section::make(__('filament-short-url::default.utm_builder'))
|
||||
->description(__('filament-short-url::default.utm_builder_helper'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('utm_source')
|
||||
->label(__('filament-short-url::default.utm_source'))
|
||||
->placeholder(__('filament-short-url::default.utm_source_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_medium')
|
||||
->label(__('filament-short-url::default.utm_medium'))
|
||||
->placeholder(__('filament-short-url::default.utm_medium_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_campaign')
|
||||
->label(__('filament-short-url::default.utm_campaign'))
|
||||
->placeholder(__('filament-short-url::default.utm_campaign_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_term')
|
||||
->label(__('filament-short-url::default.utm_term'))
|
||||
->placeholder(__('filament-short-url::default.utm_term_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_content')
|
||||
->label(__('filament-short-url::default.utm_content'))
|
||||
->placeholder(__('filament-short-url::default.utm_content_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->columnSpanFull()
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_analytics'))
|
||||
->schema([
|
||||
TextInput::make('ga_tracking_id')
|
||||
->label(__('filament-short-url::default.ga_tracking_id'))
|
||||
->helperText(__('filament-short-url::default.ga_tracking_id_helper'))
|
||||
->placeholder('G-XXXXXXXXXX')
|
||||
->regex('/^G-[A-Z0-9]+$/')
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function qrDesignTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_qr_design'))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->schema([
|
||||
Group::make([
|
||||
TextInput::make('qr_options')
|
||||
->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
])
|
||||
->extraInputAttributes([
|
||||
'id' => 'qr-options-json-input',
|
||||
])
|
||||
->hiddenLabel()
|
||||
->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: [])
|
||||
->afterStateHydrated(function (TextInput $component, mixed $state): void {
|
||||
$component->state(is_array($state) ? json_encode($state) : ($state ?? '{}'));
|
||||
}),
|
||||
|
||||
TextInput::make('qr_logo')
|
||||
->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
])
|
||||
->extraInputAttributes([
|
||||
'id' => 'qr-logo-path-input',
|
||||
])
|
||||
->hiddenLabel()
|
||||
->nullable(),
|
||||
])->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
]),
|
||||
|
||||
ViewField::make('qr_designer')
|
||||
->view('filament-short-url::qr-designer')
|
||||
->columnSpanFull()
|
||||
->dehydrated(false),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function targetingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_targeting'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.security_section_title'))
|
||||
->schema([
|
||||
TextInput::make('password')
|
||||
->label(__('filament-short-url::default.password'))
|
||||
->helperText(__('filament-short-url::default.password_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->nullable()
|
||||
->maxLength(255),
|
||||
|
||||
Toggle::make('show_warning_page')
|
||||
->label(__('filament-short-url::default.show_warning_page'))
|
||||
->helperText(__('filament-short-url::default.show_warning_page_helper'))
|
||||
->default(false)
|
||||
->inline(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.targeting_type'))
|
||||
->schema([
|
||||
Select::make('targeting_rules.type')
|
||||
->label(__('filament-short-url::default.targeting_type'))
|
||||
->options([
|
||||
'none' => __('filament-short-url::default.targeting_type_none'),
|
||||
'device' => __('filament-short-url::default.targeting_type_device'),
|
||||
'geo' => __('filament-short-url::default.targeting_type_country'),
|
||||
'language' => __('filament-short-url::default.targeting_type_language'),
|
||||
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
|
||||
])
|
||||
->default('none')
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
Section::make(__('filament-short-url::default.device_targeting_rules'))
|
||||
->schema([
|
||||
TextInput::make('targeting_rules.device.mobile')
|
||||
->label(__('filament-short-url::default.device_mobile'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.tablet')
|
||||
->label(__('filament-short-url::default.device_tablet'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.desktop')
|
||||
->label(__('filament-short-url::default.device_desktop'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(1)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'device'),
|
||||
|
||||
Repeater::make('targeting_rules.geo')
|
||||
->label(__('filament-short-url::default.country_targeting_rules'))
|
||||
->schema([
|
||||
Select::make('country_code')
|
||||
->label(__('filament-short-url::default.country_code'))
|
||||
->options(function (): array {
|
||||
$countries = __('filament-short-url::countries');
|
||||
if (is_array($countries)) {
|
||||
asort($countries, SORT_LOCALE_STRING);
|
||||
|
||||
return $countries;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->searchable()
|
||||
->optionsLimit(300)
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->required(),
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
|
||||
|
||||
Repeater::make('targeting_rules.language')
|
||||
->label(__('filament-short-url::default.language_targeting_rules'))
|
||||
->schema([
|
||||
Select::make('language_code')
|
||||
->label(__('filament-short-url::default.language_code'))
|
||||
->options(function (): array {
|
||||
$languages = __('filament-short-url::languages');
|
||||
if (is_array($languages)) {
|
||||
asort($languages, SORT_LOCALE_STRING);
|
||||
|
||||
return $languages;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->searchable()
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->required(),
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'language'),
|
||||
|
||||
Repeater::make('targeting_rules.rotation')
|
||||
->label(__('filament-short-url::default.rotation_targeting_rules'))
|
||||
->schema([
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.rotation_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
TextInput::make('weight')
|
||||
->label(__('filament-short-url::default.rotation_weight'))
|
||||
->helperText(__('filament-short-url::default.rotation_weight_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(1000)
|
||||
->required()
|
||||
->default(50),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
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([
|
||||
Select::make('pixels')
|
||||
->label(__('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels')
|
||||
->multiple()
|
||||
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
||||
->preload()
|
||||
->searchable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function appLinkingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_app_linking') ?? 'App Linking')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_app_linking') ?? 'App Linking / Deep Links')
|
||||
->schema([
|
||||
Toggle::make('auto_open_app_mobile')
|
||||
->label(__('filament-short-url::default.auto_open_app_mobile') ?? 'Auto open app on mobile')
|
||||
->helperText(__('filament-short-url::default.auto_open_app_mobile_helper') ?? 'Enable this if you want your link to automatically open as an app when accessed on mobile.')
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
ViewField::make('app_linking_preview')
|
||||
->view('filament-short-url::app-linking-preview')
|
||||
->viewData(fn (Get $get) => [
|
||||
'destinationUrl' => $get('destination_url'),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support;
|
||||
|
||||
class WeightBalancer
|
||||
{
|
||||
/**
|
||||
* Balance the weights of variants dynamically to sum up to exactly 100%.
|
||||
*/
|
||||
public static function balanceWeights(mixed $component): void
|
||||
{
|
||||
$items = $component->getState() ?? [];
|
||||
$m = count($items);
|
||||
if ($m === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sum = 0;
|
||||
foreach ($items as $key => $item) {
|
||||
$items[$key]['weight'] = (int) ($item['weight'] ?? 0);
|
||||
$sum += $items[$key]['weight'];
|
||||
}
|
||||
|
||||
$diff = 100 - $sum;
|
||||
if ($diff !== 0) {
|
||||
$diffSteps = (int) ($diff / 5);
|
||||
$changeSteps = (int) ($diffSteps / $m);
|
||||
$remainderSteps = $diffSteps % $m;
|
||||
|
||||
foreach ($items as $key => $item) {
|
||||
$items[$key]['weight'] = max(0, min(100, $items[$key]['weight'] + $changeSteps * 5));
|
||||
}
|
||||
|
||||
$step = $remainderSteps > 0 ? 5 : -5;
|
||||
$count = abs($remainderSteps);
|
||||
$keys = array_keys($items);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$k = $keys[$i % $m];
|
||||
$items[$k]['weight'] = max(0, min(100, $items[$k]['weight'] + $step));
|
||||
}
|
||||
|
||||
// Ensure sum is exactly 100 by forcing the difference on the first eligible item
|
||||
$finalSum = array_sum(array_column($items, 'weight'));
|
||||
$finalDiff = 100 - $finalSum;
|
||||
if ($finalDiff !== 0) {
|
||||
foreach ($items as $key => $item) {
|
||||
$newWeight = $items[$key]['weight'] + $finalDiff;
|
||||
if ($newWeight >= 0 && $newWeight <= 100) {
|
||||
$items[$key]['weight'] = $newWeight;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$component->state($items);
|
||||
$component->getLivewire()->getErrorBag()->forget($component->getStatePath());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance the weights of variants equally (e.g. on add/delete).
|
||||
*/
|
||||
public static function balanceWeightsEqually(mixed $component): void
|
||||
{
|
||||
$items = $component->getState() ?? [];
|
||||
$m = count($items);
|
||||
if ($m === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$base = (int) (100 / $m);
|
||||
$remainder = 100 % $m;
|
||||
|
||||
foreach ($items as $key => $item) {
|
||||
$items[$key]['weight'] = $base;
|
||||
}
|
||||
|
||||
$keys = array_keys($items);
|
||||
for ($i = 0; $i < $remainder; $i++) {
|
||||
$k = $keys[$i];
|
||||
$items[$k]['weight'] += 1;
|
||||
}
|
||||
|
||||
$component->state($items);
|
||||
|
||||
$component->getLivewire()->getErrorBag()->forget($component->getStatePath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update validation errors on the repeater level when a slider is dragged.
|
||||
*/
|
||||
public static function updateRepeaterValidationError(mixed $component): void
|
||||
{
|
||||
$statePath = $component->getStatePath();
|
||||
$repeaterPath = (string) str($statePath)->beforeLast('.')->beforeLast('.');
|
||||
$livewire = $component->getLivewire();
|
||||
|
||||
$get = $component->makeGetUtility();
|
||||
$items = $get($repeaterPath, isAbsolute: true) ?? [];
|
||||
$sum = array_sum(array_column($items, 'weight'));
|
||||
|
||||
if ($sum !== 100) {
|
||||
$livewire->addError($repeaterPath, __('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
||||
} else {
|
||||
$livewire->getErrorBag()->forget($repeaterPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
||||
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\ViewField;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class AppLinkingTab
|
||||
{
|
||||
/**
|
||||
* Build the app linking details form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_app_linking'))
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_app_linking'))
|
||||
->schema([
|
||||
Toggle::make('auto_open_app_mobile')
|
||||
->label(__('filament-short-url::default.auto_open_app_mobile'))
|
||||
->helperText(__('filament-short-url::default.auto_open_app_mobile_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
ViewField::make('app_linking_preview')
|
||||
->view('filament-short-url::app-linking-preview')
|
||||
->viewData(fn (Get $get) => [
|
||||
'destinationUrl' => $get('destination_url'),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
388
src/Filament/Resources/ShortUrlResource/Schemas/Tabs/LinkTab.php
Normal file
388
src/Filament/Resources/ShortUrlResource/Schemas/Tabs/LinkTab.php
Normal file
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Repeater\TableColumn;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\ToggleButtons;
|
||||
use Filament\Schemas\Components\FusedGroup;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LinkTab
|
||||
{
|
||||
/**
|
||||
* Build the link details form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_link'))
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
Section::make()->schema([
|
||||
ToggleButtons::make('destination_type')
|
||||
->label(__('filament-short-url::default.destination_type'))
|
||||
->options([
|
||||
'single' => __('filament-short-url::default.destination_type_single'),
|
||||
'split' => __('filament-short-url::default.destination_type_split'),
|
||||
])
|
||||
->colors([
|
||||
'single' => 'primary',
|
||||
'split' => 'warning',
|
||||
])
|
||||
->icons([
|
||||
'single' => 'heroicon-o-link',
|
||||
'split' => 'heroicon-o-arrow-path-rounded-square',
|
||||
])
|
||||
->default('single')
|
||||
->live()
|
||||
->inline()
|
||||
->columnSpanFull()
|
||||
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
||||
if ($state === 'split' && empty($get('rotation_variants'))) {
|
||||
$set('rotation_variants', [
|
||||
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
|
||||
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
|
||||
]);
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('destination_url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->helperText(__('filament-short-url::default.destination_url_helper'))
|
||||
->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
|
||||
->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
app(SafeUrl::class),
|
||||
])
|
||||
->live(onBlur: true)
|
||||
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
$parts = parse_url($state);
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
$set('utm_source', $query['utm_source'] ?? null);
|
||||
$set('utm_medium', $query['utm_medium'] ?? null);
|
||||
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
||||
$set('utm_term', $query['utm_term'] ?? null);
|
||||
$set('utm_content', $query['utm_content'] ?? null);
|
||||
}
|
||||
})
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
$parts = parse_url($state);
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
$set('utm_source', $query['utm_source'] ?? null);
|
||||
$set('utm_medium', $query['utm_medium'] ?? null);
|
||||
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
||||
$set('utm_term', $query['utm_term'] ?? null);
|
||||
$set('utm_content', $query['utm_content'] ?? null);
|
||||
} else {
|
||||
$set('utm_source', null);
|
||||
$set('utm_medium', null);
|
||||
$set('utm_campaign', null);
|
||||
$set('utm_term', null);
|
||||
$set('utm_content', null);
|
||||
}
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
Repeater::make('rotation_variants')
|
||||
->hiddenLabel()
|
||||
->extraAttributes(['class' => 'ab-test-repeater'])
|
||||
->table([
|
||||
TableColumn::make(__('filament-short-url::default.variant_label'))
|
||||
->width('30%'),
|
||||
TableColumn::make(__('filament-short-url::default.variant_url'))
|
||||
->width('70%'),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->hiddenLabel()
|
||||
->placeholder('e.g. Variant A')
|
||||
->required()
|
||||
->maxLength(100),
|
||||
TextInput::make('url')
|
||||
->hiddenLabel()
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
app(SafeUrl::class),
|
||||
]),
|
||||
Hidden::make('weight'),
|
||||
])
|
||||
->defaultItems(2)
|
||||
->minItems(2)
|
||||
->maxItems(5)
|
||||
->reorderable(false)
|
||||
->live()
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
$sum = array_sum(array_column($value, 'weight'));
|
||||
if ($sum !== 100) {
|
||||
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
||||
}
|
||||
};
|
||||
},
|
||||
])
|
||||
->deleteAction(
|
||||
fn ($action) => $action
|
||||
->visible(fn (Get $get): bool => count($get('rotation_variants') ?? []) > 2)
|
||||
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
|
||||
)
|
||||
->addAction(
|
||||
fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
|
||||
)
|
||||
->addActionLabel(__('filament-short-url::default.add_url'))
|
||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||
->columnSpanFull(),
|
||||
|
||||
TrafficSplitter::make('traffic_split')
|
||||
->label(__('filament-short-url::default.traffic_split'))
|
||||
->target('rotation_variants')
|
||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||
->columnSpanFull(),
|
||||
|
||||
FusedGroup::make([
|
||||
Select::make('custom_domain_id')
|
||||
->hiddenLabel()
|
||||
->options(function () {
|
||||
$domains = ShortUrlCustomDomain::where('is_active', true)
|
||||
->where('is_verified', true)
|
||||
->pluck('domain', 'id');
|
||||
|
||||
if (! config('filament-short-url.disable_default_domain', false)) {
|
||||
$defaultDomain = request()->getHost() ?: parse_url(config('app.url'), PHP_URL_HOST);
|
||||
$domains = collect(['default' => $defaultDomain])->union($domains);
|
||||
}
|
||||
|
||||
return $domains;
|
||||
})
|
||||
->default(function () {
|
||||
if (! config('filament-short-url.disable_default_domain', false)) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
$firstDomain = ShortUrlCustomDomain::where('is_active', true)
|
||||
->where('is_verified', true)
|
||||
->first();
|
||||
|
||||
return $firstDomain ? $firstDomain->id : null;
|
||||
})
|
||||
->afterStateHydrated(function (Select $component, $state) {
|
||||
if ($state === null && ! config('filament-short-url.disable_default_domain', false)) {
|
||||
$component->state('default');
|
||||
}
|
||||
})
|
||||
->dehydrateStateUsing(fn ($state) => $state === 'default' ? null : $state)
|
||||
->disabled(function (?ShortUrl $record) {
|
||||
if ($record && $record->exists && config('filament-short-url.lock_url_key', false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$domainsCount = ShortUrlCustomDomain::where('is_active', true)
|
||||
->where('is_verified', true)
|
||||
->count();
|
||||
|
||||
$defaultEnabled = ! config('filament-short-url.disable_default_domain', false);
|
||||
$totalOptionsCount = $domainsCount + ($defaultEnabled ? 1 : 0);
|
||||
|
||||
return $totalOptionsCount <= 1;
|
||||
})
|
||||
->dehydrated()
|
||||
->required(fn () => (bool) config('filament-short-url.disable_default_domain', false))
|
||||
->selectablePlaceholder(false)
|
||||
->nullable()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('url_key')
|
||||
->hiddenLabel()
|
||||
->helperText(__('filament-short-url::default.url_key_helper'))
|
||||
->alphaDash()
|
||||
->maxLength(32)
|
||||
->unique('short_urls', 'url_key', ignoreRecord: true)
|
||||
->disabled(fn (?ShortUrl $record) => $record && $record->exists && config('filament-short-url.lock_url_key', false))
|
||||
->placeholder('auto-generated')
|
||||
->suffixAction(
|
||||
Action::make('regenerate')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->tooltip('Generate new key')
|
||||
->action(function (Set $set, ShortUrlService $service): void {
|
||||
$set('url_key', $service->generateKey());
|
||||
})
|
||||
->visible(fn (?ShortUrl $record) => ! ($record && $record->exists && config('filament-short-url.lock_url_key', false)))
|
||||
),
|
||||
])
|
||||
->extraAttributes(['class' => 'custom-fused'])
|
||||
->label(__('filament-short-url::default.short_link_label'))
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
])
|
||||
->default(fn () => config('filament-short-url.redirect_status_code', 302))
|
||||
->required(),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_options'))->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label(__('filament-short-url::default.status'))
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('forward_query_params')
|
||||
->label(__('filament-short-url::default.forward_query_params'))
|
||||
->helperText(__('filament-short-url::default.forward_query_params_helper'))
|
||||
->default(false)
|
||||
->inline(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_validity'))
|
||||
->schema([
|
||||
Toggle::make('use_date_validity')
|
||||
->label(__('filament-short-url::default.use_date_validity'))
|
||||
->dehydrated(false)
|
||||
->live()
|
||||
->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) {
|
||||
$set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null);
|
||||
})
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state) {
|
||||
$set('activated_at', now()->startOfMinute());
|
||||
} else {
|
||||
$set('activated_at', null);
|
||||
$set('expires_at', null);
|
||||
$set('expiration_redirect_url', null);
|
||||
}
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
DateTimePicker::make('activated_at')
|
||||
->label(__('filament-short-url::default.activated_at'))
|
||||
->nullable()
|
||||
->native(false)
|
||||
->withoutSeconds()
|
||||
->live(onBlur: true)
|
||||
->required(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->minDate(now()->startOfDay())
|
||||
->maxDate(fn (Get $get) => $get('expires_at')),
|
||||
|
||||
DateTimePicker::make('expires_at')
|
||||
->label(__('filament-short-url::default.expires_at'))
|
||||
->nullable()
|
||||
->native(false)
|
||||
->withoutSeconds()
|
||||
->live(onBlur: true)
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()),
|
||||
|
||||
TextInput::make('expiration_redirect_url')
|
||||
->label(__('filament-short-url::default.expiration_redirect_url'))
|
||||
->helperText(__('filament-short-url::default.expiration_redirect_url_helper'))
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('single_use')
|
||||
->label(__('filament-short-url::default.single_use'))
|
||||
->helperText(__('filament-short-url::default.single_use_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
TextInput::make('max_visits')
|
||||
->label(__('filament-short-url::default.max_visits'))
|
||||
->helperText(__('filament-short-url::default.max_visits_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->nullable()
|
||||
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('filament-short-url::default.notes'))
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize campaign parameters into the query parameters of the destination URL.
|
||||
*/
|
||||
public static function syncUtmToDestination(Get $get, Set $set): void
|
||||
{
|
||||
$url = $get('destination_url');
|
||||
if (! $url) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$query = [];
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
}
|
||||
|
||||
$utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||
foreach ($utms as $utm) {
|
||||
$val = $get($utm);
|
||||
if ($val !== null && $val !== '') {
|
||||
$query[$utm] = $val;
|
||||
} else {
|
||||
unset($query[$utm]);
|
||||
}
|
||||
}
|
||||
|
||||
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
|
||||
$host = $parts['host'] ?? '';
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
$path = $parts['path'] ?? '';
|
||||
$queryString = ! empty($query) ? '?'.http_build_query($query) : '';
|
||||
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
|
||||
|
||||
$set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
||||
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\ViewField;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class MarketingTab
|
||||
{
|
||||
/**
|
||||
* Build the marketing details form tab.
|
||||
*/
|
||||
public static function make(): 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([
|
||||
Select::make('pixels')
|
||||
->label(__('filament-short-url::default.pixels_navigation_label'))
|
||||
->multiple()
|
||||
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
||||
->preload()
|
||||
->searchable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
|
||||
->description(__('filament-short-url::default.marketing_webhooks_desc'))
|
||||
->schema([
|
||||
Placeholder::make('webhook_info')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString(
|
||||
'<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info">'.
|
||||
'<div class="mt-0.5 w-4" data-component-part="callout-icon">'.
|
||||
'<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info">'.
|
||||
'<path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path>'.
|
||||
'</svg>'.
|
||||
'</div>'.
|
||||
'<div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content">'.
|
||||
'<span data-as="p">'.
|
||||
__('filament-short-url::default.webhook_helper_alert').
|
||||
'</span>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
))
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('webhook_url')
|
||||
->label(__('filament-short-url::default.webhook_url'))
|
||||
->placeholder('https://api.yourcrm.com/webhooks/clicks')
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
ViewField::make('webhook_payload_example')
|
||||
->view('filament-short-url::webhook-payload-example')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\ViewField;
|
||||
use Filament\Schemas\Components\Group;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
|
||||
class QrDesignTab
|
||||
{
|
||||
/**
|
||||
* Build the QR code design form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_qr_design'))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->schema([
|
||||
Group::make([
|
||||
TextInput::make('qr_options')
|
||||
->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
])
|
||||
->extraInputAttributes([
|
||||
'id' => 'qr-options-json-input',
|
||||
])
|
||||
->hiddenLabel()
|
||||
->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: [])
|
||||
->afterStateHydrated(function (TextInput $component, mixed $state): void {
|
||||
$component->state(is_array($state) ? json_encode($state) : ($state ?? '{}'));
|
||||
}),
|
||||
|
||||
TextInput::make('qr_logo')
|
||||
->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
])
|
||||
->extraInputAttributes([
|
||||
'id' => 'qr-logo-path-input',
|
||||
])
|
||||
->hiddenLabel()
|
||||
->nullable(),
|
||||
])->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
]),
|
||||
|
||||
ViewField::make('qr_designer')
|
||||
->view('filament-short-url::qr-designer')
|
||||
->columnSpanFull()
|
||||
->dehydrated(false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Builder;
|
||||
use Filament\Forms\Components\Builder\Block;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Repeater\TableColumn;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Group;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TargetingTab
|
||||
{
|
||||
/**
|
||||
* Build the targeting and security form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_targeting'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.security_section_title'))
|
||||
->schema([
|
||||
Hidden::make('password'),
|
||||
|
||||
Hidden::make('password_active_flag')
|
||||
->dehydrated(false)
|
||||
->afterStateHydrated(function (Hidden $component, $state, ?ShortUrl $record) {
|
||||
$component->state($record && ! empty($record->password));
|
||||
}),
|
||||
|
||||
Hidden::make('is_entering_password')
|
||||
->dehydrated(false)
|
||||
->default(false),
|
||||
|
||||
// State 1: Password is active (password_active_flag is true)
|
||||
Group::make([
|
||||
Placeholder::make('password_status')
|
||||
->label(__('filament-short-url::default.password'))
|
||||
->content(new HtmlString(
|
||||
'<div class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-semibold">'.
|
||||
'<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>'.
|
||||
'<span>'.__('filament-short-url::default.password_status_active').'</span>'.
|
||||
'</div>'
|
||||
))
|
||||
->columnSpan(1),
|
||||
|
||||
Actions::make([
|
||||
Action::make('change_password')
|
||||
->label(__('filament-short-url::default.change_password'))
|
||||
->icon('heroicon-o-pencil')
|
||||
->color('primary')
|
||||
->action(function (Set $set) {
|
||||
$set('password_active_flag', false);
|
||||
$set('is_entering_password', true);
|
||||
$set('new_password_input', null);
|
||||
$set('new_password_confirmation_input', null);
|
||||
}),
|
||||
Action::make('remove_password')
|
||||
->label(__('filament-short-url::default.remove_password'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (Set $set, ?ShortUrl $record) {
|
||||
$set('password', null);
|
||||
$set('new_password_input', null);
|
||||
$set('new_password_confirmation_input', null);
|
||||
$set('password_active_flag', false);
|
||||
$set('is_entering_password', false);
|
||||
if ($record) {
|
||||
$record->password = null;
|
||||
}
|
||||
}),
|
||||
])
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('password_active_flag')),
|
||||
|
||||
// State 2: No Password, Setup Button Group (password_active_flag is false, is_entering_password is false)
|
||||
Group::make([
|
||||
Actions::make([
|
||||
Action::make('setup_password')
|
||||
->label(__('filament-short-url::default.set_password'))
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->action(function (Set $set) {
|
||||
$set('is_entering_password', true);
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
|
||||
|
||||
// State 3: Entering Password Group (password_active_flag is false, is_entering_password is true)
|
||||
Group::make([
|
||||
Group::make([
|
||||
TextInput::make('new_password_input')
|
||||
->label(__('filament-short-url::default.new_password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->live()
|
||||
->maxLength(255)
|
||||
->dehydrated(false)
|
||||
->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password'))
|
||||
->columnSpan(1),
|
||||
|
||||
TextInput::make('new_password_confirmation_input')
|
||||
->label(__('filament-short-url::default.confirm_password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->same('new_password_input')
|
||||
->maxLength(255)
|
||||
->dehydrated(false)
|
||||
->required(fn (Get $get): bool => ! empty($get('new_password_input')))
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Actions::make([
|
||||
Action::make('confirm_password')
|
||||
->label(__('filament-short-url::default.confirm'))
|
||||
->icon('heroicon-o-check')
|
||||
->color('success')
|
||||
->action(function (Get $get, Set $set) {
|
||||
$password = $get('new_password_input');
|
||||
$confirm = $get('new_password_confirmation_input');
|
||||
if (empty($password)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.password_required_error'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
if ($password !== $confirm) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.password_mismatch_error'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
$set('password', $password);
|
||||
$set('password_active_flag', true);
|
||||
$set('is_entering_password', false);
|
||||
}),
|
||||
|
||||
Action::make('cancel_password')
|
||||
->label(__('filament-short-url::default.cancel'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (Get $get, Set $set, ?ShortUrl $record) {
|
||||
if ($record && ! empty($record->password)) {
|
||||
$set('password_active_flag', true);
|
||||
} else {
|
||||
$set('password_active_flag', false);
|
||||
}
|
||||
$set('is_entering_password', false);
|
||||
$set('new_password_input', null);
|
||||
$set('new_password_confirmation_input', null);
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
|
||||
|
||||
Toggle::make('show_warning_page')
|
||||
->label(__('filament-short-url::default.show_warning_page'))
|
||||
->helperText(__('filament-short-url::default.show_warning_page_helper'))
|
||||
->default(false)
|
||||
->inline(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.targeting_rules'))
|
||||
->compact()
|
||||
->schema([
|
||||
Repeater::make('targeting_rules')
|
||||
->label(__('filament-short-url::default.targeting_rules'))
|
||||
->hiddenLabel()
|
||||
->defaultItems(0)
|
||||
->maxItems(10)
|
||||
->reorderable(false)
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columns(12)
|
||||
->afterStateHydrated(function (Repeater $component, $state) {
|
||||
if (! is_array($state)) {
|
||||
$component->state([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If it is legacy format (has 'type' key)
|
||||
if (isset($state['type'])) {
|
||||
$type = $state['type'];
|
||||
$newRules = [];
|
||||
|
||||
if ($type === 'device') {
|
||||
$devices = $state['device'] ?? [];
|
||||
$mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null;
|
||||
if ($mobileUrl) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $mobileUrl,
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
$tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null;
|
||||
if ($tabletUrl) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $tabletUrl,
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['tablet']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
$desktopUrl = $devices['desktop'] ?? null;
|
||||
if ($desktopUrl) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $desktopUrl,
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['desktop']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
} elseif ($type === 'geo') {
|
||||
foreach ($state['geo'] ?? [] as $geoRule) {
|
||||
if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $geoRule['url'],
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'country',
|
||||
'data' => ['countries' => [strtoupper($geoRule['country_code'])]],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'language') {
|
||||
foreach ($state['language'] ?? [] as $langRule) {
|
||||
if (! empty($langRule['url']) && ! empty($langRule['language_code'])) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $langRule['url'],
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'language',
|
||||
'data' => ['languages' => [strtolower($langRule['language_code'])]],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$component->state($newRules);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! array_is_list($state)) {
|
||||
$component->state([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$component->state($state);
|
||||
})
|
||||
->schema([
|
||||
Select::make('match')
|
||||
->label(__('filament-short-url::default.match'))
|
||||
->options([
|
||||
'or' => __('filament-short-url::default.match_or'),
|
||||
'and' => __('filament-short-url::default.match_and'),
|
||||
])
|
||||
->default('or')
|
||||
->required()
|
||||
->columnSpan(3),
|
||||
|
||||
Select::make('destination_type')
|
||||
->label(__('filament-short-url::default.destination_type'))
|
||||
->options([
|
||||
'single' => __('filament-short-url::default.destination_type_single'),
|
||||
'split' => __('filament-short-url::default.destination_type_split'),
|
||||
])
|
||||
->default('single')
|
||||
->live()
|
||||
->required()
|
||||
->columnSpan(3)
|
||||
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
||||
if ($state === 'split' && empty($get('variants'))) {
|
||||
$set('variants', [
|
||||
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
|
||||
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
|
||||
]);
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.direct_to_url'))
|
||||
->url()
|
||||
->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
|
||||
->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
app(SafeUrl::class),
|
||||
])
|
||||
->columnSpan(6),
|
||||
|
||||
Repeater::make('variants')
|
||||
->hiddenLabel()
|
||||
->extraAttributes(['class' => 'ab-test-repeater'])
|
||||
->table([
|
||||
TableColumn::make(__('filament-short-url::default.variant_label'))
|
||||
->width('30%'),
|
||||
TableColumn::make(__('filament-short-url::default.variant_url'))
|
||||
->width('70%'),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->hiddenLabel()
|
||||
->placeholder('e.g. Variant A')
|
||||
->required()
|
||||
->maxLength(100),
|
||||
TextInput::make('url')
|
||||
->hiddenLabel()
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
app(SafeUrl::class),
|
||||
]),
|
||||
Hidden::make('weight'),
|
||||
])
|
||||
->defaultItems(2)
|
||||
->minItems(2)
|
||||
->maxItems(5)
|
||||
->reorderable(false)
|
||||
->live()
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
$sum = array_sum(array_column($value, 'weight'));
|
||||
if ($sum !== 100) {
|
||||
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
||||
}
|
||||
};
|
||||
},
|
||||
])
|
||||
->deleteAction(
|
||||
fn ($action) => $action
|
||||
->visible(fn (Get $get): bool => count($get('variants') ?? []) > 2)
|
||||
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
|
||||
)
|
||||
->addAction(
|
||||
fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
|
||||
)
|
||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||
->columnSpanFull(),
|
||||
|
||||
TrafficSplitter::make('traffic_split')
|
||||
->label(__('filament-short-url::default.traffic_split'))
|
||||
->target('variants')
|
||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||
->columnSpanFull(),
|
||||
|
||||
Builder::make('filters')
|
||||
->label(__('filament-short-url::default.add_filter'))
|
||||
->hiddenLabel()
|
||||
->addActionLabel(__('filament-short-url::default.add_filter'))
|
||||
->reorderable(false)
|
||||
->collapsible()
|
||||
->blockNumbers(false)
|
||||
->addBetweenAction(fn ($action) => $action->hidden())
|
||||
->minItems(1)
|
||||
->blocks([
|
||||
Block::make('device')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_device')
|
||||
: new HtmlString(__('filament-short-url::default.select_devices').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
CheckboxList::make('devices')
|
||||
->label(__('filament-short-url::default.select_devices'))
|
||||
->hiddenLabel()
|
||||
->options([
|
||||
'desktop' => __('filament-short-url::default.device_desktop_label'),
|
||||
'mobile' => __('filament-short-url::default.device_mobile_label'),
|
||||
'tablet' => __('filament-short-url::default.device_tablet_label'),
|
||||
])
|
||||
->required()
|
||||
->columns(3)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
Block::make('platform')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_platform')
|
||||
: new HtmlString(__('filament-short-url::default.select_platforms').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-computer-desktop')
|
||||
->schema([
|
||||
CheckboxList::make('platforms')
|
||||
->label(__('filament-short-url::default.select_platforms'))
|
||||
->hiddenLabel()
|
||||
->options([
|
||||
'android' => 'Android',
|
||||
'fire_os' => 'Fire OS',
|
||||
'ios' => 'iOS / iPadOS',
|
||||
'linux' => 'Linux',
|
||||
'mac' => 'macOS',
|
||||
'windows' => 'Windows',
|
||||
])
|
||||
->required()
|
||||
->columns(3)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
Block::make('country')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_country')
|
||||
: new HtmlString(__('filament-short-url::default.select_countries').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->schema([
|
||||
Select::make('countries')
|
||||
->label(__('filament-short-url::default.select_countries'))
|
||||
->hiddenLabel()
|
||||
->multiple()
|
||||
->searchable()
|
||||
->allowHtml()
|
||||
->options(function (): array {
|
||||
$countries = __('filament-short-url::countries');
|
||||
if (is_array($countries)) {
|
||||
asort($countries, SORT_LOCALE_STRING);
|
||||
|
||||
$htmlOptions = [];
|
||||
foreach ($countries as $code => $name) {
|
||||
$lowerCode = strtolower($code);
|
||||
$htmlOptions[$code] = "<span class=\"flex items-center gap-2\"><img src=\"https://flagcdn.com/h20/{$lowerCode}.webp\" class=\"w-5 h-auto rounded-sm inline-block mr-2\" alt=\"{$name}\" style=\"vertical-align: middle;\" /><span>{$name}</span></span>";
|
||||
}
|
||||
|
||||
return $htmlOptions;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->optionsLimit(300)
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
Block::make('language')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_language')
|
||||
: new HtmlString(__('filament-short-url::default.select_languages').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-language')
|
||||
->schema([
|
||||
Select::make('languages')
|
||||
->label(__('filament-short-url::default.select_languages'))
|
||||
->hiddenLabel()
|
||||
->multiple()
|
||||
->searchable()
|
||||
->options(function (): array {
|
||||
$languages = __('filament-short-url::languages');
|
||||
if (is_array($languages)) {
|
||||
asort($languages, SORT_LOCALE_STRING);
|
||||
|
||||
return $languages;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
])
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
$types = collect($value)->pluck('type');
|
||||
if ($types->duplicates()->isNotEmpty()) {
|
||||
$fail('Each filter type (Device, Platform, Country, Language) can only be added once.');
|
||||
}
|
||||
};
|
||||
},
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->default([]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
|
||||
class TrackingTab
|
||||
{
|
||||
/**
|
||||
* Build the tracking details form tab.
|
||||
*/
|
||||
public static function make(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_tracking'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_tracking'))
|
||||
->schema([
|
||||
Toggle::make('track_visits')
|
||||
->label(__('filament-short-url::default.track_visits'))
|
||||
->default(fn () => config('filament-short-url.tracking.enabled', true))
|
||||
->live()
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
])->columns(1),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
|
||||
->schema([
|
||||
Toggle::make('track_ip_address')
|
||||
->label(__('filament-short-url::default.track_ip'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.ip_address', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser')
|
||||
->label(__('filament-short-url::default.track_browser'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser_version')
|
||||
->label(__('filament-short-url::default.track_browser_version'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser_version', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_operating_system')
|
||||
->label(__('filament-short-url::default.track_os'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_operating_system_version')
|
||||
->label(__('filament-short-url::default.track_os_version'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_device_type')
|
||||
->label(__('filament-short-url::default.track_device_type'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.device_type', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_referer_url')
|
||||
->label(__('filament-short-url::default.track_referer'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser_language')
|
||||
->label(__('filament-short-url::default.track_browser_language'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser_language', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
])
|
||||
->columns(4)
|
||||
->hidden(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Section::make(__('filament-short-url::default.utm_builder'))
|
||||
->description(__('filament-short-url::default.utm_builder_helper'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('utm_source')
|
||||
->label(__('filament-short-url::default.utm_source'))
|
||||
->placeholder(__('filament-short-url::default.utm_source_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_medium')
|
||||
->label(__('filament-short-url::default.utm_medium'))
|
||||
->placeholder(__('filament-short-url::default.utm_medium_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_campaign')
|
||||
->label(__('filament-short-url::default.utm_campaign'))
|
||||
->placeholder(__('filament-short-url::default.utm_campaign_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_term')
|
||||
->label(__('filament-short-url::default.utm_term'))
|
||||
->placeholder(__('filament-short-url::default.utm_term_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||
|
||||
TextInput::make('utm_content')
|
||||
->label(__('filament-short-url::default.utm_content'))
|
||||
->placeholder(__('filament-short-url::default.utm_content_placeholder'))
|
||||
->dehydrated(false)
|
||||
->live(onBlur: true)
|
||||
->columnSpanFull()
|
||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_analytics'))
|
||||
->schema([
|
||||
TextInput::make('ga_tracking_id')
|
||||
->label(__('filament-short-url::default.ga_tracking_id'))
|
||||
->helperText(__('filament-short-url::default.ga_tracking_id_helper'))
|
||||
->placeholder('G-XXXXXXXXXX')
|
||||
->regex('/^G-[A-Z0-9]+$/')
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
@@ -28,151 +29,18 @@ class ShortUrlsTable
|
||||
// ROW 1: Favicon + Short URL with copy capability on click of text or icon (vertically centered!)
|
||||
TextColumn::make('url_key')
|
||||
->label(__('filament-short-url::default.col_short_url'))
|
||||
->getStateUsing(static function (ShortUrl $record): HtmlString {
|
||||
$shortUrl = e($record->getShortUrl());
|
||||
$copiedMsg = e(__('filament-short-url::default.qr_copied'));
|
||||
$tooltipCopy = e(__('filament-short-url::default.action_copy'));
|
||||
$destHost = e(parse_url($record->destination_url, PHP_URL_HOST));
|
||||
|
||||
return new HtmlString(<<<HTML
|
||||
<div onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = '{$shortUrl}';
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textCopyArea = document.createElement('textarea');
|
||||
textCopyArea.value = text;
|
||||
textCopyArea.style.position = 'fixed';
|
||||
textCopyArea.style.left = '-999999px';
|
||||
textCopyArea.style.top = '-999999px';
|
||||
document.body.appendChild(textCopyArea);
|
||||
textCopyArea.focus();
|
||||
textCopyArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (err) {}
|
||||
textCopyArea.remove();
|
||||
}
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification()
|
||||
.title('{$copiedMsg}')
|
||||
.success()
|
||||
.send();
|
||||
} else if (window.Alpine) {
|
||||
window.Alpine.store('filament-notifications')?.send({
|
||||
status: 'success',
|
||||
title: '{$copiedMsg}'
|
||||
});
|
||||
}
|
||||
" class="flex items-center gap-3 cursor-pointer w-fit">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<span class="text-[#2a5bd7] text-[16px] font-bold leading-6 break-all line-clamp-1">
|
||||
{$shortUrl}
|
||||
</span>
|
||||
<span title="{$tooltipCopy}" class="w-8 h-8 rounded-full flex items-center justify-center bg-[#f4f4f5] hover:bg-[#e4e4e7] dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0 focus:outline-none">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
HTML
|
||||
);
|
||||
})
|
||||
->view('filament-short-url::table.url-key-column')
|
||||
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
|
||||
|
||||
// ROW 2: Sub-arrow + Destination URL
|
||||
TextColumn::make('destination_url')
|
||||
->label(__('filament-short-url::default.col_destination_url'))
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
style="transform: scaleY(-1);"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
|
||||
</svg>
|
||||
<a href="'.e($record->destination_url).'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate max-w-[50ch] hover:underline text-[#273144] text-[15px] leading-[16px] font-medium"
|
||||
title="'.e($record->destination_url).'">
|
||||
'.e($record->destination_url).'
|
||||
</a>
|
||||
</div>
|
||||
'))
|
||||
->view('filament-short-url::table.destination-column')
|
||||
->searchable(),
|
||||
|
||||
// ROW 3: Bottom Metadata (Total clicks / Unique clicks / Date added / Expiry / Redirect code)
|
||||
TextColumn::make('metadata_badges')
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<!-- Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
'.$record->total_visits.' '.__('filament-short-url::default.badge_clicks').'
|
||||
</span>
|
||||
|
||||
<!-- Unique Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
'.$record->unique_visits.' '.__('filament-short-url::default.badge_unique').'
|
||||
</span>
|
||||
|
||||
<!-- QR Scans Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
'.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').'
|
||||
</span>
|
||||
|
||||
<!-- Date Added Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
'.$record->created_at->translatedFormat('M d, Y').'
|
||||
</span>
|
||||
|
||||
<!-- Expiry / Single Use Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
'.($record->expires_at ? '
|
||||
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
'.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).'
|
||||
' : '
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
'.__('filament-short-url::default.badge_no_expiry').'
|
||||
').'
|
||||
</span>
|
||||
|
||||
<!-- Redirect Type Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
'.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).'
|
||||
</span>
|
||||
</div>
|
||||
')),
|
||||
->view('filament-short-url::table.metadata-badges'),
|
||||
]),
|
||||
])
|
||||
->filters([
|
||||
@@ -193,413 +61,173 @@ HTML
|
||||
'1' => 'Single-use',
|
||||
]),
|
||||
])
|
||||
->recordClasses(fn (): string => 'short-url-card')
|
||||
->recordClasses(fn (): string => 'short-url-card group/card')
|
||||
->actions([
|
||||
Action::make('share')
|
||||
->label(__('filament-short-url::default.action_share'))
|
||||
->icon('heroicon-o-share')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_share'))
|
||||
->modalHeading(__('filament-short-url::default.share_title'))
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form([
|
||||
Forms\Components\Placeholder::make('platforms')
|
||||
->label(__('filament-short-url::default.share_description'))
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-6 overflow-x-auto pb-4 pt-1 scroll-smooth" style="scrollbar-width: thin; -ms-overflow-style: none;">
|
||||
<!-- Messenger -->
|
||||
<a href="fb-messenger://share/?link='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-tr from-[#006aff] via-[#00b2ff] to-[#00d6ff] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.14 2 11.25c0 2.9 1.45 5.48 3.73 7.08v3.67c0 .24.23.4.43.27l4.07-2.3c.57.16 1.17.25 1.77.25 5.52 0 10-4.14 10-9.25S17.52 2 12 2zm1.09 11.95l-2.43-2.6-4.73 2.6 5.19-5.52 2.47 2.63 4.7-2.63-5.2 5.52z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Messenger</span>
|
||||
</a>
|
||||
<!-- Facebook -->
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#1877f2] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Facebook</span>
|
||||
</a>
|
||||
<!-- WhatsApp -->
|
||||
<a href="https://api.whatsapp.com/send?text='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#25d366] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">WhatsApp</span>
|
||||
</a>
|
||||
<!-- Twitter/X -->
|
||||
<a href="https://twitter.com/intent/tweet?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0f1419] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Twitter (X)</span>
|
||||
</a>
|
||||
<!-- LinkedIn -->
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0077b5] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">LinkedIn</span>
|
||||
</a>
|
||||
<!-- Email -->
|
||||
<a href="mailto:?body='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#6b7280] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Email</span>
|
||||
</a>
|
||||
</div>
|
||||
')),
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_edit').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">E</span></div>'))
|
||||
->modalAutofocus(false)
|
||||
->closeModalByClickingAway(false)
|
||||
->modalSubmitAction(false)
|
||||
->extraModalFooterActions(static fn (EditAction $action): array => [
|
||||
Action::make('save_changes')
|
||||
->label(__('filament-actions::edit.single.modal.actions.save.label') ?: 'Save changes')
|
||||
->color('primary')
|
||||
->modal(static function () use ($action): bool {
|
||||
/** @var ShortUrl $record */
|
||||
$record = $action->getRecord();
|
||||
$data = $action->getRawData();
|
||||
$newKey = $data['url_key'] ?? null;
|
||||
|
||||
Forms\Components\Placeholder::make('copy_field')
|
||||
->label('')
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-2 relative mt-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="'.e($record->getShortUrl()).'"
|
||||
id="share_link_input_'.$record->id.'"
|
||||
class="flex-1 min-w-0 block w-full px-3.5 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-gray-100 text-sm focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 focus:outline-none">
|
||||
|
||||
<button onclick="
|
||||
const input = document.getElementById(\'share_link_input_'.$record->id.'\');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
if (typeof FilamentNotification !== \'undefined\') {
|
||||
new FilamentNotification()
|
||||
.title(\''.e(__('filament-short-url::default.share_copied')).'\')
|
||||
.success()
|
||||
.send();
|
||||
} else if (typeof Alpine !== \'undefined\') {
|
||||
Alpine.store(\'filament-notifications\')?.send({
|
||||
status: \'success\',
|
||||
title: \''.e(__('filament-short-url::default.share_copied')).'\'
|
||||
});
|
||||
}
|
||||
"
|
||||
class="flex-shrink-0 inline-flex items-center justify-center gap-1.5 px-4 py-2.5 bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-white text-white dark:text-gray-950 font-semibold text-sm rounded-lg shadow-sm hover:shadow transition duration-200">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>'.e(__('filament-short-url::default.share_copy')).'</span>
|
||||
</button>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
return $newKey && $newKey !== $record->url_key;
|
||||
})
|
||||
->requiresConfirmation(static function () use ($action): bool {
|
||||
/** @var ShortUrl $record */
|
||||
$record = $action->getRecord();
|
||||
$data = $action->getRawData();
|
||||
$newKey = $data['url_key'] ?? null;
|
||||
|
||||
Action::make('qrCode')
|
||||
->label(__('filament-short-url::default.action_qr') ?? 'QR Code')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_qr') ?? 'QR Code')
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form(function (ShortUrl $record): array {
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$qrTargetUrl = $shortUrl.'?source=qr';
|
||||
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
|
||||
$urlKey = $record->url_key;
|
||||
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
|
||||
return $newKey && $newKey !== $record->url_key;
|
||||
})
|
||||
->modalHeading(__('filament-short-url::default.url_key_change_confirmation_heading') ?: 'Replace link?')
|
||||
->modalDescription(__('filament-short-url::default.url_key_change_confirmation') ?: 'You have modified the short key of this link. Saving these changes will make the original short URL stop working.')
|
||||
->action(static function () use ($action) {
|
||||
/** @var ShortUrl $record */
|
||||
$record = $action->getRecord();
|
||||
$livewire = $action->getLivewire();
|
||||
$state = $livewire->getMountedTableActionForm()->getState();
|
||||
|
||||
$qrHelperText = __('filament-short-url::default.qr_modal_helper') ?? 'Scan, copy, or download your custom QR code.';
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
|
||||
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
|
||||
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
|
||||
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
|
||||
$action->process(function () use ($record, $state) {
|
||||
$record->update($state);
|
||||
});
|
||||
|
||||
$qrDefaults = $record->getQrOptions();
|
||||
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
|
||||
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
|
||||
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
|
||||
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
|
||||
$action->success();
|
||||
$action->sendSuccessNotification();
|
||||
|
||||
$dotsOptions = $isGrad ? [
|
||||
'type' => $dotStyle,
|
||||
'gradient' => [
|
||||
'type' => $qrDefaults['gradient_type'] ?? 'linear',
|
||||
'colorStops' => [
|
||||
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
|
||||
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
|
||||
],
|
||||
],
|
||||
] : [
|
||||
'type' => $dotStyle,
|
||||
'color' => $fgColor,
|
||||
];
|
||||
$livewire->mountedActions = [];
|
||||
}),
|
||||
]),
|
||||
|
||||
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
|
||||
|
||||
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
|
||||
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
|
||||
|
||||
$cornersSquareOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeSquareStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$cornersDotOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeDotStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$logo = $qrDefaults['logo'] ?? null;
|
||||
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
|
||||
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
'margin' => $qrDefaults['margin'] ?? 1,
|
||||
'dotsOptions' => $dotsOptions,
|
||||
'backgroundOptions' => ['color' => $bgColor],
|
||||
'cornersSquareOptions' => $cornersSquareOptions,
|
||||
'cornersDotOptions' => $cornersDotOptions,
|
||||
'image' => $logo ?: null,
|
||||
'imageOptions' => [
|
||||
'crossOrigin' => 'anonymous',
|
||||
'hideBackgroundDots' => $logoHideBackground,
|
||||
'imageSize' => $logoSize,
|
||||
'margin' => $logoMargin,
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
|
||||
$escapedQrOptions = e($qrOptionsJson);
|
||||
|
||||
$html = <<<HTML
|
||||
<div data-qr-options="{$escapedQrOptions}" x-data="{
|
||||
shortUrl: '{$shortUrl}',
|
||||
qrTargetUrl: '{$qrTargetUrl}',
|
||||
urlKey: '{$urlKey}',
|
||||
eid: '{$eid}',
|
||||
init() {
|
||||
this.\$nextTick(() => {
|
||||
this.loadScript().then(() => {
|
||||
const canvas = document.getElementById(this.eid + '_qr_canvas');
|
||||
if (canvas && !canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.\$el.getAttribute('data-qr-options'));
|
||||
opts.data = this.qrTargetUrl;
|
||||
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
|
||||
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + this.eid].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + this.eid].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
loadScript() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s2.onload = () => resolve();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{$closeButtonText}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{$qrHelperText}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{$eid}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{$shortUrl}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{$eid}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (Visible immediately) -->
|
||||
<div id="{$eid}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
return [
|
||||
Action::make('qrCode')
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_qr').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">Q</span></div>'))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form(fn (ShortUrl $record): array => [
|
||||
Forms\Components\Placeholder::make('qr_modal_content')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString($html)),
|
||||
];
|
||||
}),
|
||||
->content(view('filament-short-url::table.qr-code-modal', ['record' => $record])),
|
||||
]),
|
||||
|
||||
Action::make('stats')
|
||||
->label(__('filament-short-url::default.action_stats'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
Action::make('share')
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_share').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">I</span></div>'))
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->modalHeading(__('filament-short-url::default.share_title'))
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form([
|
||||
Forms\Components\Placeholder::make('platforms')
|
||||
->label(__('filament-short-url::default.share_description'))
|
||||
->content(fn (ShortUrl $record) => view('filament-short-url::table.share-platforms', ['record' => $record])),
|
||||
|
||||
Forms\Components\Placeholder::make('copy_field')
|
||||
->label('')
|
||||
->content(fn (ShortUrl $record) => view('filament-short-url::table.share-copy-field', ['record' => $record])),
|
||||
]),
|
||||
|
||||
Action::make('stats')
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_stats').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">S</span></div>'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
ActionGroup::make([
|
||||
DeleteAction::make()
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left text-red-600 dark:text-red-400 font-semibold"><span>'.__('filament-short-url::default.action_delete').'</span><span class="text-[10px] bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 rounded px-1.5 py-0.5 ml-auto font-mono font-normal">X</span></div>'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->modalHeading(__('filament-short-url::default.action_delete'))
|
||||
->modalDescription(__('filament-short-url::default.delete_confirmation_desc'))
|
||||
->modalSubmitActionLabel(__('filament-short-url::default.action_delete'))
|
||||
->form(fn (ShortUrl $record): array => [
|
||||
Forms\Components\Placeholder::make('link_preview')
|
||||
->hiddenLabel()
|
||||
->content(function () use ($record) {
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$shortUrlDisplay = str($shortUrl)->after('://')->toString();
|
||||
|
||||
$destHost = null;
|
||||
if ($record->destination_type === 'split') {
|
||||
$variants = $record->rotation_variants ?? [];
|
||||
if (! empty($variants)) {
|
||||
$firstVariant = reset($variants);
|
||||
$destHost = parse_url($firstVariant['url'] ?? '', PHP_URL_HOST);
|
||||
}
|
||||
}
|
||||
if (! $destHost) {
|
||||
$destHost = parse_url($record->destination_url ?? '', PHP_URL_HOST);
|
||||
}
|
||||
$destHostEncoded = e($destHost);
|
||||
|
||||
$destUrl = $record->destination_url;
|
||||
if ($record->destination_type === 'split') {
|
||||
$destUrl = __('filament-short-url::default.destination_type_split');
|
||||
}
|
||||
$destUrlEncoded = e($destUrl);
|
||||
|
||||
return new HtmlString("
|
||||
<div class=\"flex items-center gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/50 shadow-sm\">
|
||||
<div class=\"flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8\">
|
||||
<img src=\"https://icons.duckduckgo.com/ip2/{$destHostEncoded}.ico\"
|
||||
class=\"w-full h-full object-contain\"
|
||||
onerror=\"this.src='https://heroicons.com/24/outline/link.svg'\" />
|
||||
</div>
|
||||
<div class=\"min-w-0 flex-1\">
|
||||
<div class=\"text-sm font-semibold text-neutral-900 dark:text-neutral-100 truncate\">{$shortUrlDisplay}</div>
|
||||
<div class=\"flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-0.5\">
|
||||
<svg class=\"w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500\"
|
||||
style=\"transform: scaleY(-1);\"
|
||||
xmlns=\"http://www.w3.org/2000/svg\"
|
||||
fill=\"none\"
|
||||
viewBox=\"0 0 24 24\"
|
||||
stroke-width=\"2.5\"
|
||||
stroke=\"currentColor\">
|
||||
<path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3\" />
|
||||
</svg>
|
||||
<span class=\"truncate max-w-[50ch] text-[#273144] dark:text-gray-300 text-[13px] leading-[16px] font-medium\">{$destUrlEncoded}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
");
|
||||
}),
|
||||
Forms\Components\TextInput::make('verification')
|
||||
->label(fn () => new HtmlString(__('filament-short-url::default.delete_verification_label', [
|
||||
'short_url' => str($record->getShortUrl())->after('://')->toString(),
|
||||
])))
|
||||
->required()
|
||||
->rules([
|
||||
function () use ($record) {
|
||||
return function (string $attribute, $value, \Closure $fail) use ($record) {
|
||||
$expected = str($record->getShortUrl())->after('://')->toString();
|
||||
if ($value !== $expected) {
|
||||
$fail(__('filament-short-url::default.delete_verification_error'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
])->dropdown(false),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_stats'))
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_edit')),
|
||||
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
->extraAttributes([
|
||||
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
|
||||
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
|
||||
])
|
||||
->label(''),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 1,
|
||||
@@ -622,6 +250,9 @@ HTML;
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
->defaultSort('created_at', 'desc')
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain']))
|
||||
->recordUrl(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record]))
|
||||
->emptyState(view('filament-short-url::table.empty-state'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns;
|
||||
|
||||
trait HasStatsFilters
|
||||
{
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $filters = [];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlDeviceBreakdownWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
public string $activeTab = 'devices';
|
||||
|
||||
public array $loadedTabs = ['devices' => true];
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.device-breakdown';
|
||||
|
||||
public function setActiveTab(string $tab): void
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
$this->loadedTabs[$tab] = true;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'visitsByDevice' => [],
|
||||
'visitsByBrowser' => [],
|
||||
'visitsByOs' => [],
|
||||
'visitsByBrowserVersion' => [],
|
||||
'visitsByOsVersion' => [],
|
||||
'totalVisits' => 0,
|
||||
'browserIcons' => [],
|
||||
'osIcons' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
|
||||
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'visitsByDevice' => isset($this->loadedTabs['devices']) ? ($stats['visitsByDevice'] ?? []) : [],
|
||||
'visitsByBrowser' => isset($this->loadedTabs['browsers']) ? ($stats['visitsByBrowser'] ?? []) : [],
|
||||
'visitsByOs' => isset($this->loadedTabs['os']) ? ($stats['visitsByOs'] ?? []) : [],
|
||||
'visitsByBrowserVersion' => isset($this->loadedTabs['browsers']) ? ($stats['visitsByBrowserVersion'] ?? []) : [],
|
||||
'visitsByOsVersion' => isset($this->loadedTabs['os']) ? ($stats['visitsByOsVersion'] ?? []) : [],
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
'browserIcons' => [
|
||||
'Chrome' => 'heroicon-m-globe-alt',
|
||||
'Firefox' => 'heroicon-m-globe-alt',
|
||||
'Safari' => 'heroicon-m-globe-alt',
|
||||
'Edge' => 'heroicon-m-globe-alt',
|
||||
'Opera' => 'heroicon-m-globe-alt',
|
||||
'Internet Explorer' => 'heroicon-m-globe-alt',
|
||||
],
|
||||
'osIcons' => [
|
||||
'Windows' => 'heroicon-m-cpu-chip',
|
||||
'OS X' => 'heroicon-m-cpu-chip',
|
||||
'Android' => 'heroicon-m-cpu-chip',
|
||||
'iOS' => 'heroicon-m-cpu-chip',
|
||||
'Ubuntu' => 'heroicon-m-cpu-chip',
|
||||
'Linux' => 'heroicon-m-cpu-chip',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlGeoBreakdownWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
public string $activeTab = 'countries';
|
||||
|
||||
public array $loadedTabs = ['countries' => true];
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.geo-breakdown';
|
||||
|
||||
public function setActiveTab(string $tab): void
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
$this->loadedTabs[$tab] = true;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'visitsByCountry' => [],
|
||||
'visitsByCity' => [],
|
||||
'visitsByLanguage' => [],
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
|
||||
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'visitsByCountry' => isset($this->loadedTabs['countries']) ? ($stats['visitsByCountry'] ?? []) : [],
|
||||
'visitsByCity' => isset($this->loadedTabs['cities']) ? ($stats['visitsByCity'] ?? []) : [],
|
||||
'visitsByLanguage' => isset($this->loadedTabs['languages']) ? ($stats['visitsByLanguage'] ?? []) : [],
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,14 @@ class ShortUrlGlobalOverview extends BaseWidget
|
||||
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits']))
|
||||
->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->chart($clickData['clicksChart'] ?? [])
|
||||
->icon('heroicon-o-cursor-arrow-rays')
|
||||
->color($clicksTrend >= 0 ? 'success' : 'danger'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits']))
|
||||
->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->chart($clickData['uniquesChart'] ?? [])
|
||||
->icon('heroicon-o-user-group')
|
||||
->color($uniquesTrend >= 0 ? 'success' : 'danger'),
|
||||
];
|
||||
@@ -112,6 +114,34 @@ class ShortUrlGlobalOverview extends BaseWidget
|
||||
->distinct('ip_hash')
|
||||
->count('ip_hash');
|
||||
|
||||
// Fetch last 7 days chart data via database-level aggregation to avoid loading raw models into memory
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$dateExpression = match ($driver) {
|
||||
'sqlite' => "strftime('%Y-%m-%d', visited_at)",
|
||||
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')",
|
||||
default => "DATE_FORMAT(visited_at, '%Y-%m-%d')",
|
||||
};
|
||||
|
||||
$dailyClicks = ShortUrlVisit::select(DB::raw("{$dateExpression} as date"), DB::raw('COUNT(*) as count'))
|
||||
->where('visited_at', '>=', now()->subDays(6)->startOfDay())
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
|
||||
$dailyUniques = ShortUrlVisit::select(DB::raw("{$dateExpression} as date"), DB::raw('COUNT(DISTINCT ip_hash) as count'))
|
||||
->where('visited_at', '>=', now()->subDays(6)->startOfDay())
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
|
||||
$clicksChart = [];
|
||||
$uniquesChart = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$targetDate = now()->subDays($i)->toDateString();
|
||||
$clicksChart[] = (int) ($dailyClicks[$targetDate] ?? 0);
|
||||
$uniquesChart[] = (int) ($dailyUniques[$targetDate] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'totalVisits' => (int) ($agg->total_visits ?? 0),
|
||||
'uniqueVisits' => (int) ($agg->unique_visits ?? 0),
|
||||
@@ -119,6 +149,8 @@ class ShortUrlGlobalOverview extends BaseWidget
|
||||
'prev7Clicks' => $prev7Clicks,
|
||||
'last7Uniques' => $last7Uniques,
|
||||
'prev7Uniques' => $prev7Uniques,
|
||||
'clicksChart' => $clicksChart,
|
||||
'uniquesChart' => $uniquesChart,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlSecurityBreakdownWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
public string $activeTab = 'bots';
|
||||
|
||||
public array $loadedTabs = ['bots' => true];
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.security-breakdown';
|
||||
|
||||
public function setActiveTab(string $tab): void
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
$this->loadedTabs[$tab] = true;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'totalClicks' => 0,
|
||||
'humanClicks' => 0,
|
||||
'botClicks' => 0,
|
||||
'humanPercentage' => 0,
|
||||
'botPercentage' => 0,
|
||||
'proxyClicks' => 0,
|
||||
'proxyPercentage' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$totalClicks = 0;
|
||||
$humanClicks = 0;
|
||||
$botClicks = 0;
|
||||
$humanPercentage = 0;
|
||||
$botPercentage = 0;
|
||||
$proxyClicks = 0;
|
||||
$proxyPercentage = 0;
|
||||
|
||||
// Perform total count query only if we load bots or vpn
|
||||
if (! empty($this->loadedTabs)) {
|
||||
$totalQuery = $this->record->visits()
|
||||
->whereBetween('visited_at', [
|
||||
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
|
||||
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
|
||||
]);
|
||||
$this->record->applyStatsFilters($totalQuery, $this->filters);
|
||||
$totalClicks = $totalQuery->count();
|
||||
}
|
||||
|
||||
// Calculate Bot ratio
|
||||
if (isset($this->loadedTabs['bots'])) {
|
||||
$botQuery = $this->record->visits()
|
||||
->where('is_bot', true)
|
||||
->whereBetween('visited_at', [
|
||||
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
|
||||
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
|
||||
]);
|
||||
$this->record->applyStatsFilters($botQuery, $this->filters);
|
||||
$botClicks = $botQuery->count();
|
||||
|
||||
$humanClicks = max(0, $totalClicks - $botClicks);
|
||||
$humanPercentage = $totalClicks > 0 ? round(($humanClicks / $totalClicks) * 100, 1) : 0;
|
||||
$botPercentage = $totalClicks > 0 ? round(($botClicks / $totalClicks) * 100, 1) : 0;
|
||||
}
|
||||
|
||||
// Calculate VPN / Proxy ratio
|
||||
if (isset($this->loadedTabs['vpn'])) {
|
||||
$proxyQuery = $this->record->visits()
|
||||
->where('is_proxy', true)
|
||||
->whereBetween('visited_at', [
|
||||
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
|
||||
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
|
||||
]);
|
||||
$this->record->applyStatsFilters($proxyQuery, $this->filters);
|
||||
$proxyClicks = $proxyQuery->count();
|
||||
$proxyPercentage = $totalClicks > 0 ? round(($proxyClicks / $totalClicks) * 100, 1) : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'totalClicks' => $totalClicks,
|
||||
'humanClicks' => $humanClicks,
|
||||
'botClicks' => $botClicks,
|
||||
'humanPercentage' => $humanPercentage,
|
||||
'botPercentage' => $botPercentage,
|
||||
'proxyClicks' => $proxyClicks,
|
||||
'proxyPercentage' => $proxyPercentage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlSourcesBreakdownWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
public string $activeTab = 'referrers';
|
||||
|
||||
public string $activeSubTab = 'sources';
|
||||
|
||||
public array $loadedTabs = ['referrers' => true];
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.sources-breakdown';
|
||||
|
||||
public function setActiveTab(string $tab): void
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
if ($tab === 'referrers') {
|
||||
$this->loadedTabs['referrers'] = true;
|
||||
} elseif ($tab === 'utm') {
|
||||
$this->loadedTabs['utm_'.$this->activeSubTab] = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function setActiveSubTab(string $subTab): void
|
||||
{
|
||||
$this->activeSubTab = $subTab;
|
||||
$this->loadedTabs['utm_'.$subTab] = true;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'activeSubTab' => $this->activeSubTab,
|
||||
'visitsByReferer' => [],
|
||||
'utmSources' => [],
|
||||
'utmMediums' => [],
|
||||
'utmCampaigns' => [],
|
||||
'utmTerms' => [],
|
||||
'utmContents' => [],
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
|
||||
|
||||
return [
|
||||
'activeTab' => $this->activeTab,
|
||||
'activeSubTab' => $this->activeSubTab,
|
||||
'visitsByReferer' => isset($this->loadedTabs['referrers']) ? ($stats['visitsByReferer'] ?? []) : [],
|
||||
'utmSources' => isset($this->loadedTabs['utm_sources']) ? ($stats['utmSources'] ?? []) : [],
|
||||
'utmMediums' => isset($this->loadedTabs['utm_mediums']) ? ($stats['utmMediums'] ?? []) : [],
|
||||
'utmCampaigns' => isset($this->loadedTabs['utm_campaigns']) ? ($stats['utmCampaigns'] ?? []) : [],
|
||||
'utmTerms' => isset($this->loadedTabs['utm_terms']) ? ($stats['utmTerms'] ?? []) : [],
|
||||
'utmContents' => isset($this->loadedTabs['utm_contents']) ? ($stats['utmContents'] ?? []) : [],
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ShortUrlStatsOverview extends BaseWidget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
@@ -20,7 +24,25 @@ class ShortUrlStatsOverview extends BaseWidget
|
||||
return [];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
|
||||
$totalVisits = (int) ($stats['totalVisits'] ?? 0);
|
||||
|
||||
// Calculate previous period for PoP comparison
|
||||
$prevStats = null;
|
||||
if ($this->dateFrom && $this->dateTo) {
|
||||
try {
|
||||
$from = Carbon::parse($this->dateFrom);
|
||||
$to = Carbon::parse($this->dateTo);
|
||||
$diffInDays = $from->diffInDays($to) + 1;
|
||||
|
||||
$prevFrom = $from->copy()->subDays($diffInDays)->toDateString();
|
||||
$prevTo = $from->copy()->subDay()->toDateString();
|
||||
|
||||
$prevStats = $this->record->getCachedStats($prevFrom, $prevTo, $this->filters);
|
||||
} catch (\Throwable $e) {
|
||||
// Fail gracefully if date parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
$topSource = 'Direct';
|
||||
if (! empty($stats['utmSources'])) {
|
||||
@@ -32,18 +54,34 @@ class ShortUrlStatsOverview extends BaseWidget
|
||||
$topCountry = array_key_first($stats['visitsByCountry']);
|
||||
}
|
||||
|
||||
$trend = ! empty($stats['visitsByDay']) ? array_values($stats['visitsByDay']) : [];
|
||||
|
||||
return [
|
||||
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
|
||||
->icon('heroicon-o-cursor-arrow-rays')
|
||||
->color('primary'),
|
||||
$this->buildStatWithTrend(
|
||||
__('filament-short-url::default.stats_card_total'),
|
||||
(int) ($stats['totalVisits'] ?? 0),
|
||||
$prevStats ? (int) ($prevStats['totalVisits'] ?? 0) : null,
|
||||
'heroicon-o-cursor-arrow-rays',
|
||||
'primary',
|
||||
$trend
|
||||
),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($stats['uniqueVisits'] ?? 0))
|
||||
->icon('heroicon-o-user-group')
|
||||
->color('info'),
|
||||
$this->buildStatWithTrend(
|
||||
__('filament-short-url::default.stats_card_unique'),
|
||||
(int) ($stats['uniqueVisits'] ?? 0),
|
||||
$prevStats ? (int) ($prevStats['uniqueVisits'] ?? 0) : null,
|
||||
'heroicon-o-user-group',
|
||||
'info',
|
||||
$trend
|
||||
),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_today_clicks'), number_format($stats['visitsToday'] ?? 0))
|
||||
->icon('heroicon-o-clock')
|
||||
->color('success'),
|
||||
$this->buildStatWithTrend(
|
||||
__('filament-short-url::default.stats_card_today_clicks'),
|
||||
(int) ($stats['visitsToday'] ?? 0),
|
||||
$prevStats ? (int) ($prevStats['visitsToday'] ?? 0) : null,
|
||||
'heroicon-o-clock',
|
||||
'success'
|
||||
),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
|
||||
->icon('heroicon-o-megaphone')
|
||||
@@ -53,9 +91,92 @@ class ShortUrlStatsOverview extends BaseWidget
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->color('success'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_qr_scans'), number_format($stats['qrScans'] ?? 0))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->color('info'),
|
||||
$this->buildStatWithTrend(
|
||||
__('filament-short-url::default.stats_card_qr_scans'),
|
||||
(int) ($stats['qrScans'] ?? 0),
|
||||
$prevStats ? (int) ($prevStats['qrScans'] ?? 0) : null,
|
||||
'heroicon-o-qr-code',
|
||||
'info'
|
||||
),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_qr_rate'), number_format($totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0, 1).'%')
|
||||
->icon('heroicon-o-arrow-trending-up')
|
||||
->color('indigo')
|
||||
->description((function () use ($stats, $totalVisits, $prevStats) {
|
||||
$currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0;
|
||||
$prevRate = null;
|
||||
if ($prevStats) {
|
||||
$prevTotal = (int) ($prevStats['totalVisits'] ?? 0);
|
||||
$prevQr = (int) ($prevStats['qrScans'] ?? 0);
|
||||
$prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0;
|
||||
}
|
||||
if ($prevRate !== null) {
|
||||
$delta = $currentRate - $prevRate;
|
||||
|
||||
return ($delta >= 0 ? '+' : '').number_format($delta, 1).'% vs '.__('filament-short-url::default.stats_prev_period');
|
||||
}
|
||||
|
||||
return '— vs '.__('filament-short-url::default.stats_prev_period');
|
||||
})())
|
||||
->descriptionIcon((function () use ($stats, $totalVisits, $prevStats) {
|
||||
$currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0;
|
||||
$prevRate = null;
|
||||
if ($prevStats) {
|
||||
$prevTotal = (int) ($prevStats['totalVisits'] ?? 0);
|
||||
$prevQr = (int) ($prevStats['qrScans'] ?? 0);
|
||||
$prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0;
|
||||
}
|
||||
if ($prevRate !== null) {
|
||||
return ($currentRate - $prevRate) >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down';
|
||||
}
|
||||
|
||||
return null;
|
||||
})())
|
||||
->descriptionColor((function () use ($stats, $totalVisits, $prevStats) {
|
||||
$currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0;
|
||||
$prevRate = null;
|
||||
if ($prevStats) {
|
||||
$prevTotal = (int) ($prevStats['totalVisits'] ?? 0);
|
||||
$prevQr = (int) ($prevStats['qrScans'] ?? 0);
|
||||
$prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0;
|
||||
}
|
||||
if ($prevRate !== null) {
|
||||
return ($currentRate - $prevRate) >= 0 ? 'success' : 'danger';
|
||||
}
|
||||
|
||||
return 'gray';
|
||||
})()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stat widget with a calculated percentage trend delta and sparkline chart.
|
||||
*/
|
||||
protected function buildStatWithTrend(string $label, int $current, ?int $previous, string $icon, string $color, array $trend = []): Stat
|
||||
{
|
||||
$stat = Stat::make($label, number_format($current))
|
||||
->icon($icon)
|
||||
->color($color);
|
||||
|
||||
if (! empty($trend)) {
|
||||
$stat->chart($trend);
|
||||
}
|
||||
|
||||
if ($previous !== null && $previous > 0) {
|
||||
$delta = (($current - $previous) / $previous) * 100;
|
||||
$deltaStr = ($delta >= 0 ? '+' : '').number_format($delta, 1).'%';
|
||||
$stat->description($deltaStr.' vs '.__('filament-short-url::default.stats_prev_period'))
|
||||
->descriptionIcon($delta >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->descriptionColor($delta >= 0 ? 'success' : 'danger');
|
||||
} elseif ($previous === 0 && $current > 0) {
|
||||
$stat->description('+100.0% vs '.__('filament-short-url::default.stats_prev_period'))
|
||||
->descriptionIcon('heroicon-m-arrow-trending-up')
|
||||
->descriptionColor('success');
|
||||
} else {
|
||||
$stat->description('— vs '.__('filament-short-url::default.stats_prev_period'))
|
||||
->descriptionColor('gray');
|
||||
}
|
||||
|
||||
return $stat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class ShortUrlVariantsWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.variants';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'visitsByVariant' => [],
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
|
||||
|
||||
return [
|
||||
'visitsByVariant' => $stats['visitsByVariant'] ?? [],
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
'rotationVariants' => $this->record->rotation_variants ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
if (! $this->record) {
|
||||
return view('filament-short-url::widgets.empty');
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
|
||||
if (empty($stats['visitsByVariant'])) {
|
||||
return view('filament-short-url::widgets.empty');
|
||||
}
|
||||
|
||||
return parent::render();
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlVisitsBottomBreakdown extends Widget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function mount(?ShortUrl $record = null): void
|
||||
{
|
||||
$this->record = $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'visitsByDevice' => [],
|
||||
'visitsByBrowser' => [],
|
||||
'visitsByOs' => [],
|
||||
'visitsByReferer' => [],
|
||||
'visitsByCity' => [],
|
||||
'utmSources' => [],
|
||||
'utmMediums' => [],
|
||||
'utmCampaigns' => [],
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
|
||||
return [
|
||||
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
|
||||
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
|
||||
'visitsByOs' => $stats['visitsByOs'] ?? [],
|
||||
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
|
||||
'visitsByCity' => $stats['visitsByCity'] ?? [],
|
||||
'utmSources' => $stats['utmSources'] ?? [],
|
||||
'utmMediums' => $stats['utmMediums'] ?? [],
|
||||
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlVisitsChart extends ChartWidget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
@@ -18,11 +22,227 @@ class ShortUrlVisitsChart extends ChartWidget
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public ?string $filter = null;
|
||||
|
||||
public string $activeMetric = 'total';
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.visits-chart';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->setDefaultFilter();
|
||||
}
|
||||
|
||||
protected function setDefaultFilter(): void
|
||||
{
|
||||
$start = $this->dateFrom ? Carbon::parse($this->dateFrom) : now()->subDays(29);
|
||||
$end = $this->dateTo ? Carbon::parse($this->dateTo) : now();
|
||||
$daysDiff = (int) $start->diffInDays($end);
|
||||
|
||||
if ($daysDiff <= 1) {
|
||||
$this->filter = 'hourly';
|
||||
} elseif ($daysDiff <= 30) {
|
||||
$this->filter = 'daily';
|
||||
} elseif ($daysDiff <= 90) {
|
||||
$this->filter = 'weekly';
|
||||
} else {
|
||||
$this->filter = 'monthly';
|
||||
}
|
||||
}
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
return __('filament-short-url::default.stats_chart_title');
|
||||
}
|
||||
|
||||
protected function getFilters(): ?array
|
||||
{
|
||||
return [
|
||||
'hourly' => __('filament-short-url::default.stats_granularity_hourly'),
|
||||
'daily' => __('filament-short-url::default.stats_granularity_daily'),
|
||||
'weekly' => __('filament-short-url::default.stats_granularity_weekly'),
|
||||
'monthly' => __('filament-short-url::default.stats_granularity_monthly'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTimelineData(Carbon $start, Carbon $end, string $metric, string $granularity, array $filters): array
|
||||
{
|
||||
$dateFromClean = $start->toDateString();
|
||||
$dateToClean = $end->toDateString();
|
||||
$today = Carbon::today()->toDateString();
|
||||
$driver = DB::connection()->getDriverName();
|
||||
|
||||
$buckets = [];
|
||||
$labels = [];
|
||||
|
||||
if ($granularity === 'hourly') {
|
||||
$hoursDiff = (int) $start->diffInHours($end);
|
||||
if ($hoursDiff > 168) {
|
||||
$granularity = 'daily';
|
||||
} else {
|
||||
$current = $start->copy()->startOfHour();
|
||||
$endHour = $end->copy()->endOfHour();
|
||||
while ($current->lte($endHour)) {
|
||||
$bucketKey = $current->format('Y-m-d H:00');
|
||||
$buckets[$bucketKey] = 0;
|
||||
$labels[$bucketKey] = $current->format('d.m H:i');
|
||||
$current->addHour();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($granularity === 'weekly') {
|
||||
$current = $start->copy()->startOfWeek();
|
||||
while ($current->lte($end)) {
|
||||
$bucketKey = $current->format('o-W');
|
||||
$buckets[$bucketKey] = 0;
|
||||
$labels[$bucketKey] = 'W'.$current->format('W').' ('.$current->format('d.m').')';
|
||||
$current->addWeek();
|
||||
}
|
||||
} elseif ($granularity === 'monthly') {
|
||||
$current = $start->copy()->startOfMonth();
|
||||
while ($current->lte($end)) {
|
||||
$bucketKey = $current->format('Y-m');
|
||||
$buckets[$bucketKey] = 0;
|
||||
$labels[$bucketKey] = $current->format('m.Y');
|
||||
$current->addMonth();
|
||||
}
|
||||
} elseif ($granularity === 'daily') {
|
||||
$daysDiff = (int) $start->diffInDays($end);
|
||||
for ($i = $daysDiff; $i >= 0; $i--) {
|
||||
$d = $end->copy()->subDays($i)->format('Y-m-d');
|
||||
$buckets[$d] = 0;
|
||||
$labels[$d] = Carbon::parse($d)->format('d.m');
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($filters) && $granularity !== 'hourly') {
|
||||
$dailyStatsQuery = DB::table('short_url_daily_stats')
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('date', '>=', $dateFromClean)
|
||||
->where('date', '<=', $dateToClean)
|
||||
->orderBy('date', 'asc');
|
||||
|
||||
$col = match ($metric) {
|
||||
'unique' => 'unique_visits_count',
|
||||
'qr' => 'qr_visits_count',
|
||||
default => 'visits_count',
|
||||
};
|
||||
|
||||
$dailyRows = $dailyStatsQuery->pluck($col, 'date')->toArray();
|
||||
|
||||
foreach ($dailyRows as $date => $count) {
|
||||
$carbonDate = Carbon::parse($date);
|
||||
if ($granularity === 'weekly') {
|
||||
$bucketKey = $carbonDate->format('o-W');
|
||||
} elseif ($granularity === 'monthly') {
|
||||
$bucketKey = $carbonDate->format('Y-m');
|
||||
} else {
|
||||
$bucketKey = $date;
|
||||
}
|
||||
|
||||
if (array_key_exists($bucketKey, $buckets)) {
|
||||
$buckets[$bucketKey] += (int) $count;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dateToClean >= $today) {
|
||||
$todayRawQuery = DB::table('short_url_visits')
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('visited_at', '>=', $today.' 00:00:00')
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false);
|
||||
|
||||
$aggregateExpression = match ($metric) {
|
||||
'unique' => 'COUNT(DISTINCT ip_hash)',
|
||||
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
||||
default => 'COUNT(*)',
|
||||
};
|
||||
|
||||
$todayCount = (int) $todayRawQuery->selectRaw("{$aggregateExpression} as cnt")->value('cnt');
|
||||
|
||||
$carbonToday = Carbon::parse($today);
|
||||
if ($granularity === 'weekly') {
|
||||
$todayBucket = $carbonToday->format('o-W');
|
||||
} elseif ($granularity === 'monthly') {
|
||||
$todayBucket = $carbonToday->format('Y-m');
|
||||
} else {
|
||||
$todayBucket = $today;
|
||||
}
|
||||
|
||||
if (array_key_exists($todayBucket, $buckets)) {
|
||||
$buckets[$todayBucket] += $todayCount;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$query = DB::table('short_url_visits')
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('visited_at', '>=', $dateFromClean.' 00:00:00')
|
||||
->where('visited_at', '<=', $dateToClean.' 23:59:59')
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false);
|
||||
|
||||
$this->record->applyStatsFilters($query, $filters);
|
||||
|
||||
$dateExpression = '';
|
||||
if ($granularity === 'hourly') {
|
||||
$dateExpression = match ($driver) {
|
||||
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
|
||||
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
|
||||
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
|
||||
};
|
||||
} elseif ($granularity === 'weekly') {
|
||||
$dateExpression = match ($driver) {
|
||||
'sqlite' => "strftime('%Y-%W', visited_at)",
|
||||
'pgsql' => "to_char(visited_at, 'IYYY-IW')",
|
||||
default => 'YEARWEEK(visited_at, 3)',
|
||||
};
|
||||
} elseif ($granularity === 'monthly') {
|
||||
$dateExpression = match ($driver) {
|
||||
'sqlite' => "strftime('%Y-%m', visited_at)",
|
||||
'pgsql' => "to_char(visited_at, 'YYYY-MM')",
|
||||
default => "DATE_FORMAT(visited_at, '%Y-%m')",
|
||||
};
|
||||
} else {
|
||||
$dateExpression = match ($driver) {
|
||||
'sqlite' => "strftime('%Y-%m-%d', visited_at)",
|
||||
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')",
|
||||
default => "DATE_FORMAT(visited_at, '%Y-%m-%d')",
|
||||
};
|
||||
}
|
||||
|
||||
$aggregateExpression = match ($metric) {
|
||||
'unique' => 'COUNT(DISTINCT ip_hash)',
|
||||
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
||||
default => 'COUNT(*)',
|
||||
};
|
||||
|
||||
$rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count"))
|
||||
->groupBy('time_bucket')
|
||||
->pluck('count', 'time_bucket')
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $bucket => $count) {
|
||||
if ($granularity === 'weekly' && $driver !== 'sqlite' && $driver !== 'pgsql') {
|
||||
if (strlen($bucket) === 6) {
|
||||
$year = substr($bucket, 0, 4);
|
||||
$week = substr($bucket, 4, 2);
|
||||
$bucket = "{$year}-{$week}";
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists($bucket, $buckets)) {
|
||||
$buckets[$bucket] = (int) $count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => array_values($buckets),
|
||||
'labels' => array_values($labels),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
@@ -32,14 +252,56 @@ class ShortUrlVisitsChart extends ChartWidget
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
$visitsByDay = $stats['visitsByDay'] ?? [];
|
||||
$start = $this->dateFrom ? Carbon::parse($this->dateFrom)->startOfDay() : now()->subDays(29)->startOfDay();
|
||||
$end = $this->dateTo ? Carbon::parse($this->dateTo)->endOfDay() : now()->endOfDay();
|
||||
$granularity = $this->filter ?: 'daily';
|
||||
$filtersHash = md5(json_encode($this->filters));
|
||||
|
||||
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
$cacheKey = "short_url_chart_data_{$this->record->id}_".$start->toDateString().'_'.$end->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
|
||||
$this->record->registerCacheKey($cacheKey);
|
||||
|
||||
$chartData = cache()->remember($cacheKey, $cacheTtl, function () use ($start, $end, $granularity) {
|
||||
return $this->getTimelineData($start, $end, $this->activeMetric, $granularity, $this->filters);
|
||||
});
|
||||
|
||||
$currentValues = $chartData['data'] ?? [];
|
||||
$labels = $chartData['labels'] ?? [];
|
||||
|
||||
// Dual-line Chart data generation: Query stats for previous period
|
||||
$diffInDays = $start->diffInDays($end) + 1;
|
||||
$prevStart = $start->copy()->subDays($diffInDays);
|
||||
$prevEnd = $start->copy()->subDay();
|
||||
|
||||
$prevCacheKey = "short_url_chart_data_{$this->record->id}_".$prevStart->toDateString().'_'.$prevEnd->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
|
||||
$this->record->registerCacheKey($prevCacheKey);
|
||||
|
||||
$prevChartData = cache()->remember($prevCacheKey, $cacheTtl, function () use ($prevStart, $prevEnd, $granularity) {
|
||||
return $this->getTimelineData($prevStart, $prevEnd, $this->activeMetric, $granularity, $this->filters);
|
||||
});
|
||||
|
||||
$prevValues = $prevChartData['data'] ?? [];
|
||||
|
||||
// Align/pad previous data structure to match current labels length exactly
|
||||
$countCurrent = count($currentValues);
|
||||
$countPrev = count($prevValues);
|
||||
if ($countPrev < $countCurrent) {
|
||||
$prevValues = array_pad($prevValues, $countCurrent, 0);
|
||||
} elseif ($countPrev > $countCurrent) {
|
||||
$prevValues = array_slice($prevValues, 0, $countCurrent);
|
||||
}
|
||||
|
||||
$metricLabel = match ($this->activeMetric) {
|
||||
'unique' => __('filament-short-url::default.stats_card_unique'),
|
||||
'qr' => __('filament-short-url::default.stats_card_qr_scans'),
|
||||
default => __('filament-short-url::default.qr_chart_visits_label'),
|
||||
};
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('filament-short-url::default.qr_chart_visits_label'),
|
||||
'data' => array_values($visitsByDay),
|
||||
'label' => $metricLabel,
|
||||
'data' => $currentValues,
|
||||
'borderColor' => 'rgb(99, 102, 241)',
|
||||
'backgroundColor' => 'rgba(99, 102, 241, 0.08)',
|
||||
'borderWidth' => 2,
|
||||
@@ -48,19 +310,19 @@ class ShortUrlVisitsChart extends ChartWidget
|
||||
'tension' => 0.4,
|
||||
'fill' => true,
|
||||
],
|
||||
[
|
||||
'label' => $metricLabel.' ('.__('filament-short-url::default.stats_prev_period').')',
|
||||
'data' => $prevValues,
|
||||
'borderColor' => 'rgba(99, 102, 241, 0.35)',
|
||||
'backgroundColor' => 'transparent',
|
||||
'borderWidth' => 2,
|
||||
'borderDash' => [5, 5],
|
||||
'pointRadius' => 0,
|
||||
'tension' => 0.4,
|
||||
'fill' => false,
|
||||
],
|
||||
],
|
||||
'labels' => array_map(function (string $date) {
|
||||
try {
|
||||
$carbon = Carbon::parse($date);
|
||||
if (strlen($date) === 7) { // Y-m
|
||||
return $carbon->format('m.Y');
|
||||
}
|
||||
|
||||
return $carbon->format('d.m');
|
||||
} catch (\Throwable $e) {
|
||||
return $date;
|
||||
}
|
||||
}, array_keys($visitsByDay)),
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlVisitsRightBreakdown extends Widget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function mount(?ShortUrl $record = null): void
|
||||
{
|
||||
$this->record = $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$languageNames = __('filament-short-url::languages');
|
||||
if (! is_array($languageNames)) {
|
||||
$languageNames = [];
|
||||
}
|
||||
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'visitsByCountry' => [],
|
||||
'visitsByLanguage' => [],
|
||||
'languageNames' => $languageNames,
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
|
||||
return [
|
||||
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
|
||||
'visitsByLanguage' => $stats['visitsByLanguage'] ?? [],
|
||||
'languageNames' => $languageNames,
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically translate an ISO language code to the current application locale.
|
||||
*/
|
||||
public static function getLanguageTranslation(string $langCode): string
|
||||
{
|
||||
$langCode = strtolower(trim($langCode));
|
||||
|
||||
// 1. Try loading from languages translation file (languages.php)
|
||||
$translatedLanguages = __('filament-short-url::languages');
|
||||
if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) {
|
||||
return $translatedLanguages[$langCode];
|
||||
}
|
||||
|
||||
// 2. Try PHP Locale extension
|
||||
if (class_exists(\Locale::class)) {
|
||||
try {
|
||||
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
|
||||
if ($name && $name !== $langCode) {
|
||||
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simple fallback
|
||||
return strtoupper($langCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically translate an English country name to the current application locale.
|
||||
*/
|
||||
public static function getCountryTranslation(string $englishName): string
|
||||
{
|
||||
$englishName = trim($englishName);
|
||||
|
||||
try {
|
||||
$enCountries = trans('filament-short-url::countries', [], 'en');
|
||||
if (is_array($enCountries)) {
|
||||
$flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER);
|
||||
$lookupKey = strtolower($englishName);
|
||||
$code = $flipped[$lookupKey] ?? null;
|
||||
|
||||
if ($code) {
|
||||
$translated = __('filament-short-url::countries.'.strtoupper($code));
|
||||
if ($translated && $translated !== 'filament-short-url::countries.'.strtoupper($code)) {
|
||||
return $translated;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return $englishName;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\Widget;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlWorldMapWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
@@ -40,8 +43,9 @@ class ShortUrlWorldMapWidget extends Widget
|
||||
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
|
||||
$today = Carbon::today()->toDateString();
|
||||
|
||||
$filtersHash = md5(json_encode($this->filters));
|
||||
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
|
||||
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash;
|
||||
|
||||
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
|
||||
$counts = [];
|
||||
@@ -61,6 +65,8 @@ class ShortUrlWorldMapWidget extends Widget
|
||||
$query->whereDate('visited_at', '<=', $dateToClean);
|
||||
}
|
||||
|
||||
$this->record->applyStatsFilters($query, $this->filters);
|
||||
|
||||
foreach ($query->groupBy('country_code')->get() as $row) {
|
||||
$code = strtoupper(trim($row->country_code));
|
||||
if ($code) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
@@ -53,6 +54,7 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
->resources([
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
ShortUrlCustomDomainResource::class,
|
||||
])
|
||||
->pages([
|
||||
ShortUrlSettingsPage::class,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Assets\ShortUrlCss;
|
||||
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
|
||||
@@ -16,7 +17,6 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Filament\Support\Assets\Css;
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
@@ -34,20 +34,14 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
->hasViews('filament-short-url')
|
||||
->hasTranslations()
|
||||
->hasMigrations([
|
||||
'2024_01_01_000001_create_short_urls_table',
|
||||
'2024_01_01_000002_create_short_url_visits_table',
|
||||
'2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table',
|
||||
'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',
|
||||
'2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table',
|
||||
'2026_06_02_224250_add_qr_logo_to_short_urls_table',
|
||||
'2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats',
|
||||
'2026_06_03_120000_add_track_browser_language_to_short_urls_table',
|
||||
'2026_06_03_150000_create_short_url_pixels_table',
|
||||
'2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table',
|
||||
'2026_06_04_000000_create_short_url_settings_table',
|
||||
'2024_01_01_000001_create_short_url_settings_table',
|
||||
'2024_01_01_000002_create_short_url_pixels_table',
|
||||
'2024_01_01_000003_create_short_urls_table',
|
||||
'2024_01_01_000004_create_short_url_visits_table',
|
||||
'2024_01_01_000005_create_short_url_daily_stats_table',
|
||||
'2026_06_05_000001_add_user_id_to_short_urls_table',
|
||||
'2026_06_05_000002_create_short_url_custom_domains_table',
|
||||
'2026_06_05_000003_add_custom_domain_id_to_short_urls_table',
|
||||
])
|
||||
->hasCommands([
|
||||
SyncBufferedCountersCommand::class,
|
||||
@@ -59,7 +53,6 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
public function packageRegistered(): void
|
||||
{
|
||||
$this->app->singleton(ShortUrlSettingsManager::class);
|
||||
$this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides();
|
||||
|
||||
// Bind services as singletons for efficient reuse
|
||||
$this->app->singleton(UserAgentParser::class);
|
||||
@@ -72,8 +65,10 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
|
||||
public function packageBooted(): void
|
||||
{
|
||||
$this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides();
|
||||
|
||||
FilamentAsset::register([
|
||||
Css::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
|
||||
ShortUrlCss::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
|
||||
], package: 'janczakb/filament-short-url');
|
||||
|
||||
// Automatically register scheduled tasks in the application scheduler
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest;
|
||||
use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest;
|
||||
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ShortUrlApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShortUrlService $service
|
||||
private readonly ShortUrlService $service,
|
||||
private readonly SafeBrowsingService $safeBrowsing,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -26,47 +32,17 @@ class ShortUrlApiController extends Controller
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$links = ShortUrl::orderBy('id', 'desc')->paginate(30);
|
||||
$links = ShortUrl::with(['pixels', 'customDomain'])->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(),
|
||||
],
|
||||
]);
|
||||
return ShortUrlResource::collection($links)->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created short URL.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
public function store(StoreShortUrlRequest $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',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
]);
|
||||
$validated = $request->validated();
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
@@ -82,18 +58,50 @@ class ShortUrlApiController extends Controller
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
return (new ShortUrlResource($shortUrl))
|
||||
->additional(['message' => 'Short URL created successfully.'])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified short URL.
|
||||
*/
|
||||
public function show(string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
return (new ShortUrlResource($shortUrl))->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display statistics/analytics for the specified short URL.
|
||||
*/
|
||||
public function stats(Request $request, string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'date_from' => 'nullable|date_format:Y-m-d',
|
||||
'date_to' => 'nullable|date_format:Y-m-d|after_or_equal:date_from',
|
||||
]);
|
||||
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
$stats = $shortUrl->getCachedStats(
|
||||
dateFrom: $request->query('date_from'),
|
||||
dateTo: $request->query('date_to')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Short URL created successfully.',
|
||||
'data' => $this->transformLink($shortUrl),
|
||||
], 201);
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified short URL.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
public function destroy(string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = ShortUrl::findOrFail($id);
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
$shortUrl->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -102,40 +110,44 @@ class ShortUrlApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a ShortUrl model to API response array.
|
||||
* Update the specified short URL.
|
||||
*/
|
||||
private function transformLink(ShortUrl $link): array
|
||||
public function update(UpdateShortUrlRequest $request, string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get();
|
||||
$shortUrl = $request->getModel();
|
||||
|
||||
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,
|
||||
'webhook_url' => $link->webhook_url,
|
||||
'targeting_rules' => $link->targeting_rules,
|
||||
'password' => $link->password,
|
||||
'show_warning_page' => (bool) $link->show_warning_page,
|
||||
'track_visits' => (bool) $link->track_visits,
|
||||
'track_browser_language' => (bool) $link->track_browser_language,
|
||||
'pixels' => $pixels->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'type' => $p->type,
|
||||
'pixel_id' => $p->pixel_id,
|
||||
'is_active' => (bool) $p->is_active,
|
||||
])->toArray(),
|
||||
'notes' => $link->notes,
|
||||
'created_at' => $link->created_at->toIso8601String(),
|
||||
];
|
||||
$validated = $request->validated();
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? null;
|
||||
unset($validated['pixels']);
|
||||
|
||||
$shortUrl->update($validated);
|
||||
|
||||
if ($pixelIds !== null) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
return (new ShortUrlResource($shortUrl->fresh()))
|
||||
->additional(['message' => 'Short URL updated successfully.'])
|
||||
->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ShortUrl by database ID or URL key.
|
||||
*/
|
||||
private function findLink(string|int $idOrKey): ShortUrl
|
||||
{
|
||||
if (is_numeric($idOrKey)) {
|
||||
return ShortUrl::findOrFail((int) $idOrKey);
|
||||
}
|
||||
|
||||
$link = ShortUrl::where('url_key', $idOrKey)->first();
|
||||
|
||||
if (! $link) {
|
||||
abort(404, 'Short URL not found.');
|
||||
}
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,15 +166,24 @@ class ShortUrlApiController extends Controller
|
||||
if (! empty($targetUrl)) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
dispatch(new SendWebhookJob(
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'created',
|
||||
payload: [
|
||||
'event' => 'created',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => $this->transformLink($shortUrl),
|
||||
'short_url' => (new ShortUrlResource($shortUrl))->resolve(),
|
||||
]
|
||||
)->onConnection($connection ?: 'sync'));
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
$job->onConnection($connection);
|
||||
} else {
|
||||
$job->onConnection('sync');
|
||||
}
|
||||
|
||||
dispatch($job);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Created webhook dispatch failed', [
|
||||
'url_key' => $shortUrl->url_key,
|
||||
@@ -171,223 +192,4 @@ class ShortUrlApiController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload QR logo file from admin panel.
|
||||
*/
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
Log::info('ShortUrlApiController::uploadLogo called', [
|
||||
'auth_check' => auth()->check(),
|
||||
'user_id' => auth()->id(),
|
||||
'has_file' => $request->hasFile('logo'),
|
||||
'all_files' => array_keys($request->allFiles()),
|
||||
]);
|
||||
|
||||
if (! auth()->check()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'logo' => 'required|image|max:10240',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$file = $request->file('logo');
|
||||
$tempPath = $file->getRealPath();
|
||||
$filename = Str::random(40).'.webp';
|
||||
$targetPath = 'short-urls/tmp/'.$filename;
|
||||
|
||||
$processed = $this->processLogo($tempPath, $targetPath);
|
||||
|
||||
if ($processed) {
|
||||
$path = $targetPath;
|
||||
} else {
|
||||
// Fallback: store raw file if image processing fails
|
||||
$path = $file->store('short-urls/tmp', 'public');
|
||||
}
|
||||
|
||||
$url = route('short-url.logo', ['filename' => basename($path)]);
|
||||
|
||||
return response()->json([
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'No file uploaded'], 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
|
||||
*/
|
||||
private function processLogo(string $filePath, string $targetPath): bool
|
||||
{
|
||||
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
|
||||
return $this->processLogoWithImagick($filePath, $targetPath);
|
||||
}
|
||||
|
||||
if (extension_loaded('gd') && function_exists('gd_info')) {
|
||||
return $this->processLogoWithGd($filePath, $targetPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithImagick(string $filePath, string $targetPath): bool
|
||||
{
|
||||
try {
|
||||
$imagick = new \Imagick($filePath);
|
||||
|
||||
// Get original dimensions
|
||||
$width = $imagick->getImageWidth();
|
||||
$height = $imagick->getImageHeight();
|
||||
|
||||
// Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
$imagick->scaleImage($newWidth, $newHeight);
|
||||
}
|
||||
|
||||
// Convert to WebP format
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->setImageCompressionQuality(85);
|
||||
|
||||
// Get image data as blob
|
||||
$webpData = $imagick->getImageBlob();
|
||||
|
||||
// Clear resources
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
|
||||
if ($webpData) {
|
||||
return Storage::disk('public')->put($targetPath, $webpData);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall back to GD or raw store
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithGd(string $filePath, string $targetPath): bool
|
||||
{
|
||||
// 1. Get original dimensions and type
|
||||
$info = @getimagesize($filePath);
|
||||
if (! $info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$width, $height, $type] = $info;
|
||||
|
||||
// 2. Load image based on type
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$src = @imagecreatefromjpeg($filePath);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$src = @imagecreatefrompng($filePath);
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$src = @imagecreatefromwebp($filePath);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$src = @imagecreatefromgif($filePath);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
$newWidth = $width;
|
||||
$newHeight = $height;
|
||||
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create new truecolor image
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
if (! $dst) {
|
||||
imagedestroy($src);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preserve transparency for PNG and WebP
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
|
||||
// Resize
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
|
||||
|
||||
// 5. Save as WebP
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
ob_start();
|
||||
$saved = imagewebp($dst, null, 85);
|
||||
$webpData = ob_get_clean();
|
||||
|
||||
// Free memory
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved && $webpData !== false) {
|
||||
return $disk->put($targetPath, $webpData);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the uploaded QR logo.
|
||||
*/
|
||||
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
|
||||
{
|
||||
// Prevent directory traversal attacks
|
||||
$filename = basename($filename);
|
||||
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
|
||||
abort(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$path = 'short-urls/logos/'.$filename;
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
$path = 'short-urls/tmp/'.$filename;
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $disk->response($path, null, [
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
246
src/Http/Controllers/ShortUrlLogoController.php
Normal file
246
src/Http/Controllers/ShortUrlLogoController.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ShortUrlLogoController extends Controller
|
||||
{
|
||||
/**
|
||||
* Upload QR logo file from admin panel.
|
||||
*/
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
if (! auth()->check()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
// Authorize access using Filament's resource check or direct Gate check
|
||||
if (class_exists(ShortUrlResource::class)) {
|
||||
if (! ShortUrlResource::canCreate()) {
|
||||
return response()->json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
} else {
|
||||
if (Gate::denies('create', ShortUrl::class)) {
|
||||
return response()->json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'logo' => 'required|image|max:10240',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$file = $request->file('logo');
|
||||
$tempPath = $file->getRealPath();
|
||||
$filename = Str::random(40).'.webp';
|
||||
$targetPath = 'short-urls/tmp/'.$filename;
|
||||
|
||||
$processed = $this->processLogo($tempPath, $targetPath);
|
||||
|
||||
if ($processed) {
|
||||
$path = $targetPath;
|
||||
} else {
|
||||
// Fallback: store raw file if image processing fails
|
||||
$path = $file->store('short-urls/tmp', 'public');
|
||||
}
|
||||
|
||||
$url = route('short-url.logo', ['filename' => basename($path)]);
|
||||
|
||||
return response()->json([
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'No file uploaded'], 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
|
||||
*/
|
||||
private function processLogo(string $filePath, string $targetPath): bool
|
||||
{
|
||||
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
|
||||
return $this->processLogoWithImagick($filePath, $targetPath);
|
||||
}
|
||||
|
||||
if (extension_loaded('gd') && function_exists('gd_info')) {
|
||||
return $this->processLogoWithGd($filePath, $targetPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithImagick(string $filePath, string $targetPath): bool
|
||||
{
|
||||
try {
|
||||
$imagick = new \Imagick($filePath);
|
||||
|
||||
// Get original dimensions
|
||||
$width = $imagick->getImageWidth();
|
||||
$height = $imagick->getImageHeight();
|
||||
|
||||
// Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
$imagick->scaleImage($newWidth, $newHeight);
|
||||
}
|
||||
|
||||
// Convert to WebP format
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->setImageCompressionQuality(85);
|
||||
|
||||
// Get image data as blob
|
||||
$webpData = $imagick->getImageBlob();
|
||||
|
||||
// Clear resources
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
|
||||
if ($webpData) {
|
||||
return Storage::disk('public')->put($targetPath, $webpData);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall back to GD or raw store
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithGd(string $filePath, string $targetPath): bool
|
||||
{
|
||||
// 1. Get original dimensions and type
|
||||
$info = @getimagesize($filePath);
|
||||
if (! $info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$width, $height, $type] = $info;
|
||||
|
||||
// 2. Load image based on type
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$src = @imagecreatefromjpeg($filePath);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$src = @imagecreatefrompng($filePath);
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$src = @imagecreatefromwebp($filePath);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$src = @imagecreatefromgif($filePath);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
$newWidth = $width;
|
||||
$newHeight = $height;
|
||||
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create new truecolor image
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
if (! $dst) {
|
||||
imagedestroy($src);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preserve transparency for PNG and WebP
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
|
||||
// Resize
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
|
||||
|
||||
// 5. Save as WebP
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
ob_start();
|
||||
$saved = imagewebp($dst, null, 85);
|
||||
$webpData = ob_get_clean();
|
||||
|
||||
// Free memory
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved && ! empty($webpData)) {
|
||||
return $disk->put($targetPath, $webpData);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the uploaded QR logo.
|
||||
*/
|
||||
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
|
||||
{
|
||||
// Prevent directory traversal attacks
|
||||
$filename = basename($filename);
|
||||
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
|
||||
abort(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$path = 'short-urls/logos/'.$filename;
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
$path = 'short-urls/tmp/'.$filename;
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $disk->response($path, null, [
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user