mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Add Turnstile protection demo
This commit is contained in:
parent
3e413e2fed
commit
f8c953d37c
@ -88,3 +88,7 @@ QUICK_LISTING_AI_MODEL=gpt-5.2
|
|||||||
|
|
||||||
DEMO=0
|
DEMO=0
|
||||||
DEMO_TTL_MINUTES=360
|
DEMO_TTL_MINUTES=360
|
||||||
|
DEMO_TURNSTILE_ENABLED=0
|
||||||
|
TURNSTILE_SITE_KEY=0x4AAAAAACogGCt62w6ahqM4
|
||||||
|
TURNSTILE_SECRET_KEY=0x4AAAAAACogGLdg-1mydGAW8FT_He6DTI8
|
||||||
|
TURNSTILE_TIMEOUT_SECONDS=8
|
||||||
|
|||||||
@ -8,11 +8,16 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cookie;
|
use Illuminate\Support\Facades\Cookie;
|
||||||
use Modules\Demo\App\Support\DemoSchemaManager;
|
use Modules\Demo\App\Support\DemoSchemaManager;
|
||||||
|
use Modules\Demo\App\Support\TurnstileVerifier;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class DemoController extends Controller
|
class DemoController extends Controller
|
||||||
{
|
{
|
||||||
public function prepare(Request $request, DemoSchemaManager $demoSchemaManager): RedirectResponse
|
public function prepare(
|
||||||
|
Request $request,
|
||||||
|
DemoSchemaManager $demoSchemaManager,
|
||||||
|
TurnstileVerifier $turnstileVerifier,
|
||||||
|
): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(config('demo.enabled'), 404);
|
abort_unless(config('demo.enabled'), 404);
|
||||||
|
|
||||||
@ -20,6 +25,29 @@ class DemoController extends Controller
|
|||||||
$redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to'))
|
$redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to'))
|
||||||
?? route('home');
|
?? route('home');
|
||||||
|
|
||||||
|
if ($turnstileVerifier->enabled() && ! $turnstileVerifier->configured()) {
|
||||||
|
return redirect()
|
||||||
|
->to($redirectTo)
|
||||||
|
->with('error', 'Security verification is unavailable right now. Please contact support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $turnstileVerifier->verify(
|
||||||
|
$request->input('cf-turnstile-response'),
|
||||||
|
$request->ip(),
|
||||||
|
)) {
|
||||||
|
return redirect()
|
||||||
|
->to($redirectTo)
|
||||||
|
->with('error', 'Security verification failed. Please complete the check and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('set_time_limit')) {
|
||||||
|
@set_time_limit(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('ignore_user_abort')) {
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$instance = $demoSchemaManager->prepare($request->cookie($cookieName));
|
$instance = $demoSchemaManager->prepare($request->cookie($cookieName));
|
||||||
$user = $demoSchemaManager->resolveLoginUser();
|
$user = $demoSchemaManager->resolveLoginUser();
|
||||||
|
|||||||
72
Modules/Demo/App/Support/TurnstileVerifier.php
Normal file
72
Modules/Demo/App/Support/TurnstileVerifier.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Demo\App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class TurnstileVerifier
|
||||||
|
{
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('demo.turnstile.enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function siteKey(): string
|
||||||
|
{
|
||||||
|
return trim((string) config('demo.turnstile.site_key', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configured(): bool
|
||||||
|
{
|
||||||
|
return $this->siteKey() !== '' && $this->secretKey() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(?string $token, ?string $ip = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->enabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->configured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = trim((string) $token);
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'secret' => $this->secretKey(),
|
||||||
|
'response' => $token,
|
||||||
|
];
|
||||||
|
|
||||||
|
$ip = trim((string) $ip);
|
||||||
|
|
||||||
|
if ($ip !== '') {
|
||||||
|
$payload['remoteip'] = $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(max(3, (int) config('demo.turnstile.timeout_seconds', 8)))
|
||||||
|
->post((string) config('demo.turnstile.verify_url'), $payload);
|
||||||
|
|
||||||
|
if (! $response->ok()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) data_get($response->json(), 'success', false);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function secretKey(): string
|
||||||
|
{
|
||||||
|
return trim((string) config('demo.turnstile.secret_key', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,5 +4,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
use Modules\Demo\App\Http\Controllers\DemoController;
|
use Modules\Demo\App\Http\Controllers\DemoController;
|
||||||
|
|
||||||
Route::middleware('web')->group(function () {
|
Route::middleware('web')->group(function () {
|
||||||
Route::post('/demo/prepare', [DemoController::class, 'prepare'])->name('demo.prepare');
|
Route::post('/demo/prepare', [DemoController::class, 'prepare'])
|
||||||
|
->middleware('throttle:8,1')
|
||||||
|
->name('demo.prepare');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,22 +30,35 @@ class ListingSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$users = $this->resolveSeederUsers();
|
$users = $this->resolveSeederUsers();
|
||||||
$categories = $this->resolveSeedableCategories();
|
$categories = $this->resolveSeedableCategories();
|
||||||
|
$imagePool = SampleListingImageCatalog::uniquePaths();
|
||||||
|
|
||||||
if ($users->isEmpty() || $categories->isEmpty()) {
|
if ($users->isEmpty() || $categories->isEmpty() || $imagePool->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$countries = $this->resolveCountries();
|
$countries = $this->resolveCountries();
|
||||||
$turkeyCities = $this->resolveTurkeyCities();
|
$turkeyCities = $this->resolveTurkeyCities();
|
||||||
$plannedSlugs = [];
|
$plannedSlugs = [];
|
||||||
|
$assignedImageIndex = 0;
|
||||||
|
|
||||||
foreach ($users as $userIndex => $user) {
|
foreach ($categories as $category) {
|
||||||
foreach ($categories as $categoryIndex => $category) {
|
foreach ($users as $user) {
|
||||||
$listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex;
|
if ($assignedImageIndex >= $imagePool->count()) {
|
||||||
$listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$listingData = $this->buildListingData(
|
||||||
|
$category,
|
||||||
|
$assignedImageIndex,
|
||||||
|
$countries,
|
||||||
|
$turkeyCities,
|
||||||
|
$user,
|
||||||
|
$imagePool->get($assignedImageIndex)
|
||||||
|
);
|
||||||
$listing = $this->upsertListing($listingData, $category, $user);
|
$listing = $this->upsertListing($listingData, $category, $user);
|
||||||
$plannedSlugs[] = $listing->slug;
|
$plannedSlugs[] = $listing->slug;
|
||||||
$this->syncListingImage($listing, $listingData['image_path']);
|
$this->syncListingImage($listing, $listingData['image_path']);
|
||||||
|
$assignedImageIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,7 +151,8 @@ class ListingSeeder extends Seeder
|
|||||||
int $index,
|
int $index,
|
||||||
Collection $countries,
|
Collection $countries,
|
||||||
Collection $turkeyCities,
|
Collection $turkeyCities,
|
||||||
User $user
|
User $user,
|
||||||
|
?string $imagePath
|
||||||
): array {
|
): array {
|
||||||
$location = $this->resolveLocation($index, $countries, $turkeyCities);
|
$location = $this->resolveLocation($index, $countries, $turkeyCities);
|
||||||
$title = $this->buildTitle($category, $index, $user);
|
$title = $this->buildTitle($category, $index, $user);
|
||||||
@ -155,7 +169,7 @@ class ListingSeeder extends Seeder
|
|||||||
'is_featured' => $index % 7 === 0,
|
'is_featured' => $index % 7 === 0,
|
||||||
'expires_at' => now()->addDays(21 + ($index % 9)),
|
'expires_at' => now()->addDays(21 + ($index % 9)),
|
||||||
'created_at' => now()->subHours(6 + $index),
|
'created_at' => now()->subHours(6 + $index),
|
||||||
'image_path' => SampleListingImageCatalog::pathFor($category, $index),
|
'image_path' => $imagePath,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -539,6 +539,10 @@ class Listing extends Model implements HasMedia
|
|||||||
|
|
||||||
private function shouldSkipConversionsForSeeder(): bool
|
private function shouldSkipConversionsForSeeder(): bool
|
||||||
{
|
{
|
||||||
|
if ((bool) config('demo.provisioning', false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (! app()->runningInConsole()) {
|
if (! app()->runningInConsole()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -176,26 +176,26 @@ final class SampleListingImageCatalog
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static function uniquePaths(): Collection
|
||||||
|
{
|
||||||
|
return self::allPaths()
|
||||||
|
->sortBy(fn (string $path): string => strtolower((string) basename($path)))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
public static function pathFor(Category $category, int $seed): ?string
|
public static function pathFor(Category $category, int $seed): ?string
|
||||||
{
|
{
|
||||||
$categorySlug = trim((string) $category->slug);
|
$paths = self::uniquePaths();
|
||||||
$familySlug = trim((string) ($category->parent?->slug ?? $category->slug));
|
|
||||||
|
|
||||||
$paths = self::resolvePathsForSlug($categorySlug);
|
|
||||||
|
|
||||||
if ($paths->isEmpty()) {
|
|
||||||
$paths = self::resolvePathsForSlug($familySlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($paths->isEmpty()) {
|
|
||||||
$paths = self::allPaths();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($paths->isEmpty()) {
|
if ($paths->isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $paths->values()->get($seed % $paths->count());
|
if ($seed < 0 || $seed >= $paths->count()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paths->get($seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fileNameFor(string $absolutePath, string $slug): string
|
public static function fileNameFor(string $absolutePath, string $slug): string
|
||||||
|
|||||||
78
README.md
78
README.md
@ -155,6 +155,82 @@ php artisan demo:cleanup
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Realtime Chat (Laravel Reverb)
|
||||||
|
|
||||||
|
This project already uses Laravel Reverb + Echo for inbox and listing chat realtime updates.
|
||||||
|
|
||||||
|
### 1. Environment
|
||||||
|
|
||||||
|
Set these values in `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BROADCAST_CONNECTION=reverb
|
||||||
|
|
||||||
|
REVERB_APP_ID=480227
|
||||||
|
REVERB_APP_KEY=your_key
|
||||||
|
REVERB_APP_SECRET=your_secret
|
||||||
|
REVERB_HOST=localhost
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
REVERB_SERVER_HOST=0.0.0.0
|
||||||
|
REVERB_SERVER_PORT=8080
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
|
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start Services
|
||||||
|
|
||||||
|
Use one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan serve
|
||||||
|
php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||||
|
php artisan queue:listen --tries=1 --timeout=0
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. How It Works in This Codebase
|
||||||
|
|
||||||
|
- Private channel: `users.{id}.inbox`
|
||||||
|
- Channel authorization: `Modules/Conversation/App/Providers/ConversationServiceProvider.php`
|
||||||
|
- Broadcast events:
|
||||||
|
- `InboxMessageCreated` (`.inbox.message.created`)
|
||||||
|
- `ConversationReadUpdated` (`.inbox.read.updated`)
|
||||||
|
- Frontend subscriptions: `Modules/Conversation/resources/assets/js/conversation.js`
|
||||||
|
- Echo bootstrap: `resources/js/echo.js`
|
||||||
|
|
||||||
|
### 4. Quick Verification
|
||||||
|
|
||||||
|
1. Open two different authenticated sessions (for example `a@a.com` and `b@b.com`).
|
||||||
|
2. Go to `/panel/inbox` in both sessions.
|
||||||
|
3. Send a message from one session.
|
||||||
|
4. Confirm in the other session:
|
||||||
|
- thread updates instantly,
|
||||||
|
- inbox ordering/unread state updates,
|
||||||
|
- header inbox badge updates.
|
||||||
|
|
||||||
|
### 5. Troubleshooting
|
||||||
|
|
||||||
|
- No realtime updates:
|
||||||
|
- check `php artisan reverb:start` is running,
|
||||||
|
- check Vite is running (`npm run dev`) and assets are rebuilt.
|
||||||
|
- Private channel auth fails (`403`):
|
||||||
|
- verify user is authenticated in the same browser/session.
|
||||||
|
- WebSocket connection fails:
|
||||||
|
- verify `REVERB_HOST/PORT/SCHEME` and matching `VITE_REVERB_*` values,
|
||||||
|
- run `php artisan optimize:clear` after env changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Code Contributors
|
## Code Contributors
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@ -246,4 +322,4 @@ php artisan config:cache
|
|||||||
php artisan route:cache
|
php artisan route:cache
|
||||||
php artisan view:cache
|
php artisan view:cache
|
||||||
php artisan storage:link
|
php artisan storage:link
|
||||||
```
|
```
|
||||||
|
|||||||
@ -8,4 +8,11 @@ return [
|
|||||||
'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'),
|
'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'),
|
||||||
'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'),
|
'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'),
|
||||||
'public_schema' => env('DEMO_PUBLIC_SCHEMA', 'public'),
|
'public_schema' => env('DEMO_PUBLIC_SCHEMA', 'public'),
|
||||||
|
'turnstile' => [
|
||||||
|
'enabled' => (bool) env('DEMO_TURNSTILE_ENABLED', false),
|
||||||
|
'site_key' => env('TURNSTILE_SITE_KEY'),
|
||||||
|
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
||||||
|
'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
|
||||||
|
'timeout_seconds' => (int) env('TURNSTILE_TIMEOUT_SECONDS', 8),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,6 +10,10 @@
|
|||||||
$prepareDemoRedirect = url()->full();
|
$prepareDemoRedirect = url()->full();
|
||||||
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
|
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
|
||||||
$demoLandingMode = $demoEnabled && !auth()->check() && !$hasDemoSession;
|
$demoLandingMode = $demoEnabled && !auth()->check() && !$hasDemoSession;
|
||||||
|
$demoTurnstileProtectionEnabled = (bool) config('demo.turnstile.enabled', false);
|
||||||
|
$demoTurnstileSiteKey = trim((string) config('demo.turnstile.site_key', ''));
|
||||||
|
$prepareDemoTurnstileRequired = $demoLandingMode && $demoTurnstileProtectionEnabled;
|
||||||
|
$prepareDemoTurnstileRenderable = $prepareDemoTurnstileRequired && $demoTurnstileSiteKey !== '';
|
||||||
$demoTtlMinutes = (int) config('demo.ttl_minutes', 360);
|
$demoTtlMinutes = (int) config('demo.ttl_minutes', 360);
|
||||||
$demoTtlHours = intdiv($demoTtlMinutes, 60);
|
$demoTtlHours = intdiv($demoTtlMinutes, 60);
|
||||||
$demoTtlRemainderMinutes = $demoTtlMinutes % 60;
|
$demoTtlRemainderMinutes = $demoTtlMinutes % 60;
|
||||||
@ -62,7 +66,7 @@
|
|||||||
|
|
||||||
@if($demoLandingMode && $prepareDemoRoute)
|
@if($demoLandingMode && $prepareDemoRoute)
|
||||||
<div class="min-h-screen flex items-center justify-center px-5 py-10">
|
<div class="min-h-screen flex items-center justify-center px-5 py-10">
|
||||||
<form method="POST" action="{{ $prepareDemoRoute }}" class="w-full max-w-xl rounded-[32px] border border-slate-200 bg-white p-8 md:p-10 shadow-xl">
|
<form method="POST" action="{{ $prepareDemoRoute }}" data-demo-prepare-form data-turnstile-required="{{ $prepareDemoTurnstileRequired ? '1' : '0' }}" class="w-full max-w-xl rounded-[32px] border border-slate-200 bg-white p-8 md:p-10 shadow-xl">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
|
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
|
||||||
<h1 class="text-3xl md:text-5xl font-extrabold tracking-tight text-slate-950">Prepare Demo</h1>
|
<h1 class="text-3xl md:text-5xl font-extrabold tracking-tight text-slate-950">Prepare Demo</h1>
|
||||||
@ -72,8 +76,28 @@
|
|||||||
<p class="mt-4 text-base text-slate-500">
|
<p class="mt-4 text-base text-slate-500">
|
||||||
This demo is deleted automatically after {{ $demoTtlLabel }}.
|
This demo is deleted automatically after {{ $demoTtlLabel }}.
|
||||||
</p>
|
</p>
|
||||||
<button type="submit" class="mt-8 inline-flex min-h-16 w-full items-center justify-center rounded-full bg-blue-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition hover:bg-blue-700">
|
@if($prepareDemoTurnstileRenderable)
|
||||||
Prepare Demo
|
<div class="mt-6 space-y-2">
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ $demoTurnstileSiteKey }}"></div>
|
||||||
|
<p class="text-xs text-slate-500">Complete the security check before starting your private demo.</p>
|
||||||
|
</div>
|
||||||
|
@elseif($prepareDemoTurnstileRequired)
|
||||||
|
<p class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-6 text-amber-700">
|
||||||
|
Security check is enabled but the widget is not configured. Contact the administrator.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
<p data-demo-prepare-status data-turnstile-message="Please complete the security verification first." data-loading-message="Preparing your private demo. This can take longer because a dedicated seeded environment is being provisioned for your browser." aria-live="polite" class="mt-4 hidden rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium leading-6 text-blue-800">
|
||||||
|
Preparing your private demo. This can take longer because a dedicated seeded environment is being provisioned for your browser.
|
||||||
|
</p>
|
||||||
|
<button type="submit" data-demo-prepare-button @if($prepareDemoTurnstileRequired) disabled @endif class="mt-8 inline-flex min-h-16 w-full items-center justify-center rounded-full bg-blue-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-500">
|
||||||
|
<span data-demo-prepare-idle>Prepare Demo</span>
|
||||||
|
<span data-demo-prepare-loading class="hidden items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v3a5 5 0 0 0-5 5H4z"></path>
|
||||||
|
</svg>
|
||||||
|
Preparing Demo...
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -362,6 +386,107 @@
|
|||||||
@endif
|
@endif
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
|
const setupPrepareDemoForm = () => {
|
||||||
|
const form = document.querySelector('[data-demo-prepare-form]');
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = form.querySelector('[data-demo-prepare-button]');
|
||||||
|
const idleLabel = form.querySelector('[data-demo-prepare-idle]');
|
||||||
|
const loadingLabel = form.querySelector('[data-demo-prepare-loading]');
|
||||||
|
const status = form.querySelector('[data-demo-prepare-status]');
|
||||||
|
const turnstileRequired = form.dataset.turnstileRequired === '1';
|
||||||
|
|
||||||
|
const resolveTurnstileToken = () => {
|
||||||
|
const tokenField = form.querySelector('input[name="cf-turnstile-response"]');
|
||||||
|
|
||||||
|
if (!tokenField) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenField.value.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyReadyState = () => {
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!turnstileRequired) {
|
||||||
|
button.removeAttribute('disabled');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = resolveTurnstileToken();
|
||||||
|
|
||||||
|
if (token === '') {
|
||||||
|
button.setAttribute('disabled', 'disabled');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.removeAttribute('disabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (turnstileRequired) {
|
||||||
|
const tokenObserver = window.setInterval(() => {
|
||||||
|
applyReadyState();
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
window.clearInterval(tokenObserver);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
applyReadyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (form.dataset.submitting === '1') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnstileRequired && resolveTurnstileToken() === '') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
status.textContent = status.dataset.turnstileMessage ?? 'Please complete the security verification first.';
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReadyState();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.dataset.submitting = '1';
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.setAttribute('disabled', 'disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idleLabel) {
|
||||||
|
idleLabel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingLabel) {
|
||||||
|
loadingLabel.classList.remove('hidden');
|
||||||
|
loadingLabel.classList.add('inline-flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
status.textContent = status.dataset.loadingMessage ?? status.textContent;
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setupPrepareDemoForm();
|
||||||
|
|
||||||
const setupTrendCategories = () => {
|
const setupTrendCategories = () => {
|
||||||
const track = document.querySelector('[data-trend-track]');
|
const track = document.querySelector('[data-trend-track]');
|
||||||
const previousButton = document.querySelector('[data-trend-prev]');
|
const previousButton = document.querySelector('[data-trend-prev]');
|
||||||
@ -471,4 +596,7 @@
|
|||||||
setupTrendCategories();
|
setupTrendCategories();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
@if($prepareDemoTurnstileRenderable)
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
@endif
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
$demoExpiresAt = session('demo_expires_at');
|
$demoExpiresAt = session('demo_expires_at');
|
||||||
$demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null;
|
$demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null;
|
||||||
$demoRemainingLabel = null;
|
$demoRemainingLabel = null;
|
||||||
|
$demoRemainingCompactLabel = null;
|
||||||
|
|
||||||
if ($demoExpiresAt?->isFuture()) {
|
if ($demoExpiresAt?->isFuture()) {
|
||||||
$remainingMinutes = now()->diffInMinutes($demoExpiresAt, false);
|
$remainingMinutes = now()->diffInMinutes($demoExpiresAt, false);
|
||||||
@ -38,6 +39,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$demoRemainingLabel = $remainingParts !== [] ? implode(' ', $remainingParts) : 'less than a minute';
|
$demoRemainingLabel = $remainingParts !== [] ? implode(' ', $remainingParts) : 'less than a minute';
|
||||||
|
$demoRemainingCompactLabel = trim(
|
||||||
|
($remainingHours > 0 ? $remainingHours.'h ' : '')
|
||||||
|
.($remainingRemainderMinutes > 0 ? $remainingRemainderMinutes.'m' : '')
|
||||||
|
);
|
||||||
|
$demoRemainingCompactLabel = $demoRemainingCompactLabel !== '' ? $demoRemainingCompactLabel : '<1m';
|
||||||
}
|
}
|
||||||
$availableLocales = config('app.available_locales', ['en']);
|
$availableLocales = config('app.available_locales', ['en']);
|
||||||
$localeLabels = [
|
$localeLabels = [
|
||||||
@ -88,6 +94,7 @@
|
|||||||
'bg-slate-50' => $demoLandingMode,
|
'bg-slate-50' => $demoLandingMode,
|
||||||
'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode,
|
'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode,
|
||||||
])>
|
])>
|
||||||
|
@if(! $demoLandingMode)
|
||||||
@if($simplePage)
|
@if($simplePage)
|
||||||
<nav class="sticky top-0 z-50 border-b border-black/5 bg-white/80 backdrop-blur-2xl">
|
<nav class="sticky top-0 z-50 border-b border-black/5 bg-white/80 backdrop-blur-2xl">
|
||||||
<div class="mx-auto flex min-h-[76px] max-w-[1120px] items-center justify-between gap-4 px-4">
|
<div class="mx-auto flex min-h-[76px] max-w-[1120px] items-center justify-between gap-4 px-4">
|
||||||
@ -374,10 +381,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
@if($demoRemainingLabel)
|
@if($demoRemainingLabel)
|
||||||
<div class="sticky top-0 z-40 border-b border-amber-200 bg-amber-50/95 backdrop-blur-md">
|
<div class="pointer-events-none fixed bottom-4 right-4 z-40">
|
||||||
<div class="mx-auto flex min-h-12 max-w-[1320px] items-center justify-center px-4 py-2 text-center text-sm font-semibold text-amber-900">
|
<div class="rounded-full border border-amber-200 bg-white/95 px-3 py-1.5 text-[11px] font-semibold text-amber-900 shadow-lg backdrop-blur">
|
||||||
Demo auto deletes in {{ $demoRemainingLabel }}
|
Demo: {{ $demoRemainingCompactLabel }} left
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -395,7 +403,7 @@
|
|||||||
'site-main',
|
'site-main',
|
||||||
'min-h-screen' => $demoLandingMode,
|
'min-h-screen' => $demoLandingMode,
|
||||||
])>@yield('content')</main>
|
])>@yield('content')</main>
|
||||||
@if(!$simplePage)
|
@if(!$simplePage && ! $demoLandingMode)
|
||||||
<footer class="mt-10 md:mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer>
|
<footer class="mt-10 md:mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer>
|
||||||
<div class="max-w-[1320px] mx-auto px-4 py-8 md:py-12">
|
<div class="max-w-[1320px] mx-auto px-4 py-8 md:py-12">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 md:gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 md:gap-8">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Loading…
Reference in New Issue
Block a user