mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 03:02:08 -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_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\Cookie;
|
||||
use Modules\Demo\App\Support\DemoSchemaManager;
|
||||
use Modules\Demo\App\Support\TurnstileVerifier;
|
||||
use Throwable;
|
||||
|
||||
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);
|
||||
|
||||
@ -20,6 +25,29 @@ class DemoController extends Controller
|
||||
$redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to'))
|
||||
?? 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 {
|
||||
$instance = $demoSchemaManager->prepare($request->cookie($cookieName));
|
||||
$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;
|
||||
|
||||
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();
|
||||
$categories = $this->resolveSeedableCategories();
|
||||
$imagePool = SampleListingImageCatalog::uniquePaths();
|
||||
|
||||
if ($users->isEmpty() || $categories->isEmpty()) {
|
||||
if ($users->isEmpty() || $categories->isEmpty() || $imagePool->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$countries = $this->resolveCountries();
|
||||
$turkeyCities = $this->resolveTurkeyCities();
|
||||
$plannedSlugs = [];
|
||||
$assignedImageIndex = 0;
|
||||
|
||||
foreach ($users as $userIndex => $user) {
|
||||
foreach ($categories as $categoryIndex => $category) {
|
||||
$listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex;
|
||||
$listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user);
|
||||
foreach ($categories as $category) {
|
||||
foreach ($users as $user) {
|
||||
if ($assignedImageIndex >= $imagePool->count()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$listingData = $this->buildListingData(
|
||||
$category,
|
||||
$assignedImageIndex,
|
||||
$countries,
|
||||
$turkeyCities,
|
||||
$user,
|
||||
$imagePool->get($assignedImageIndex)
|
||||
);
|
||||
$listing = $this->upsertListing($listingData, $category, $user);
|
||||
$plannedSlugs[] = $listing->slug;
|
||||
$this->syncListingImage($listing, $listingData['image_path']);
|
||||
$assignedImageIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,7 +151,8 @@ class ListingSeeder extends Seeder
|
||||
int $index,
|
||||
Collection $countries,
|
||||
Collection $turkeyCities,
|
||||
User $user
|
||||
User $user,
|
||||
?string $imagePath
|
||||
): array {
|
||||
$location = $this->resolveLocation($index, $countries, $turkeyCities);
|
||||
$title = $this->buildTitle($category, $index, $user);
|
||||
@ -155,7 +169,7 @@ class ListingSeeder extends Seeder
|
||||
'is_featured' => $index % 7 === 0,
|
||||
'expires_at' => now()->addDays(21 + ($index % 9)),
|
||||
'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
|
||||
{
|
||||
if ((bool) config('demo.provisioning', false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! app()->runningInConsole()) {
|
||||
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
|
||||
{
|
||||
$categorySlug = trim((string) $category->slug);
|
||||
$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();
|
||||
}
|
||||
$paths = self::uniquePaths();
|
||||
|
||||
if ($paths->isEmpty()) {
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
<p align="center">
|
||||
@ -246,4 +322,4 @@ php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan storage:link
|
||||
```
|
||||
```
|
||||
|
||||
@ -8,4 +8,11 @@ return [
|
||||
'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'),
|
||||
'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'),
|
||||
'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();
|
||||
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
|
||||
$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);
|
||||
$demoTtlHours = intdiv($demoTtlMinutes, 60);
|
||||
$demoTtlRemainderMinutes = $demoTtlMinutes % 60;
|
||||
@ -62,7 +66,7 @@
|
||||
|
||||
@if($demoLandingMode && $prepareDemoRoute)
|
||||
<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
|
||||
<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>
|
||||
@ -72,8 +76,28 @@
|
||||
<p class="mt-4 text-base text-slate-500">
|
||||
This demo is deleted automatically after {{ $demoTtlLabel }}.
|
||||
</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">
|
||||
Prepare Demo
|
||||
@if($prepareDemoTurnstileRenderable)
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@ -362,6 +386,107 @@
|
||||
@endif
|
||||
<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 track = document.querySelector('[data-trend-track]');
|
||||
const previousButton = document.querySelector('[data-trend-prev]');
|
||||
@ -471,4 +596,7 @@
|
||||
setupTrendCategories();
|
||||
})();
|
||||
</script>
|
||||
@if($prepareDemoTurnstileRenderable)
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
$demoExpiresAt = session('demo_expires_at');
|
||||
$demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null;
|
||||
$demoRemainingLabel = null;
|
||||
$demoRemainingCompactLabel = null;
|
||||
|
||||
if ($demoExpiresAt?->isFuture()) {
|
||||
$remainingMinutes = now()->diffInMinutes($demoExpiresAt, false);
|
||||
@ -38,6 +39,11 @@
|
||||
}
|
||||
|
||||
$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']);
|
||||
$localeLabels = [
|
||||
@ -88,6 +94,7 @@
|
||||
'bg-slate-50' => $demoLandingMode,
|
||||
'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode,
|
||||
])>
|
||||
@if(! $demoLandingMode)
|
||||
@if($simplePage)
|
||||
<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">
|
||||
@ -374,10 +381,11 @@
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
@endif
|
||||
@if($demoRemainingLabel)
|
||||
<div class="sticky top-0 z-40 border-b border-amber-200 bg-amber-50/95 backdrop-blur-md">
|
||||
<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">
|
||||
Demo auto deletes in {{ $demoRemainingLabel }}
|
||||
<div class="pointer-events-none fixed bottom-4 right-4 z-40">
|
||||
<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: {{ $demoRemainingCompactLabel }} left
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@ -395,7 +403,7 @@
|
||||
'site-main',
|
||||
'min-h-screen' => $demoLandingMode,
|
||||
])>@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>
|
||||
<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">
|
||||
|
||||
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