mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Compare commits
2 Commits
3e413e2fed
...
6ea371e372
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea371e372 | ||
|
|
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,33 +176,43 @@ final class SampleListingImageCatalog
|
||||
],
|
||||
];
|
||||
|
||||
public static function uniquePaths(): Collection
|
||||
{
|
||||
return self::allPaths()
|
||||
->sortBy(fn (string $path): string => strtolower((string) basename($path)))
|
||||
->map(fn (string $path): array => [
|
||||
'path' => $path,
|
||||
'hash' => md5_file($path) ?: strtolower((string) basename($path)),
|
||||
])
|
||||
->unique('hash')
|
||||
->pluck('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
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION));
|
||||
$hash = md5_file($absolutePath);
|
||||
$hashSuffix = is_string($hash) && $hash !== ''
|
||||
? '-'.substr($hash, 0, 8)
|
||||
: '';
|
||||
|
||||
return $slug.($extension !== '' ? '.'.$extension : '');
|
||||
return $slug.$hashSuffix.($extension !== '' ? '.'.$extension : '');
|
||||
}
|
||||
|
||||
private static function resolvePathsForSlug(string $slug): Collection
|
||||
|
||||
@ -21,55 +21,70 @@
|
||||
'search' => $search !== '' ? $search : null,
|
||||
'user' => $sellerUserId ?? null,
|
||||
], $normalizeQuery);
|
||||
$activeFilterCount = collect([
|
||||
$categoryId,
|
||||
$countryId,
|
||||
$cityId,
|
||||
$sellerUserId,
|
||||
$minPriceInput !== '' ? $minPriceInput : null,
|
||||
$maxPriceInput !== '' ? $maxPriceInput : null,
|
||||
$dateFilter !== 'all' ? $dateFilter : null,
|
||||
])->filter($normalizeQuery)->count();
|
||||
@endphp
|
||||
|
||||
<div class="max-w-[1320px] mx-auto px-4 py-7 lg:py-8">
|
||||
<div class="listing-index-shell max-w-[1320px] mx-auto px-4 py-7 lg:py-8">
|
||||
<h1 class="sr-only">{{ $seoHeading }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[260px,1fr] gap-4 lg:gap-5">
|
||||
<aside class="space-y-4">
|
||||
<section class="listing-filter-card p-4">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 class="text-2xl font-bold text-slate-900 leading-none">Categories</h2>
|
||||
<aside class="listing-sidebar" data-listing-filter-drawer aria-hidden="false">
|
||||
<button type="button" class="listing-sidebar-backdrop lg:hidden" data-listing-filter-close aria-label="Close filters"></button>
|
||||
<div class="listing-sidebar-shell space-y-4">
|
||||
<div class="listing-sidebar-head lg:hidden">
|
||||
<h2>Filters</h2>
|
||||
<button type="button" class="listing-sidebar-close" data-listing-filter-close aria-label="Close filters">×</button>
|
||||
</div>
|
||||
<section class="listing-filter-card p-4">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 class="text-2xl font-bold text-slate-900 leading-none">Categories</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 max-h-[330px] overflow-y-auto pr-1">
|
||||
@php
|
||||
$allCategoriesLink = route('listings.index', $baseCategoryQuery);
|
||||
@endphp
|
||||
<a href="{{ $allCategoriesLink }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ is_null($categoryId) ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
|
||||
<span>All Listings</span>
|
||||
<span>{{ number_format($allListingsCount) }}</span>
|
||||
</a>
|
||||
|
||||
@foreach($categories as $category)
|
||||
<div class="space-y-1 max-h-[330px] overflow-y-auto pr-1">
|
||||
@php
|
||||
$categoryCount = (int) $category->active_listing_total;
|
||||
$isSelectedParent = (int) $categoryId === (int) $category->id;
|
||||
$categoryUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [
|
||||
'category' => $category->id,
|
||||
]), $normalizeQuery));
|
||||
$allCategoriesLink = route('listings.index', $baseCategoryQuery);
|
||||
@endphp
|
||||
<a href="{{ $categoryUrl }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ $isSelectedParent ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
|
||||
<span>{{ $category->name }}</span>
|
||||
<span>{{ number_format($categoryCount) }}</span>
|
||||
<a href="{{ $allCategoriesLink }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ is_null($categoryId) ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
|
||||
<span>All Listings</span>
|
||||
<span>{{ number_format($allListingsCount) }}</span>
|
||||
</a>
|
||||
|
||||
@foreach($category->children as $childCategory)
|
||||
@foreach($categories as $category)
|
||||
@php
|
||||
$isSelectedChild = (int) $categoryId === (int) $childCategory->id;
|
||||
$childUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [
|
||||
'category' => $childCategory->id,
|
||||
$categoryCount = (int) $category->active_listing_total;
|
||||
$isSelectedParent = (int) $categoryId === (int) $category->id;
|
||||
$categoryUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [
|
||||
'category' => $category->id,
|
||||
]), $normalizeQuery));
|
||||
@endphp
|
||||
<a href="{{ $childUrl }}" class="ml-2 flex items-center justify-between rounded-lg px-2 py-1.5 text-[13px] font-medium {{ $isSelectedChild ? 'bg-rose-50 text-rose-600' : 'text-slate-600 hover:bg-slate-100' }}">
|
||||
<span>{{ $childCategory->name }}</span>
|
||||
<span>{{ number_format((int) $childCategory->active_listing_total) }}</span>
|
||||
<a href="{{ $categoryUrl }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ $isSelectedParent ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
|
||||
<span>{{ $category->name }}</span>
|
||||
<span>{{ number_format($categoryCount) }}</span>
|
||||
</a>
|
||||
|
||||
@foreach($category->children as $childCategory)
|
||||
@php
|
||||
$isSelectedChild = (int) $categoryId === (int) $childCategory->id;
|
||||
$childUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [
|
||||
'category' => $childCategory->id,
|
||||
]), $normalizeQuery));
|
||||
@endphp
|
||||
<a href="{{ $childUrl }}" class="ml-2 flex items-center justify-between rounded-lg px-2 py-1.5 text-[13px] font-medium {{ $isSelectedChild ? 'bg-rose-50 text-rose-600' : 'text-slate-600 hover:bg-slate-100' }}">
|
||||
<span>{{ $childCategory->name }}</span>
|
||||
<span>{{ number_format((int) $childCategory->active_listing_total) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form method="GET" action="{{ route('listings.index') }}" class="listing-filter-card p-4 space-y-5">
|
||||
@if($search !== '')
|
||||
@ -159,10 +174,62 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="listing-filter-card px-4 py-3 flex flex-col xl:flex-row xl:items-center gap-3">
|
||||
<div class="listing-mobile-toolbar lg:hidden">
|
||||
<div class="listing-mobile-toolbar-row">
|
||||
<button type="button" class="listing-mobile-filter-button" data-listing-filter-open>
|
||||
Filters
|
||||
@if($activeFilterCount > 0)
|
||||
<span class="listing-mobile-filter-badge">{{ $activeFilterCount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<form method="GET" action="{{ route('listings.index') }}" class="listing-mobile-sort-form">
|
||||
@if($search !== '')
|
||||
<input type="hidden" name="search" value="{{ $search }}">
|
||||
@endif
|
||||
@if($categoryId)
|
||||
<input type="hidden" name="category" value="{{ $categoryId }}">
|
||||
@endif
|
||||
@if(! empty($sellerUserId))
|
||||
<input type="hidden" name="user" value="{{ $sellerUserId }}">
|
||||
@endif
|
||||
@if($countryId)
|
||||
<input type="hidden" name="country" value="{{ $countryId }}">
|
||||
@endif
|
||||
@if($cityId)
|
||||
<input type="hidden" name="city" value="{{ $cityId }}">
|
||||
@endif
|
||||
@if($minPriceInput !== '')
|
||||
<input type="hidden" name="min_price" value="{{ $minPriceInput }}">
|
||||
@endif
|
||||
@if($maxPriceInput !== '')
|
||||
<input type="hidden" name="max_price" value="{{ $maxPriceInput }}">
|
||||
@endif
|
||||
@if($dateFilter !== 'all')
|
||||
<input type="hidden" name="date_filter" value="{{ $dateFilter }}">
|
||||
@endif
|
||||
<label class="listing-mobile-sort-label">
|
||||
<span>Sort</span>
|
||||
<select name="sort" class="listing-mobile-sort-select" onchange="this.form.submit()">
|
||||
<option value="smart" @selected($sort === 'smart')>Recommended</option>
|
||||
<option value="newest" @selected($sort === 'newest')>Newest</option>
|
||||
<option value="oldest" @selected($sort === 'oldest')>Oldest</option>
|
||||
<option value="price_asc" @selected($sort === 'price_asc')>Price ↑</option>
|
||||
<option value="price_desc" @selected($sort === 'price_desc')>Price ↓</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<p class="listing-mobile-toolbar-meta">
|
||||
<strong>{{ number_format($resultListingsCount) }}</strong>
|
||||
{{ $activeCategoryName !== '' ? ' listings in '.$activeCategoryName : ' listings found' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="listing-filter-card px-4 py-3 hidden lg:flex flex-col xl:flex-row xl:items-center gap-3">
|
||||
<p class="text-sm text-slate-700 mr-auto">
|
||||
<strong>{{ number_format($resultListingsCount) }}</strong>
|
||||
{{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }}
|
||||
@ -228,7 +295,7 @@
|
||||
No listings match this filter.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3.5">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||
@foreach($listings as $listing)
|
||||
@php
|
||||
$listingImage = $listing->primaryImageData('card');
|
||||
@ -241,7 +308,7 @@
|
||||
$locationText = implode(', ', $locationParts);
|
||||
@endphp
|
||||
<article class="listing-card">
|
||||
<div class="relative h-52 bg-slate-200">
|
||||
<div class="relative h-40 sm:h-48 lg:h-52 bg-slate-200">
|
||||
@if($listingImage)
|
||||
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full">
|
||||
@include('listing::partials.responsive-image', [
|
||||
@ -282,7 +349,7 @@
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<a href="{{ route('listings.show', $listing) }}" class="block">
|
||||
<p class="text-3xl leading-none font-bold text-slate-900">
|
||||
<p class="text-xl sm:text-2xl lg:text-3xl leading-none font-bold text-slate-900">
|
||||
@if(!is_null($priceValue) && $priceValue > 0)
|
||||
{{ number_format($priceValue, 0) }} {{ $listing->currency }}
|
||||
@else
|
||||
@ -320,8 +387,60 @@
|
||||
const countrySelect = document.querySelector('[data-listing-country]');
|
||||
const citySelect = document.querySelector('[data-listing-city]');
|
||||
const currentLocationButton = document.querySelector('[data-use-current-location]');
|
||||
const filterDrawer = document.querySelector('[data-listing-filter-drawer]');
|
||||
const filterOpenButtons = Array.from(document.querySelectorAll('[data-listing-filter-open]'));
|
||||
const filterCloseButtons = Array.from(document.querySelectorAll('[data-listing-filter-close]'));
|
||||
const citiesTemplate = countrySelect?.dataset.citiesUrlTemplate ?? '';
|
||||
const locationStorageKey = 'oc2.header.location';
|
||||
const drawerMediaQuery = window.matchMedia('(max-width: 1023px)');
|
||||
|
||||
const setDrawerExpanded = (expanded) => {
|
||||
filterOpenButtons.forEach((button) => button.setAttribute('aria-expanded', expanded ? 'true' : 'false'));
|
||||
};
|
||||
|
||||
const closeFilterDrawer = () => {
|
||||
if (!filterDrawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterDrawer.classList.remove('is-open');
|
||||
filterDrawer.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('listing-filters-open');
|
||||
setDrawerExpanded(false);
|
||||
};
|
||||
|
||||
const openFilterDrawer = () => {
|
||||
if (!filterDrawer || !drawerMediaQuery.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterDrawer.classList.add('is-open');
|
||||
filterDrawer.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('listing-filters-open');
|
||||
setDrawerExpanded(true);
|
||||
};
|
||||
|
||||
filterOpenButtons.forEach((button) => button.addEventListener('click', openFilterDrawer));
|
||||
filterCloseButtons.forEach((button) => button.addEventListener('click', closeFilterDrawer));
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (!drawerMediaQuery.matches) {
|
||||
closeFilterDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeFilterDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
if (drawerMediaQuery.matches) {
|
||||
closeFilterDrawer();
|
||||
} else if (filterDrawer) {
|
||||
filterDrawer.setAttribute('aria-hidden', 'false');
|
||||
setDrawerExpanded(false);
|
||||
}
|
||||
|
||||
if (!countrySelect || !citySelect || citiesTemplate === '') {
|
||||
return;
|
||||
|
||||
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),
|
||||
],
|
||||
];
|
||||
|
||||
@ -4138,3 +4138,308 @@ textarea {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-index-shell .listing-filter-card,
|
||||
.listing-index-shell .listing-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.listing-mobile-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.listing-mobile-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.listing-mobile-filter-button {
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(29, 29, 31, 0.12);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
padding: 0 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.listing-mobile-filter-badge {
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
background: #ff375f;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.listing-mobile-sort-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.listing-mobile-sort-label {
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(29, 29, 31, 0.12);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #4b5563;
|
||||
flex: 1 1 auto;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listing-mobile-sort-select {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
max-width: 8.5rem;
|
||||
}
|
||||
|
||||
.listing-mobile-toolbar-meta {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.listing-sidebar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listing-sidebar-head,
|
||||
.listing-sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.listing-sidebar-close {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body.listing-filters-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.listing-mobile-toolbar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.listing-sidebar {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 130;
|
||||
display: none;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.listing-sidebar.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.listing-sidebar-backdrop {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.34);
|
||||
backdrop-filter: saturate(130%) blur(5px);
|
||||
}
|
||||
|
||||
.listing-sidebar-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 24px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.listing-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.listing-sidebar-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.listing-sidebar .listing-filter-card {
|
||||
box-shadow: none;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body.listing-filters-open {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.listing-sidebar {
|
||||
position: static;
|
||||
display: block;
|
||||
inset: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listing-sidebar-shell {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.oc-nav-wrap {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.oc-nav-main {
|
||||
gap: 8px 8px;
|
||||
}
|
||||
|
||||
.oc-topbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
max-width: 6rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.oc-actions {
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oc-location {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.oc-location-trigger {
|
||||
min-height: 40px;
|
||||
min-width: 44px;
|
||||
padding: 0 10px;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oc-location-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.oc-location-trigger svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.oc-account-trigger {
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.oc-account-name {
|
||||
max-width: 4.6rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.oc-cta {
|
||||
min-height: 40px;
|
||||
padding: 0 13px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.oc-search {
|
||||
min-height: 46px;
|
||||
padding: 0 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.oc-nav-main {
|
||||
gap: 6px 6px;
|
||||
}
|
||||
|
||||
.oc-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-utility {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
flex-basis: 2.35rem;
|
||||
}
|
||||
|
||||
.oc-location-trigger {
|
||||
min-width: 40px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.oc-account-trigger {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.oc-account-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.oc-account-name {
|
||||
max-width: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand-text {
|
||||
max-width: 5.25rem;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.oc-account-name {
|
||||
max-width: 3.8rem;
|
||||
}
|
||||
|
||||
.oc-cta {
|
||||
padding: 0 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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