Compare commits

...

2 Commits

Author SHA1 Message Date
fatihalp
6ea371e372 Fix duplicate listing images 2026-03-10 04:35:25 +03:00
fatihalp
f8c953d37c Add Turnstile protection demo 2026-03-10 04:12:59 +03:00
18 changed files with 845 additions and 68 deletions

View File

@ -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

View File

@ -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();

View 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', ''));
}
}

View File

@ -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');
}); });

View File

@ -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,
]; ];
} }

View File

@ -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;
} }

View File

@ -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 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
{ {
$extension = strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION)); $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 private static function resolvePathsForSlug(string $slug): Collection

View File

@ -21,55 +21,70 @@
'search' => $search !== '' ? $search : null, 'search' => $search !== '' ? $search : null,
'user' => $sellerUserId ?? null, 'user' => $sellerUserId ?? null,
], $normalizeQuery); ], $normalizeQuery);
$activeFilterCount = collect([
$categoryId,
$countryId,
$cityId,
$sellerUserId,
$minPriceInput !== '' ? $minPriceInput : null,
$maxPriceInput !== '' ? $maxPriceInput : null,
$dateFilter !== 'all' ? $dateFilter : null,
])->filter($normalizeQuery)->count();
@endphp @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> <h1 class="sr-only">{{ $seoHeading }}</h1>
<div class="grid grid-cols-1 lg:grid-cols-[260px,1fr] gap-4 lg:gap-5"> <div class="grid grid-cols-1 lg:grid-cols-[260px,1fr] gap-4 lg:gap-5">
<aside class="space-y-4"> <aside class="listing-sidebar" data-listing-filter-drawer aria-hidden="false">
<section class="listing-filter-card p-4"> <button type="button" class="listing-sidebar-backdrop lg:hidden" data-listing-filter-close aria-label="Close filters"></button>
<div class="flex items-center justify-between gap-3 mb-3"> <div class="listing-sidebar-shell space-y-4">
<h2 class="text-2xl font-bold text-slate-900 leading-none">Categories</h2> <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> </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"> <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)
@php @php
$categoryCount = (int) $category->active_listing_total; $allCategoriesLink = route('listings.index', $baseCategoryQuery);
$isSelectedParent = (int) $categoryId === (int) $category->id;
$categoryUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [
'category' => $category->id,
]), $normalizeQuery));
@endphp @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' }}"> <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>{{ $category->name }}</span> <span>All Listings</span>
<span>{{ number_format($categoryCount) }}</span> <span>{{ number_format($allListingsCount) }}</span>
</a> </a>
@foreach($category->children as $childCategory) @foreach($categories as $category)
@php @php
$isSelectedChild = (int) $categoryId === (int) $childCategory->id; $categoryCount = (int) $category->active_listing_total;
$childUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [ $isSelectedParent = (int) $categoryId === (int) $category->id;
'category' => $childCategory->id, $categoryUrl = route('listings.index', array_filter(array_merge($baseCategoryQuery, [
'category' => $category->id,
]), $normalizeQuery)); ]), $normalizeQuery));
@endphp @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' }}"> <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>{{ $childCategory->name }}</span> <span>{{ $category->name }}</span>
<span>{{ number_format((int) $childCategory->active_listing_total) }}</span> <span>{{ number_format($categoryCount) }}</span>
</a> </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
@endforeach </div>
</div> </section>
</section>
<form method="GET" action="{{ route('listings.index') }}" class="listing-filter-card p-4 space-y-5"> <form method="GET" action="{{ route('listings.index') }}" class="listing-filter-card p-4 space-y-5">
@if($search !== '') @if($search !== '')
@ -159,10 +174,62 @@
</button> </button>
</div> </div>
</form> </form>
</div>
</aside> </aside>
<section class="space-y-4"> <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"> <p class="text-sm text-slate-700 mr-auto">
<strong>{{ number_format($resultListingsCount) }}</strong> <strong>{{ number_format($resultListingsCount) }}</strong>
{{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }} {{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }}
@ -228,7 +295,7 @@
No listings match this filter. No listings match this filter.
</div> </div>
@else @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) @foreach($listings as $listing)
@php @php
$listingImage = $listing->primaryImageData('card'); $listingImage = $listing->primaryImageData('card');
@ -241,7 +308,7 @@
$locationText = implode(', ', $locationParts); $locationText = implode(', ', $locationParts);
@endphp @endphp
<article class="listing-card"> <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) @if($listingImage)
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full"> <a href="{{ route('listings.show', $listing) }}" class="block w-full h-full">
@include('listing::partials.responsive-image', [ @include('listing::partials.responsive-image', [
@ -282,7 +349,7 @@
<div class="px-3.5 py-3"> <div class="px-3.5 py-3">
<a href="{{ route('listings.show', $listing) }}" class="block"> <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) @if(!is_null($priceValue) && $priceValue > 0)
{{ number_format($priceValue, 0) }} {{ $listing->currency }} {{ number_format($priceValue, 0) }} {{ $listing->currency }}
@else @else
@ -320,8 +387,60 @@
const countrySelect = document.querySelector('[data-listing-country]'); const countrySelect = document.querySelector('[data-listing-country]');
const citySelect = document.querySelector('[data-listing-city]'); const citySelect = document.querySelector('[data-listing-city]');
const currentLocationButton = document.querySelector('[data-use-current-location]'); 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 citiesTemplate = countrySelect?.dataset.citiesUrlTemplate ?? '';
const locationStorageKey = 'oc2.header.location'; 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 === '') { if (!countrySelect || !citySelect || citiesTemplate === '') {
return; return;

View File

@ -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">

View File

@ -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),
],
]; ];

View File

@ -4138,3 +4138,308 @@ textarea {
align-items: flex-start; 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;
}
}

View File

@ -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

View File

@ -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