Demo hazırla sayfa ekle

This commit is contained in:
fatihalp 2026-03-07 19:29:55 +03:00
parent 93ce5a0925
commit d5f88c79af
46 changed files with 1709 additions and 391 deletions

View File

@ -34,7 +34,10 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
FILESYSTEM_DISK=s3
MEDIA_DISK=s3
LOCAL_MEDIA_DISK=public
CLOUD_MEDIA_DISK=s3
QUEUE_CONNECTION=database
CACHE_STORE=database
@ -53,9 +56,21 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@openclassify.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=2OR5YLAPW0UN0O45A20L
AWS_SECRET_ACCESS_KEY=bceTprhHHl3GZFMq9ii80hjvN6JRppQzA4JsFCCr
AWS_DEFAULT_REGION=hel1
AWS_BUCKET=
AWS_URL=
AWS_ENDPOINT=https://hel1.your-objectstorage.com
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
OPENAI_API_KEY=
GEMINI_API_KEY=
QUICK_LISTING_AI_PROVIDER=openai
QUICK_LISTING_AI_MODEL=gpt-5.2
DEMO=0
DEMO_TTL_MINUTES=360

View File

@ -2,19 +2,23 @@
namespace Modules\Admin\Filament\Pages;
use App\Support\HomeSlideDefaults;
use App\Support\CountryCodeManager;
use App\Settings\GeneralSettings;
use App\Support\CountryCodeManager;
use App\Support\HomeSlideDefaults;
use BackedEnum;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\S3\Support\MediaStorage;
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
@ -40,8 +44,15 @@ class ManageGeneralSettings extends SettingsPage
return [
'site_name' => filled($data['site_name'] ?? null) ? $data['site_name'] : $defaults['site_name'],
'site_description' => filled($data['site_description'] ?? null) ? $data['site_description'] : $defaults['site_description'],
'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $defaults['home_slides']),
'media_disk' => MediaStorage::normalizeDriver($data['media_disk'] ?? $defaults['media_disk']),
'home_slides' => $this->normalizeHomeSlides(
$data['home_slides'] ?? $defaults['home_slides'],
MediaStorage::storedDisk('public'),
),
'site_logo' => $data['site_logo'] ?? null,
'site_logo_disk' => filled($data['site_logo'] ?? null)
? MediaStorage::storedDisk($data['site_logo_disk'] ?? 'public')
: null,
'sender_name' => filled($data['sender_name'] ?? null) ? $data['sender_name'] : $defaults['sender_name'],
'sender_email' => filled($data['sender_email'] ?? null) ? $data['sender_email'] : $defaults['sender_email'],
'default_language' => filled($data['default_language'] ?? null) ? $data['default_language'] : $defaults['default_language'],
@ -64,6 +75,21 @@ class ManageGeneralSettings extends SettingsPage
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$mediaDriver = MediaStorage::normalizeDriver($data['media_disk'] ?? null);
$mediaDisk = MediaStorage::diskFromDriver($mediaDriver);
$data['media_disk'] = $mediaDriver;
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], $mediaDisk);
$data['site_logo_disk'] = MediaStorage::managesPath($data['site_logo'] ?? null)
? MediaStorage::storedDisk($data['site_logo_disk'] ?? null, $mediaDriver)
: null;
$data['currencies'] = $this->normalizeCurrencies($data['currencies'] ?? []);
return $data;
}
public function form(Schema $schema): Schema
{
$defaults = $this->defaultFormData();
@ -80,16 +106,32 @@ class ManageGeneralSettings extends SettingsPage
->default($defaults['site_description'])
->rows(3)
->maxLength(500),
Select::make('media_disk')
->label('Medya Depolama')
->options(MediaStorage::options())
->default($defaults['media_disk'])
->required()
->native(false)
->helperText('İlan resimleri, videolar, logo ve slide görselleri için kullanılacak depolama sürücüsü.'),
HomeSlideFormSchema::make(
$defaults['home_slides'],
fn ($state): array => $this->normalizeHomeSlides($state),
fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()),
),
Hidden::make('site_logo_disk'),
FileUpload::make('site_logo')
->label('Site Logosu')
->image()
->disk('public')
->disk(fn (Get $get): string => MediaStorage::storedDisk($get('site_logo_disk'), $get('media_disk')))
->directory('settings')
->visibility('public'),
->visibility('public')
->afterStateUpdated(function (Get $get, Set $set, mixed $state): void {
$set(
'site_logo_disk',
MediaStorage::managesPath($state)
? MediaStorage::diskFromDriver($get('media_disk'))
: null,
);
}),
TextInput::make('sender_name')
->label('Gönderici Adı')
->default($defaults['sender_name'])
@ -200,7 +242,9 @@ class ManageGeneralSettings extends SettingsPage
return [
'site_name' => $siteName,
'site_description' => 'Alim satim icin hizli ve guvenli ilan platformu.',
'media_disk' => MediaStorage::defaultDriver(),
'home_slides' => $this->defaultHomeSlides(),
'site_logo_disk' => null,
'sender_name' => $siteName,
'sender_email' => (string) config('mail.from.address', 'info@' . $siteHost),
'default_language' => in_array(config('app.locale'), array_keys($this->localeOptions()), true) ? (string) config('app.locale') : 'tr',
@ -248,8 +292,8 @@ class ManageGeneralSettings extends SettingsPage
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state): array
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
{
return HomeSlideDefaults::normalize($state);
return HomeSlideDefaults::normalize($state, $defaultDisk);
}
}

View File

@ -9,6 +9,7 @@ use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\S3\Support\MediaStorage;
use UnitEnum;
class ManageHomeSlides extends SettingsPage
@ -30,17 +31,27 @@ class ManageHomeSlides extends SettingsPage
protected function mutateFormDataBeforeFill(array $data): array
{
return [
'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $this->defaultHomeSlides()),
'home_slides' => $this->normalizeHomeSlides(
$data['home_slides'] ?? $this->defaultHomeSlides(),
MediaStorage::storedDisk('public'),
),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], MediaStorage::activeDisk());
return $data;
}
public function form(Schema $schema): Schema
{
return $schema
->components([
HomeSlideFormSchema::make(
$this->defaultHomeSlides(),
fn ($state): array => $this->normalizeHomeSlides($state),
fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()),
),
]);
}
@ -50,8 +61,8 @@ class ManageHomeSlides extends SettingsPage
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state): array
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
{
return HomeSlideDefaults::normalize($state);
return HomeSlideDefaults::normalize($state, $defaultDisk);
}
}

View File

@ -1,6 +1,7 @@
<?php
namespace Modules\Admin\Providers;
use App\Http\Middleware\BootstrapAppData;
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
use Filament\Http\Middleware\Authenticate;
@ -22,6 +23,7 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
use Jeffgreco13\FilamentBreezy\BreezyCore;
use MWGuerra\FileManager\FileManagerPlugin;
use MWGuerra\FileManager\Filament\Pages\FileManager;
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Admin\Filament\Resources\ListingResource;
use Modules\Admin\Filament\Resources\LocationResource;
@ -74,6 +76,8 @@ class AdminPanelProvider extends PanelProvider
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ResolveDemoRequest::class,
BootstrapAppData::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,

View File

@ -3,9 +3,13 @@
namespace Modules\Admin\Support;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Modules\S3\Support\MediaStorage;
final class HomeSlideFormSchema
{
@ -15,15 +19,24 @@ final class HomeSlideFormSchema
->label('Homepage Slides')
->helperText('Use 1 to 5 slides. Upload a wide image for each slide to improve the hero area.')
->schema([
Hidden::make('disk'),
FileUpload::make('image_path')
->label('Slide Image')
->image()
->disk('public')
->disk(fn (Get $get): string => MediaStorage::storedDisk($get('disk'), self::mediaDriver($get)))
->directory('home-slides')
->visibility('public')
->imageEditor()
->imagePreviewHeight('200')
->helperText('Recommended: 1600x1000 or wider.')
->afterStateUpdated(function (Get $get, Set $set, mixed $state): void {
$set(
'disk',
MediaStorage::managesPath($state)
? MediaStorage::diskFromDriver(self::mediaDriver($get))
: null,
);
})
->columnSpanFull(),
TextInput::make('badge')
->label('Badge')
@ -59,4 +72,13 @@ final class HomeSlideFormSchema
->itemLabel(fn (array $state): string => filled($state['title'] ?? null) ? (string) $state['title'] : 'New Slide')
->dehydrateStateUsing(fn ($state) => $normalizeSlides($state));
}
private static function mediaDriver(Get $get): string
{
$driver = $get('../../media_disk');
return is_string($driver) && trim($driver) !== ''
? MediaStorage::normalizeDriver($driver)
: MediaStorage::activeDriver();
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Modules\Demo\App\Console;
use Illuminate\Console\Command;
use Modules\Demo\App\Support\DemoSchemaManager;
use Throwable;
class CleanupDemoCommand extends Command
{
protected $signature = 'demo:cleanup';
protected $description = 'Delete expired temporary demo schemas';
public function handle(DemoSchemaManager $demoSchemaManager): int
{
try {
$deletedCount = $demoSchemaManager->cleanupExpired();
$this->info("Expired demos removed: {$deletedCount}");
return self::SUCCESS;
} catch (Throwable $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
}
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\Demo\App\Console;
use Illuminate\Console\Command;
use Modules\Demo\App\Support\DemoSchemaManager;
use Throwable;
class PrepareDemoCommand extends Command
{
protected $signature = 'demo:prepare {uuid?}';
protected $description = 'Prepare or refresh a temporary demo schema';
public function handle(DemoSchemaManager $demoSchemaManager): int
{
try {
$instance = $demoSchemaManager->prepare($this->argument('uuid'));
$this->info('Demo prepared.');
$this->line('UUID: '.$instance->uuid);
$this->line('Schema: '.$instance->schema_name);
$this->line('Expires: '.$instance->expires_at?->toDateTimeString());
return self::SUCCESS;
} catch (Throwable $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
}
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace Modules\Demo\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Modules\Demo\App\Support\DemoSchemaManager;
use Throwable;
class DemoController extends Controller
{
public function prepare(Request $request, DemoSchemaManager $demoSchemaManager): RedirectResponse
{
abort_unless(config('demo.enabled'), 404);
$cookieName = (string) config('demo.cookie_name', 'oc2_demo');
$redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to'))
?? route('home');
try {
$instance = $demoSchemaManager->prepare($request->cookie($cookieName));
$user = $demoSchemaManager->resolveLoginUser();
Auth::guard('web')->login($user);
$request->session()->regenerate();
$request->session()->put([
'demo_uuid' => $instance->uuid,
'is_demo_session' => true,
'demo_expires_at' => $instance->expires_at?->toIso8601String(),
]);
Cookie::queue(cookie(
$cookieName,
$instance->uuid,
(int) config('demo.ttl_minutes', 360),
));
return redirect()->to($redirectTo)->with('success', 'Your private demo is ready.');
} catch (Throwable $exception) {
report($exception);
Auth::guard('web')->logout();
$request->session()->forget([
'demo_uuid',
'is_demo_session',
]);
Cookie::queue(Cookie::forget($cookieName));
$demoSchemaManager->activatePublic();
return redirect()->to($redirectTo)->with('error', 'Demo could not be prepared right now.');
}
}
private function sanitizeRedirectTarget(?string $target): ?string
{
$target = trim((string) $target);
if ($target === '' || str_starts_with($target, '//')) {
return null;
}
if (str_starts_with($target, '/')) {
return $target;
}
if (! filter_var($target, FILTER_VALIDATE_URL)) {
return null;
}
$applicationUrl = parse_url(url('/'));
$targetUrl = parse_url($target);
if (($applicationUrl['host'] ?? null) !== ($targetUrl['host'] ?? null)) {
return null;
}
$path = $targetUrl['path'] ?? '/';
$query = isset($targetUrl['query']) ? '?'.$targetUrl['query'] : '';
$fragment = isset($targetUrl['fragment']) ? '#'.$targetUrl['fragment'] : '';
return $path.$query.$fragment;
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace Modules\Demo\App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Modules\Demo\App\Support\DemoSchemaManager;
class ResolveDemoRequest
{
public function __construct(private readonly DemoSchemaManager $demoSchemaManager)
{
}
public function handle(Request $request, Closure $next)
{
if (! $this->demoSchemaManager->enabled()) {
return $next($request);
}
$cookieName = (string) config('demo.cookie_name', 'oc2_demo');
$demoUuid = $request->cookie($cookieName);
$instance = $this->demoSchemaManager->findActiveInstance($demoUuid);
$shouldForgetCookie = filled($demoUuid) && ! $instance;
if (! $instance) {
$this->resetDemoSession($request);
$this->demoSchemaManager->activatePublic();
$response = $next($request);
if ($shouldForgetCookie) {
Cookie::queue(Cookie::forget($cookieName));
}
return $response;
}
if (! (bool) $request->session()->get('is_demo_session') && $this->hasAuthSession($request)) {
Auth::guard('web')->logout();
}
$this->demoSchemaManager->activateDemo($instance);
$request->session()->put([
'demo_uuid' => $instance->uuid,
'is_demo_session' => true,
'demo_expires_at' => $instance->expires_at?->toIso8601String(),
]);
return $next($request);
}
private function resetDemoSession(Request $request): void
{
if (! $request->session()->has('demo_uuid') && ! (bool) $request->session()->get('is_demo_session')) {
return;
}
if ($this->hasAuthSession($request)) {
Auth::guard('web')->logout();
}
$request->session()->forget([
'demo_uuid',
'is_demo_session',
'demo_expires_at',
]);
}
private function hasAuthSession(Request $request): bool
{
return filled($request->session()->get(Auth::guard('web')->getName()));
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\Demo\App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class DemoInstance extends Model
{
protected $connection = 'pgsql_public';
protected $fillable = [
'uuid',
'schema_name',
'prepared_at',
'expires_at',
];
protected function casts(): array
{
return [
'prepared_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function scopeActiveUuid(Builder $query, string $uuid): Builder
{
return $query
->where('uuid', $uuid)
->where('expires_at', '>', now());
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\Demo\App\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Demo\App\Console\CleanupDemoCommand;
use Modules\Demo\App\Console\PrepareDemoCommand;
use Modules\Demo\App\Support\DemoSchemaManager;
use RuntimeException;
class DemoServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(DemoSchemaManager::class);
if ($this->app->runningInConsole()) {
$this->commands([
PrepareDemoCommand::class,
CleanupDemoCommand::class,
]);
}
}
public function boot(): void
{
$this->guardConfiguration();
$this->loadMigrationsFrom(module_path('Demo', 'database/migrations'));
$this->loadRoutesFrom(module_path('Demo', 'routes/web.php'));
}
private function guardConfiguration(): void
{
if (! config('demo.enabled')) {
return;
}
if (config('database.default') !== 'pgsql') {
throw new RuntimeException('Demo mode requires DB_CONNECTION=pgsql.');
}
}
}

View File

@ -0,0 +1,276 @@
<?php
namespace Modules\Demo\App\Support;
use App\Settings\GeneralSettings;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Modules\Demo\App\Models\DemoInstance;
use Modules\User\App\Models\User;
use Spatie\Permission\PermissionRegistrar;
use Throwable;
final class DemoSchemaManager
{
private readonly string $defaultConnection;
private readonly string $publicConnection;
private readonly string $baseCachePrefix;
private readonly string $basePermissionCacheKey;
public function __construct(private readonly Application $app)
{
$this->defaultConnection = (string) config('database.default', 'pgsql');
$this->publicConnection = 'pgsql_public';
$this->baseCachePrefix = (string) config('cache.prefix', '');
$this->basePermissionCacheKey = (string) config('permission.cache.key', 'spatie.permission.cache');
}
public function enabled(): bool
{
return (bool) config('demo.enabled');
}
public function prepare(?string $uuid = null): DemoInstance
{
$this->ensureEnabled();
$this->cleanupExpired();
$uuid = $this->normalizeUuid($uuid);
if ($uuid) {
$instance = $this->findActiveInstance($uuid);
if ($instance) {
return $this->reuse($instance);
}
}
return $this->createFresh($uuid ?? (string) str()->uuid());
}
public function findActiveInstance(?string $uuid): ?DemoInstance
{
$uuid = $this->normalizeUuid($uuid);
if (! $uuid) {
return null;
}
return DemoInstance::query()->activeUuid($uuid)->first();
}
public function activateDemo(DemoInstance $instance): void
{
$this->activateSchema($instance->schema_name, $instance->uuid);
$this->ensureLoginUserExists();
}
public function activatePublic(): void
{
if (! $this->enabled()) {
return;
}
$this->activateSchema((string) config('demo.public_schema', 'public'));
}
public function cleanupExpired(): int
{
if (! $this->enabled()) {
return 0;
}
$expired = DemoInstance::query()
->where('expires_at', '<=', now())
->get();
foreach ($expired as $instance) {
$this->dropSchema($instance->schema_name);
$instance->delete();
}
return $expired->count();
}
public function resolveLoginUser(): User
{
$user = User::query()
->where('email', (string) config('demo.login_email', 'a@a.com'))
->first();
if (! $user) {
throw new \RuntimeException('The seeded demo login user could not be found.');
}
return $user;
}
public function clearDemoArtifacts(?string $uuid): void
{
$uuid = $this->normalizeUuid($uuid);
if (! $uuid) {
return;
}
$instance = DemoInstance::query()->where('uuid', $uuid)->first();
if ($instance) {
$this->dropSchema($instance->schema_name);
$instance->delete();
return;
}
$this->dropSchema($this->schemaNameFor($uuid));
}
private function reuse(DemoInstance $instance): DemoInstance
{
$instance->forceFill([
'expires_at' => now()->addMinutes((int) config('demo.ttl_minutes', 360)),
])->save();
$this->activateDemo($instance);
return $instance->fresh() ?? $instance;
}
private function createFresh(string $uuid): DemoInstance
{
$schema = $this->schemaNameFor($uuid);
try {
$this->createSchema($schema);
$this->activateSchema($schema, $uuid);
$this->runProvisioningCommands();
$this->ensureLoginUserExists();
return DemoInstance::query()->updateOrCreate(
['uuid' => $uuid],
[
'schema_name' => $schema,
'prepared_at' => now(),
'expires_at' => now()->addMinutes((int) config('demo.ttl_minutes', 360)),
],
);
} catch (Throwable $exception) {
$this->dropSchema($schema);
DemoInstance::query()->where('uuid', $uuid)->delete();
$this->activatePublic();
throw $exception;
}
}
private function runProvisioningCommands(): void
{
config(['demo.provisioning' => true]);
try {
Artisan::call('migrate', [
'--database' => $this->defaultConnection,
'--force' => true,
]);
Artisan::call('db:seed', [
'--class' => \Database\Seeders\DatabaseSeeder::class,
'--database' => $this->defaultConnection,
'--force' => true,
]);
} finally {
config(['demo.provisioning' => false]);
}
}
private function createSchema(string $schema): void
{
DB::connection($this->publicConnection)->statement(
sprintf('CREATE SCHEMA IF NOT EXISTS %s', $this->quoteIdentifier($schema))
);
}
private function dropSchema(string $schema): void
{
DB::connection($this->publicConnection)->statement(
sprintf('DROP SCHEMA IF EXISTS %s CASCADE', $this->quoteIdentifier($schema))
);
}
private function ensureEnabled(): void
{
if (! $this->enabled()) {
throw new \RuntimeException('Demo mode is disabled.');
}
}
private function activateSchema(string $schema, ?string $uuid = null): void
{
config([
"database.connections.{$this->defaultConnection}.search_path" => $schema,
'cache.prefix' => $uuid
? $this->baseCachePrefix.'demo-'.$uuid.'-'
: $this->baseCachePrefix,
'permission.cache.key' => $uuid
? $this->basePermissionCacheKey.'.'.$uuid
: $this->basePermissionCacheKey,
]);
DB::purge($this->defaultConnection);
DB::reconnect($this->defaultConnection);
if ($this->app->resolved(GeneralSettings::class)) {
$this->app->forgetInstance(GeneralSettings::class);
}
if ($this->app->resolved('cache') && method_exists($this->app['cache'], 'forgetDriver')) {
$this->app['cache']->forgetDriver(config('cache.default'));
}
if ($this->app->resolved(PermissionRegistrar::class)) {
$permissionRegistrar = $this->app->make(PermissionRegistrar::class);
$permissionRegistrar->initializeCache();
$permissionRegistrar->clearPermissionsCollection();
}
}
private function ensureLoginUserExists(): void
{
$this->resolveLoginUser();
}
private function normalizeUuid(?string $uuid): ?string
{
$uuid = trim((string) $uuid);
if ($uuid === '' || ! preg_match('/^[a-f0-9-]{36}$/i', $uuid)) {
return null;
}
return strtolower($uuid);
}
private function schemaNameFor(string $uuid): string
{
$prefix = strtolower((string) config('demo.schema_prefix', 'demo_'));
$prefix = preg_replace('/[^a-z0-9_]+/', '_', $prefix) ?? 'demo_';
$prefix = trim($prefix, '_');
$prefix = $prefix !== '' ? $prefix.'_' : 'demo_';
$suffix = str_replace('-', '', strtolower($uuid));
return substr($prefix.$suffix, 0, 63);
}
private function quoteIdentifier(string $identifier): string
{
if (! preg_match('/^[a-z0-9_]+$/', $identifier)) {
throw new \RuntimeException('Invalid demo schema identifier.');
}
return '"'.$identifier.'"';
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Modules\Demo\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Modules\User\App\Models\User;
use Spatie\Permission\Models\Role;
class DemoContentSeeder extends Seeder
{
public function run(): void
{
$admin = User::query()->updateOrCreate(
['email' => 'a@a.com'],
[
'name' => 'Admin',
'password' => Hash::make('236330'),
'status' => 'active',
],
);
$partner = User::query()->updateOrCreate(
['email' => 'b@b.com'],
[
'name' => 'Partner',
'password' => Hash::make('36330'),
'status' => 'active',
],
);
if (class_exists(Role::class)) {
$adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$partnerRole = Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']);
$admin->syncRoles([$adminRole->name]);
$partner->syncRoles([$partnerRole->name]);
}
$this->call([
\Modules\Listing\Database\Seeders\ListingSeeder::class,
\Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class,
\Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class,
\Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class,
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('demo_instances', function (Blueprint $table): void {
$table->id();
$table->uuid('uuid')->unique();
$table->string('schema_name', 63)->unique();
$table->timestamp('prepared_at');
$table->timestamp('expires_at')->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('demo_instances');
}
};

11
Modules/Demo/module.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "Demo",
"alias": "demo",
"description": "Temporary per-visitor demo schemas",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Demo\\App\\Providers\\DemoServiceProvider"
],
"files": []
}

View File

@ -0,0 +1,8 @@
<?php
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');
});

View File

@ -1,6 +1,7 @@
<?php
namespace Modules\Partner\Providers;
use App\Http\Middleware\BootstrapAppData;
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
use Modules\User\App\Models\User;
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
@ -24,6 +25,7 @@ use Illuminate\Support\Str;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Jeffgreco13\FilamentBreezy\BreezyCore;
use Laravel\Socialite\Contracts\User as SocialiteUserContract;
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
use Modules\Partner\Support\Filament\SocialiteProviderResolver;
use Spatie\Permission\Models\Role;
@ -67,6 +69,8 @@ class PartnerPanelProvider extends PanelProvider
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ResolveDemoRequest::class,
BootstrapAppData::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,

View File

@ -0,0 +1,13 @@
<?php
namespace Modules\S3\Providers;
use Illuminate\Support\ServiceProvider;
class S3ServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(module_path('S3', 'config/s3.php'), 'media_storage');
}
}

View File

@ -0,0 +1,147 @@
<?php
namespace Modules\S3\Support;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class MediaStorage
{
public const DRIVER_LOCAL = 'local';
public const DRIVER_S3 = 's3';
public static function options(): array
{
return [
self::DRIVER_S3 => 'S3 Object Storage',
self::DRIVER_LOCAL => 'Local Storage',
];
}
public static function defaultDriver(): string
{
return self::coerceDriver(config('media_storage.default_driver'))
?? self::coerceDriver(env('MEDIA_DISK'))
?? self::coerceDriver(env('FILESYSTEM_DISK'))
?? self::DRIVER_S3;
}
public static function activeDriver(): string
{
if (! self::hasSettingsTable()) {
return self::defaultDriver();
}
try {
return self::normalizeDriver(app(GeneralSettings::class)->media_disk ?? null);
} catch (Throwable) {
return self::defaultDriver();
}
}
public static function normalizeDriver(mixed $driver): string
{
return self::coerceDriver($driver) ?? self::defaultDriver();
}
public static function diskFromDriver(mixed $driver = null): string
{
return self::normalizeDriver($driver) === self::DRIVER_LOCAL
? (string) config('media_storage.local_disk', 'public')
: (string) config('media_storage.cloud_disk', 's3');
}
public static function activeDisk(): string
{
return self::diskFromDriver(self::activeDriver());
}
public static function storedDisk(mixed $disk = null, mixed $driver = null): string
{
if (is_string($disk) && trim($disk) !== '') {
return self::diskFromDriver(trim($disk) === 'public' ? self::DRIVER_LOCAL : trim($disk));
}
return self::diskFromDriver($driver);
}
public static function managesPath(mixed $path): bool
{
$path = is_string($path) ? trim($path) : '';
if ($path === '') {
return false;
}
return ! self::isExternalUrl($path) && ! self::isAssetPath($path);
}
public static function url(mixed $path, mixed $disk = null): ?string
{
$path = is_string($path) ? trim($path) : '';
if ($path === '') {
return null;
}
if (self::isExternalUrl($path)) {
return $path;
}
if (self::isAssetPath($path)) {
return asset($path);
}
return Storage::disk(self::storedDisk($disk))->url($path);
}
public static function applyRuntimeConfig(): void
{
$disk = self::activeDisk();
config([
'filesystems.default' => $disk,
'filemanager.disk' => env('FILEMANAGER_DISK', $disk),
'filament.default_filesystem_disk' => $disk,
'media-library.disk_name' => $disk,
'video.disk' => $disk,
]);
}
private static function coerceDriver(mixed $driver): ?string
{
if (! is_string($driver)) {
return null;
}
return match (strtolower(trim($driver))) {
self::DRIVER_LOCAL, 'public' => self::DRIVER_LOCAL,
self::DRIVER_S3 => self::DRIVER_S3,
default => null,
};
}
private static function hasSettingsTable(): bool
{
try {
return Schema::hasTable('settings');
} catch (Throwable) {
return false;
}
}
private static function isAssetPath(string $path): bool
{
return str_starts_with($path, 'images/');
}
private static function isExternalUrl(string $path): bool
{
return str_starts_with($path, 'http://')
|| str_starts_with($path, 'https://')
|| str_starts_with($path, '//');
}
}

7
Modules/S3/config/s3.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'default_driver' => env('MEDIA_DISK', env('FILESYSTEM_DISK', 's3')),
'local_disk' => env('LOCAL_MEDIA_DISK', 'public'),
'cloud_disk' => env('CLOUD_MEDIA_DISK', 's3'),
];

12
Modules/S3/module.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "S3",
"alias": "s3",
"description": "Media storage selection and S3 object storage integration",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\S3\\Providers\\S3ServiceProvider"
],
"aliases": {},
"files": []
}

View File

@ -19,6 +19,7 @@ use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\S3\Support\MediaStorage;
use Modules\User\App\States\UserStatus;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -133,7 +134,27 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
public function getFilamentAvatarUrl(): ?string
{
return filled($this->avatar_url) ? Storage::disk('public')->url($this->avatar_url) : null;
if (! filled($this->avatar_url)) {
return null;
}
$path = trim((string) $this->avatar_url);
if (! MediaStorage::managesPath($path)) {
return MediaStorage::url($path);
}
$activeDisk = MediaStorage::activeDisk();
if (Storage::disk($activeDisk)->exists($path)) {
return Storage::disk($activeDisk)->url($path);
}
if ($activeDisk !== 'public' && Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
return MediaStorage::url($path, $activeDisk);
}
public function getDisplayName(): string

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Modules\Listing\Models\Listing;
use Modules\S3\Support\MediaStorage;
use Modules\User\App\Models\User;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Jobs\ProcessVideo;
@ -87,7 +88,7 @@ class Video extends Model
public static function createFromTemporaryUpload(Listing $listing, TemporaryUploadedFile $file, array $attributes = []): self
{
$disk = (string) config('video.disk', 'public');
$disk = (string) config('video.disk', MediaStorage::activeDisk());
$path = $file->storeAs(
trim((string) config('video.upload_directory', 'videos/uploads').'/'.$listing->getKey(), '/'),
Str::ulid().'.'.($file->getClientOriginalExtension() ?: $file->guessExtension() ?: 'mp4'),
@ -133,9 +134,9 @@ class Video extends Model
$uploadPath = $this->upload_path;
$this->forceFill([
'disk' => $attributes['disk'] ?? (string) config('video.disk', 'public'),
'disk' => $attributes['disk'] ?? (string) config('video.disk', MediaStorage::activeDisk()),
'path' => $attributes['path'] ?? null,
'upload_disk' => (string) config('video.disk', 'public'),
'upload_disk' => (string) config('video.disk', MediaStorage::activeDisk()),
'upload_path' => null,
'mime_type' => $attributes['mime_type'] ?? 'video/mp4',
'size' => $attributes['size'] ?? null,
@ -184,14 +185,14 @@ class Video extends Model
$status = $this->currentStatus();
if (($status !== VideoStatus::Ready) && filled($this->upload_path)) {
return (string) ($this->upload_disk ?: config('video.disk', 'public'));
return (string) ($this->upload_disk ?: config('video.disk', MediaStorage::activeDisk()));
}
if (filled($this->path)) {
return (string) ($this->disk ?: config('video.disk', 'public'));
return (string) ($this->disk ?: config('video.disk', MediaStorage::activeDisk()));
}
return (string) ($this->upload_disk ?: config('video.disk', 'public'));
return (string) ($this->upload_disk ?: config('video.disk', MediaStorage::activeDisk()));
}
public function playableUrl(): ?string
@ -293,7 +294,7 @@ class Video extends Model
$this->previousUploadDisk = filled($this->getOriginal('upload_disk'))
? (string) $this->getOriginal('upload_disk')
: (string) config('video.disk', 'public');
: (string) config('video.disk', MediaStorage::activeDisk());
$this->previousUploadPath = filled($this->getOriginal('upload_path'))
? (string) $this->getOriginal('upload_path')
@ -318,11 +319,11 @@ class Video extends Model
protected function normalizeStatus(): void
{
if (blank($this->disk)) {
$this->disk = (string) config('video.disk', 'public');
$this->disk = (string) config('video.disk', MediaStorage::activeDisk());
}
if (blank($this->upload_disk)) {
$this->upload_disk = (string) config('video.disk', 'public');
$this->upload_disk = (string) config('video.disk', MediaStorage::activeDisk());
}
if (! $this->isDirty('upload_path')) {

View File

@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Modules\S3\Support\MediaStorage;
use Modules\Video\Models\Video;
class VideoFormSchema
@ -126,7 +127,7 @@ class VideoFormSchema
return FileUpload::make('upload_path')
->label('Source video')
->disk((string) config('video.disk', 'public'))
->disk(fn (?Video $record): string => MediaStorage::storedDisk($record?->upload_disk))
->directory(trim((string) config('video.upload_directory', 'videos/uploads'), '/'))
->visibility('public')
->acceptedFileTypes([
@ -191,7 +192,7 @@ class VideoFormSchema
protected static function normalizeData(array $data): array
{
$data['upload_disk'] = (string) config('video.disk', 'public');
$data['upload_disk'] = (string) config('video.disk', MediaStorage::activeDisk());
if (blank($data['title'] ?? null) && filled($data['upload_path'] ?? null)) {
$data['title'] = str(pathinfo((string) $data['upload_path'], PATHINFO_FILENAME))

View File

@ -3,6 +3,8 @@
namespace Modules\Video\Support;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\S3\Support\MediaStorage;
use Modules\Video\Models\Video;
use RuntimeException;
use Symfony\Component\Process\Process;
@ -11,87 +13,137 @@ class VideoTranscoder
{
public function transcode(Video $video): array
{
$disk = (string) config('video.disk', 'public');
$disk = (string) config('video.disk', MediaStorage::activeDisk());
$inputDisk = Storage::disk((string) ($video->upload_disk ?: $disk));
$outputDisk = Storage::disk($disk);
$inputPath = $inputDisk->path((string) $video->upload_path);
$workspace = storage_path('app/private/video-processing/'.Str::uuid());
$inputExtension = pathinfo((string) $video->upload_path, PATHINFO_EXTENSION) ?: 'mp4';
$inputPath = $workspace.'/input.'.$inputExtension;
$outputPath = $workspace.'/output.mp4';
$outputRelativePath = $video->mobileOutputPath();
$outputPath = $outputDisk->path($outputRelativePath);
$outputDirectory = dirname($outputPath);
$inputStream = null;
$outputStream = null;
if (! is_dir($outputDirectory)) {
mkdir($outputDirectory, 0775, true);
if (! is_dir($workspace) && ! mkdir($workspace, 0775, true) && ! is_dir($workspace)) {
throw new RuntimeException('Video processing workspace could not be created.');
}
$process = new Process([
(string) config('video.ffmpeg', 'ffmpeg'),
'-y',
'-i',
$inputPath,
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-vf',
'scale=min('.(int) config('video.mobile_width', 854).'\\,iw):-2',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
(string) config('video.mobile_crf', 31),
'-maxrate',
(string) config('video.mobile_video_bitrate', '900k'),
'-bufsize',
'1800k',
'-movflags',
'+faststart',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
(string) config('video.mobile_audio_bitrate', '96k'),
'-ac',
'2',
$outputPath,
]);
try {
$inputStream = $inputDisk->readStream((string) $video->upload_path);
$process->setTimeout((int) config('video.timeout', 1800));
$process->run();
if (! is_resource($inputStream)) {
throw new RuntimeException('Source video could not be read.');
}
if (! $process->isSuccessful()) {
throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Video transcoding failed.');
$localInputStream = fopen($inputPath, 'wb');
if (! is_resource($localInputStream)) {
throw new RuntimeException('Temporary input video could not be created.');
}
stream_copy_to_stream($inputStream, $localInputStream);
fclose($localInputStream);
$process = new Process([
(string) config('video.ffmpeg', 'ffmpeg'),
'-y',
'-i',
$inputPath,
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-vf',
'scale=min('.(int) config('video.mobile_width', 854).'\\,iw):-2',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
(string) config('video.mobile_crf', 31),
'-maxrate',
(string) config('video.mobile_video_bitrate', '900k'),
'-bufsize',
'1800k',
'-movflags',
'+faststart',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
(string) config('video.mobile_audio_bitrate', '96k'),
'-ac',
'2',
$outputPath,
]);
$process->setTimeout((int) config('video.timeout', 1800));
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Video transcoding failed.');
}
$probe = new Process([
(string) config('video.ffprobe', 'ffprobe'),
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height:format=duration',
'-of',
'json',
$outputPath,
]);
$probe->setTimeout(30);
$probe->run();
$outputStream = fopen($outputPath, 'rb');
if (! is_resource($outputStream)) {
throw new RuntimeException('Processed video could not be opened.');
}
if (! $outputDisk->put($outputRelativePath, $outputStream, ['visibility' => 'public'])) {
throw new RuntimeException('Processed video could not be stored.');
}
$metadata = json_decode($probe->getOutput(), true);
$stream = $metadata['streams'][0] ?? [];
$format = $metadata['format'] ?? [];
return [
'disk' => $disk,
'path' => $outputRelativePath,
'mime_type' => $outputDisk->mimeType($outputRelativePath) ?: 'video/mp4',
'size' => $outputDisk->size($outputRelativePath),
'width' => isset($stream['width']) ? (int) $stream['width'] : null,
'height' => isset($stream['height']) ? (int) $stream['height'] : null,
'duration_seconds' => isset($format['duration']) ? (int) round((float) $format['duration']) : null,
];
} finally {
if (is_resource($inputStream)) {
fclose($inputStream);
}
if (is_resource($outputStream)) {
fclose($outputStream);
}
if (is_file($inputPath)) {
unlink($inputPath);
}
if (is_file($outputPath)) {
unlink($outputPath);
}
if (is_dir($workspace)) {
rmdir($workspace);
}
}
$probe = new Process([
(string) config('video.ffprobe', 'ffprobe'),
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height:format=duration',
'-of',
'json',
$outputPath,
]);
$probe->setTimeout(30);
$probe->run();
$metadata = json_decode($probe->getOutput(), true);
$stream = $metadata['streams'][0] ?? [];
$format = $metadata['format'] ?? [];
return [
'disk' => $disk,
'path' => $outputRelativePath,
'mime_type' => $outputDisk->mimeType($outputRelativePath) ?: 'video/mp4',
'size' => $outputDisk->size($outputRelativePath),
'width' => isset($stream['width']) ? (int) $stream['width'] : null,
'height' => isset($stream['height']) ? (int) $stream['height'] : null,
'duration_seconds' => isset($format['duration']) ? (int) round((float) $format['duration']) : null,
];
}
}

View File

@ -1,7 +1,7 @@
<?php
return [
'disk' => env('VIDEO_DISK', 'public'),
'disk' => env('VIDEO_DISK', env('MEDIA_DISK', env('FILESYSTEM_DISK', 's3'))),
'upload_directory' => env('VIDEO_UPLOAD_DIRECTORY', 'videos/uploads'),
'processed_directory' => env('VIDEO_PROCESSED_DIRECTORY', 'videos/mobile'),
'queue' => env('VIDEO_QUEUE', 'videos'),

View File

@ -10,6 +10,7 @@ A modern classified ads platform built with Laravel 12, FilamentPHP v5, and Lara
- 👤 **User Profiles** — Manage your listings and account
- 🔐 **Admin Panel** — Full control via FilamentPHP v5 at `/admin`
- 🤝 **Partner Panel** — Users manage their own listings at `/partner/{id}` (tenant isolation)
- 🧪 **Demo Mode** — Per-visitor PostgreSQL schema provisioning with seeded data and automatic cleanup
- 🌍 **10 Languages** — English, Turkish, Arabic, German, French, Spanish, Portuguese, Russian, Chinese, Japanese
- 🐳 **Docker Ready** — One-command production and development setup
- ☁️ **GitHub Codespaces** — Zero-config cloud development
@ -31,8 +32,8 @@ Project-level custom instruction set files are available at:
| Modules | nWidart/laravel-modules v11 |
| Auth/Roles | Spatie Laravel Permission |
| Frontend | Blade + TailwindCSS + Vite |
| Database | MySQL / SQLite |
| Cache/Queue | Redis |
| Database | PostgreSQL (required for demo mode), SQLite for minimal local dev |
| Cache/Queue | Database or Redis |
## Quick Start (Docker)
@ -50,12 +51,14 @@ docker compose up -d
# The application will be available at http://localhost:8000
```
### Default Credentials
### Demo Credentials (`DEMO=1` only)
| Role | Email | Password |
|------|-------|----------|
| Admin | admin@openclassify.com | password |
| Partner | partner@openclassify.com | password |
| Admin | a@a.com | 236330 |
| Partner | b@b.com | 36330 |
Demo preparation auto-logs the visitor into the schema-local admin account, so manual login is usually not required.
**Admin Panel:** http://localhost:8000/admin
**Partner Panel:** http://localhost:8000/partner
@ -82,7 +85,7 @@ docker compose -f docker-compose.dev.yml logs -f app
### Option 3: Local (PHP + Node)
**Requirements:** PHP 8.2+, Composer, Node 18+, SQLite or MySQL
**Requirements:** PHP 8.2+, Composer, Node 18+, PostgreSQL for demo mode
```bash
# Install dependencies
@ -102,6 +105,53 @@ php artisan db:seed
composer run dev
```
## Demo Mode
Demo mode is designed for isolated visitor sessions. When enabled, each visitor can provision a private temporary marketplace backed by its own PostgreSQL schema.
### Requirements
- `DB_CONNECTION=pgsql`
- `DEMO=1`
- database-backed session / cache / queue drivers are supported and will stay on the public schema via `pgsql_public`
If `DEMO=1` is set while the app is not using PostgreSQL, the application fails fast during boot.
### Runtime Behavior
- On the first guest homepage visit, the primary visible CTA is a single large `Prepare Demo` button.
- The homepage shows how long the temporary demo will live before automatic deletion.
- Clicking `Prepare Demo` provisions a visitor-specific schema, runs `migrate` and `db:seed`, and logs the visitor into the seeded admin account.
- The same browser reuses its active demo instead of creating duplicate schemas.
- Demo lifetime defaults to `360` minutes from explicit prepare / reopen time.
- Expired demos are removed by `demo:cleanup`, which is scheduled hourly.
### Environment
```env
DB_CONNECTION=pgsql
DEMO=1
DEMO_TTL_MINUTES=360
DEMO_SCHEMA_PREFIX=demo_
DEMO_COOKIE_NAME=oc2_demo
DEMO_LOGIN_EMAIL=a@a.com
DEMO_PUBLIC_SCHEMA=public
```
### Commands
```bash
php artisan migrate --force
php artisan db:seed --force
php artisan demo:prepare
php artisan demo:cleanup
```
### Notes
- `php artisan db:seed` only injects demo-heavy listings, favorites, inbox threads, and demo users when demo mode is enabled.
- Public infrastructure tables such as sessions, cache, jobs, and failed jobs remain on the public schema even while visitor requests are switched into demo schemas.
---
## Architecture

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use App\Support\RequestAppData;
use Closure;
use Illuminate\Http\Request;
class BootstrapAppData
{
public function __construct(private readonly RequestAppData $requestAppData)
{
}
public function handle(Request $request, Closure $next)
{
$this->requestAppData->bootstrap();
return $next($request);
}
}

View File

@ -2,26 +2,17 @@
namespace App\Providers;
use App\Support\CountryCodeManager;
use App\Support\HomeSlideDefaults;
use App\Settings\GeneralSettings;
use BezhanSalleh\LanguageSwitch\LanguageSwitch;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\View;
use Modules\Category\Models\Category;
use Modules\Location\Models\Country;
use Illuminate\Support\ServiceProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
@ -36,141 +27,6 @@ class AppServiceProvider extends ServiceProvider
View::addNamespace('app', resource_path('views'));
$fallbackName = config('app.name', 'OpenClassify');
$fallbackLocale = config('app.locale', 'tr');
$fallbackCurrencies = $this->normalizeCurrencies(config('app.currencies', ['USD']));
$fallbackDescription = 'Alım satım için hızlı ve güvenli ilan platformu.';
$fallbackHomeSlides = $this->defaultHomeSlides();
$fallbackGoogleMapsApiKey = env('GOOGLE_MAPS_API_KEY');
$fallbackGoogleClientId = env('GOOGLE_CLIENT_ID');
$fallbackGoogleClientSecret = env('GOOGLE_CLIENT_SECRET');
$fallbackFacebookClientId = env('FACEBOOK_CLIENT_ID');
$fallbackFacebookClientSecret = env('FACEBOOK_CLIENT_SECRET');
$fallbackAppleClientId = env('APPLE_CLIENT_ID');
$fallbackAppleClientSecret = env('APPLE_CLIENT_SECRET');
$fallbackDefaultCountryCode = '+90';
$generalSettings = [
'site_name' => $fallbackName,
'site_description' => $fallbackDescription,
'home_slides' => $fallbackHomeSlides,
'site_logo_url' => null,
'default_language' => $fallbackLocale,
'default_country_code' => $fallbackDefaultCountryCode,
'currencies' => $fallbackCurrencies,
'sender_email' => config('mail.from.address', 'hello@example.com'),
'sender_name' => config('mail.from.name', $fallbackName),
'linkedin_url' => null,
'instagram_url' => null,
'whatsapp' => null,
'google_maps_enabled' => false,
'google_maps_api_key' => $fallbackGoogleMapsApiKey,
'google_login_enabled' => (bool) env('ENABLE_GOOGLE_LOGIN', false),
'google_client_id' => $fallbackGoogleClientId,
'google_client_secret' => $fallbackGoogleClientSecret,
'facebook_login_enabled' => (bool) env('ENABLE_FACEBOOK_LOGIN', false),
'facebook_client_id' => $fallbackFacebookClientId,
'facebook_client_secret' => $fallbackFacebookClientSecret,
'apple_login_enabled' => (bool) env('ENABLE_APPLE_LOGIN', false),
'apple_client_id' => $fallbackAppleClientId,
'apple_client_secret' => $fallbackAppleClientSecret,
];
$hasSettingsTable = false;
try {
$hasSettingsTable = Schema::hasTable('settings');
} catch (Throwable) {
$hasSettingsTable = false;
}
if ($hasSettingsTable) {
try {
$settings = app(GeneralSettings::class);
$currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies);
$availableLocales = config('app.available_locales', ['en']);
$defaultLanguage = in_array($settings->default_language, $availableLocales, true)
? $settings->default_language
: $fallbackLocale;
$googleMapsApiKey = trim((string) ($settings->google_maps_api_key ?: $fallbackGoogleMapsApiKey));
$googleMapsApiKey = $googleMapsApiKey !== '' ? $googleMapsApiKey : null;
$googleClientId = trim((string) ($settings->google_client_id ?: $fallbackGoogleClientId));
$googleClientSecret = trim((string) ($settings->google_client_secret ?: $fallbackGoogleClientSecret));
$facebookClientId = trim((string) ($settings->facebook_client_id ?: $fallbackFacebookClientId));
$facebookClientSecret = trim((string) ($settings->facebook_client_secret ?: $fallbackFacebookClientSecret));
$appleClientId = trim((string) ($settings->apple_client_id ?: $fallbackAppleClientId));
$appleClientSecret = trim((string) ($settings->apple_client_secret ?: $fallbackAppleClientSecret));
$defaultCountryCode = CountryCodeManager::normalizeCountryCode($settings->default_country_code ?? $fallbackDefaultCountryCode);
$homeSlides = $this->normalizeHomeSlides($settings->home_slides ?? []);
$generalSettings = [
'site_name' => trim((string) ($settings->site_name ?: $fallbackName)),
'site_description' => trim((string) ($settings->site_description ?: $fallbackDescription)),
'home_slides' => $homeSlides,
'site_logo_url' => filled($settings->site_logo)
? Storage::disk('public')->url($settings->site_logo)
: null,
'default_language' => $defaultLanguage,
'default_country_code' => $defaultCountryCode,
'currencies' => $currencies,
'sender_email' => trim((string) ($settings->sender_email ?: config('mail.from.address'))),
'sender_name' => trim((string) ($settings->sender_name ?: $fallbackName)),
'linkedin_url' => $settings->linkedin_url ?: null,
'instagram_url' => $settings->instagram_url ?: null,
'whatsapp' => $settings->whatsapp ?: null,
'google_maps_enabled' => (bool) ($settings->enable_google_maps ?? false),
'google_maps_api_key' => $googleMapsApiKey,
'google_login_enabled' => (bool) ($settings->enable_google_login ?? false),
'google_client_id' => $googleClientId !== '' ? $googleClientId : null,
'google_client_secret' => $googleClientSecret !== '' ? $googleClientSecret : null,
'facebook_login_enabled' => (bool) ($settings->enable_facebook_login ?? false),
'facebook_client_id' => $facebookClientId !== '' ? $facebookClientId : null,
'facebook_client_secret' => $facebookClientSecret !== '' ? $facebookClientSecret : null,
'apple_login_enabled' => (bool) ($settings->enable_apple_login ?? false),
'apple_client_id' => $appleClientId !== '' ? $appleClientId : null,
'apple_client_secret' => $appleClientSecret !== '' ? $appleClientSecret : null,
];
config([
'app.name' => $generalSettings['site_name'],
'app.locale' => $generalSettings['default_language'],
'app.currencies' => $generalSettings['currencies'],
'mail.from.address' => $generalSettings['sender_email'],
'mail.from.name' => $generalSettings['sender_name'],
]);
} catch (Throwable) {
config(['app.currencies' => $fallbackCurrencies]);
}
} else {
config(['app.currencies' => $fallbackCurrencies]);
}
$mapsKey = $generalSettings['google_maps_enabled']
? $generalSettings['google_maps_api_key']
: null;
config([
'filament-google-maps.key' => $mapsKey,
'filament-google-maps.keys.web_key' => $mapsKey,
'filament-google-maps.keys.server_key' => $mapsKey,
'services.google.client_id' => $generalSettings['google_client_id'],
'services.google.client_secret' => $generalSettings['google_client_secret'],
'services.google.redirect' => url('/oauth/callback/google'),
'services.google.enabled' => (bool) $generalSettings['google_login_enabled'],
'services.facebook.client_id' => $generalSettings['facebook_client_id'],
'services.facebook.client_secret' => $generalSettings['facebook_client_secret'],
'services.facebook.redirect' => url('/oauth/callback/facebook'),
'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'],
'services.apple.client_id' => $generalSettings['apple_client_id'],
'services.apple.client_secret' => $generalSettings['apple_client_secret'],
'services.apple.redirect' => url('/oauth/callback/apple'),
'services.apple.stateless' => true,
'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'],
'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD',
'app.default_country_code' => $generalSettings['default_country_code'] ?? $fallbackDefaultCountryCode,
'app.default_country_iso2' => CountryCodeManager::iso2FromCountryCode($generalSettings['default_country_code'] ?? $fallbackDefaultCountryCode) ?? 'TR',
]);
Event::listen(function (SocialiteWasCalled $event): void {
$event->extendSocialite('apple', \SocialiteProviders\Apple\Provider::class);
});
@ -189,75 +45,5 @@ class AppServiceProvider extends ServiceProvider
)->all())
->visible(insidePanels: count($availableLocales) > 1, outsidePanels: false);
});
$headerLocationCountries = [];
$headerNavCategories = [];
try {
if (Schema::hasTable('countries') && Schema::hasTable('cities')) {
$headerLocationCountries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'code'])
->map(function (Country $country): array {
return [
'id' => (int) $country->id,
'name' => (string) $country->name,
'code' => strtoupper((string) $country->code),
];
})
->values()
->all();
}
} catch (Throwable) {
$headerLocationCountries = [];
}
try {
if (Schema::hasTable('categories')) {
$headerNavCategories = Category::query()
->where('is_active', true)
->whereNull('parent_id')
->orderBy('sort_order')
->orderBy('name')
->limit(8)
->get(['id', 'name'])
->map(fn (Category $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
])
->values()
->all();
}
} catch (Throwable) {
$headerNavCategories = [];
}
View::share('generalSettings', $generalSettings);
View::share('headerLocationCountries', $headerLocationCountries);
View::share('headerNavCategories', $headerNavCategories);
}
private function normalizeCurrencies(array $currencies): array
{
$normalized = collect($currencies)
->filter(fn ($currency) => is_string($currency) && trim($currency) !== '')
->map(fn (string $currency) => strtoupper(substr(trim($currency), 0, 3)))
->filter(fn (string $currency) => strlen($currency) === 3)
->unique()
->values()
->all();
return $normalized !== [] ? $normalized : ['USD'];
}
private function defaultHomeSlides(): array
{
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $slides): array
{
return HomeSlideDefaults::normalize($slides);
}
}

View File

@ -10,8 +10,12 @@ class GeneralSettings extends Settings
public string $site_description;
public string $media_disk;
public ?string $site_logo;
public ?string $site_logo_disk;
public string $default_language;
public string $default_country_code;

View File

@ -3,12 +3,10 @@
namespace App\Support;
use Illuminate\Support\Arr;
use Modules\S3\Support\MediaStorage;
final class HomeSlideDefaults
{
/**
* @return array<int, array{badge: string, title: string, subtitle: string, primary_button_text: string, secondary_button_text: string, image_path: string}>
*/
public static function defaults(): array
{
return [
@ -19,6 +17,7 @@ final class HomeSlideDefaults
'primary_button_text' => 'Browse Listings',
'secondary_button_text' => 'Post Listing',
'image_path' => 'images/home-slides/slide-marketplace.svg',
'disk' => null,
],
[
'badge' => 'Fresh Categories',
@ -27,6 +26,7 @@ final class HomeSlideDefaults
'primary_button_text' => 'See Categories',
'secondary_button_text' => 'Start Now',
'image_path' => 'images/home-slides/slide-categories.svg',
'disk' => null,
],
[
'badge' => 'Local Shopping',
@ -35,14 +35,12 @@ final class HomeSlideDefaults
'primary_button_text' => 'Nearby Deals',
'secondary_button_text' => 'Sell for Free',
'image_path' => 'images/home-slides/slide-local.svg',
'disk' => null,
],
];
}
/**
* @return array<int, array{badge: string, title: string, subtitle: string, primary_button_text: string, secondary_button_text: string, image_path: string|null}>
*/
public static function normalize(mixed $slides): array
public static function normalize(mixed $slides, ?string $defaultDisk = null): array
{
$defaults = self::defaults();
$source = is_array($slides) ? $slides : [];
@ -58,6 +56,9 @@ final class HomeSlideDefaults
$primaryButtonText = trim((string) ($slide['primary_button_text'] ?? ''));
$secondaryButtonText = trim((string) ($slide['secondary_button_text'] ?? ''));
$imagePath = self::normalizeImagePath($slide['image_path'] ?? null);
$disk = MediaStorage::managesPath($imagePath)
? MediaStorage::storedDisk($slide['disk'] ?? null, $defaultDisk)
: null;
if ($title === '') {
return null;
@ -70,6 +71,7 @@ final class HomeSlideDefaults
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : $fallback['primary_button_text'],
'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : $fallback['secondary_button_text'],
'image_path' => $imagePath !== '' ? $imagePath : ($fallback['image_path'] ?? null),
'disk' => $imagePath !== '' ? $disk : ($fallback['disk'] ?? null),
];
})
->filter(fn ($slide): bool => is_array($slide))

View File

@ -0,0 +1,228 @@
<?php
namespace App\Support;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Modules\Category\Models\Category;
use Modules\Location\Models\Country;
use Modules\S3\Support\MediaStorage;
use Throwable;
final class RequestAppData
{
public function bootstrap(): void
{
$generalSettings = $this->resolveGeneralSettings();
$this->applyRuntimeConfig($generalSettings);
View::share('generalSettings', $generalSettings);
View::share('headerLocationCountries', $this->resolveHeaderLocationCountries());
View::share('headerNavCategories', $this->resolveHeaderNavCategories());
}
private function resolveGeneralSettings(): array
{
$fallbackName = config('app.name', 'OpenClassify');
$fallbackLocale = config('app.locale', 'en');
$fallbackCurrencies = $this->normalizeCurrencies(config('app.currencies', ['USD']));
$fallbackDescription = 'Buy and sell everything in your area.';
$fallbackHomeSlides = HomeSlideDefaults::defaults();
$fallbackGoogleMapsApiKey = env('GOOGLE_MAPS_API_KEY');
$fallbackGoogleClientId = env('GOOGLE_CLIENT_ID');
$fallbackGoogleClientSecret = env('GOOGLE_CLIENT_SECRET');
$fallbackFacebookClientId = env('FACEBOOK_CLIENT_ID');
$fallbackFacebookClientSecret = env('FACEBOOK_CLIENT_SECRET');
$fallbackAppleClientId = env('APPLE_CLIENT_ID');
$fallbackAppleClientSecret = env('APPLE_CLIENT_SECRET');
$fallbackDefaultCountryCode = '+90';
$fallbackMediaDriver = MediaStorage::defaultDriver();
$generalSettings = [
'site_name' => $fallbackName,
'site_description' => $fallbackDescription,
'media_disk' => $fallbackMediaDriver,
'home_slides' => $fallbackHomeSlides,
'site_logo_url' => null,
'default_language' => $fallbackLocale,
'default_country_code' => $fallbackDefaultCountryCode,
'currencies' => $fallbackCurrencies,
'sender_email' => config('mail.from.address', 'hello@example.com'),
'sender_name' => config('mail.from.name', $fallbackName),
'linkedin_url' => null,
'instagram_url' => null,
'whatsapp' => null,
'google_maps_enabled' => false,
'google_maps_api_key' => $fallbackGoogleMapsApiKey,
'google_login_enabled' => (bool) env('ENABLE_GOOGLE_LOGIN', false),
'google_client_id' => $fallbackGoogleClientId,
'google_client_secret' => $fallbackGoogleClientSecret,
'facebook_login_enabled' => (bool) env('ENABLE_FACEBOOK_LOGIN', false),
'facebook_client_id' => $fallbackFacebookClientId,
'facebook_client_secret' => $fallbackFacebookClientSecret,
'apple_login_enabled' => (bool) env('ENABLE_APPLE_LOGIN', false),
'apple_client_id' => $fallbackAppleClientId,
'apple_client_secret' => $fallbackAppleClientSecret,
];
try {
if (! Schema::hasTable('settings')) {
return $generalSettings;
}
} catch (Throwable) {
return $generalSettings;
}
try {
$settings = app(GeneralSettings::class);
$currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies);
$availableLocales = config('app.available_locales', ['en']);
$defaultLanguage = in_array($settings->default_language, $availableLocales, true)
? $settings->default_language
: $fallbackLocale;
$googleMapsApiKey = trim((string) ($settings->google_maps_api_key ?: $fallbackGoogleMapsApiKey));
$googleClientId = trim((string) ($settings->google_client_id ?: $fallbackGoogleClientId));
$googleClientSecret = trim((string) ($settings->google_client_secret ?: $fallbackGoogleClientSecret));
$facebookClientId = trim((string) ($settings->facebook_client_id ?: $fallbackFacebookClientId));
$facebookClientSecret = trim((string) ($settings->facebook_client_secret ?: $fallbackFacebookClientSecret));
$appleClientId = trim((string) ($settings->apple_client_id ?: $fallbackAppleClientId));
$appleClientSecret = trim((string) ($settings->apple_client_secret ?: $fallbackAppleClientSecret));
$defaultCountryCode = CountryCodeManager::normalizeCountryCode($settings->default_country_code ?? $fallbackDefaultCountryCode);
$mediaDriver = MediaStorage::normalizeDriver($settings->media_disk ?? null);
return [
'site_name' => trim((string) ($settings->site_name ?: $fallbackName)),
'site_description' => trim((string) ($settings->site_description ?: $fallbackDescription)),
'media_disk' => $mediaDriver,
'home_slides' => HomeSlideDefaults::normalize(
$settings->home_slides ?? [],
MediaStorage::diskFromDriver($mediaDriver),
),
'site_logo_url' => filled($settings->site_logo)
? MediaStorage::url($settings->site_logo, $settings->site_logo_disk ?? null)
: null,
'default_language' => $defaultLanguage,
'default_country_code' => $defaultCountryCode,
'currencies' => $currencies,
'sender_email' => trim((string) ($settings->sender_email ?: config('mail.from.address'))),
'sender_name' => trim((string) ($settings->sender_name ?: $fallbackName)),
'linkedin_url' => $settings->linkedin_url ?: null,
'instagram_url' => $settings->instagram_url ?: null,
'whatsapp' => $settings->whatsapp ?: null,
'google_maps_enabled' => (bool) ($settings->enable_google_maps ?? false),
'google_maps_api_key' => $googleMapsApiKey !== '' ? $googleMapsApiKey : null,
'google_login_enabled' => (bool) ($settings->enable_google_login ?? false),
'google_client_id' => $googleClientId !== '' ? $googleClientId : null,
'google_client_secret' => $googleClientSecret !== '' ? $googleClientSecret : null,
'facebook_login_enabled' => (bool) ($settings->enable_facebook_login ?? false),
'facebook_client_id' => $facebookClientId !== '' ? $facebookClientId : null,
'facebook_client_secret' => $facebookClientSecret !== '' ? $facebookClientSecret : null,
'apple_login_enabled' => (bool) ($settings->enable_apple_login ?? false),
'apple_client_id' => $appleClientId !== '' ? $appleClientId : null,
'apple_client_secret' => $appleClientSecret !== '' ? $appleClientSecret : null,
];
} catch (Throwable) {
return $generalSettings;
}
}
private function applyRuntimeConfig(array $generalSettings): void
{
$mapsKey = $generalSettings['google_maps_enabled']
? $generalSettings['google_maps_api_key']
: null;
Config::set([
'app.name' => $generalSettings['site_name'],
'app.locale' => $generalSettings['default_language'],
'app.currencies' => $generalSettings['currencies'],
'mail.from.address' => $generalSettings['sender_email'],
'mail.from.name' => $generalSettings['sender_name'],
'filament-google-maps.key' => $mapsKey,
'filament-google-maps.keys.web_key' => $mapsKey,
'filament-google-maps.keys.server_key' => $mapsKey,
'services.google.client_id' => $generalSettings['google_client_id'],
'services.google.client_secret' => $generalSettings['google_client_secret'],
'services.google.redirect' => url('/oauth/callback/google'),
'services.google.enabled' => (bool) $generalSettings['google_login_enabled'],
'services.facebook.client_id' => $generalSettings['facebook_client_id'],
'services.facebook.client_secret' => $generalSettings['facebook_client_secret'],
'services.facebook.redirect' => url('/oauth/callback/facebook'),
'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'],
'services.apple.client_id' => $generalSettings['apple_client_id'],
'services.apple.client_secret' => $generalSettings['apple_client_secret'],
'services.apple.redirect' => url('/oauth/callback/apple'),
'services.apple.stateless' => true,
'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'],
'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD',
'app.default_country_code' => $generalSettings['default_country_code'] ?? '+90',
'app.default_country_iso2' => CountryCodeManager::iso2FromCountryCode($generalSettings['default_country_code'] ?? '+90') ?? 'TR',
]);
MediaStorage::applyRuntimeConfig();
}
private function resolveHeaderLocationCountries(): array
{
try {
if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) {
return [];
}
return Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'code'])
->map(fn (Country $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
'code' => strtoupper((string) $country->code),
])
->values()
->all();
} catch (Throwable) {
return [];
}
}
private function resolveHeaderNavCategories(): array
{
try {
if (! Schema::hasTable('categories')) {
return [];
}
return Category::query()
->where('is_active', true)
->whereNull('parent_id')
->orderBy('sort_order')
->orderBy('name')
->limit(8)
->get(['id', 'name'])
->map(fn (Category $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
])
->values()
->all();
} catch (Throwable) {
return [];
}
}
private function normalizeCurrencies(array $currencies): array
{
$normalized = collect($currencies)
->filter(fn ($currency) => is_string($currency) && trim($currency) !== '')
->map(fn (string $currency) => strtoupper(substr(trim($currency), 0, 3)))
->filter(fn (string $currency) => strlen($currency) === 3)
->unique()
->values()
->all();
return $normalized !== [] ? $normalized : ['USD'];
}
}

View File

@ -3,6 +3,9 @@
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
use Illuminate\Session\Middleware\StartSession;
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@ -12,8 +15,15 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
ResolveDemoRequest::class,
\App\Http\Middleware\BootstrapAppData::class,
\App\Http\Middleware\SetLocale::class,
]);
$middleware->appendToPriorityList(StartSession::class, ResolveDemoRequest::class);
$middleware->appendToPriorityList(ResolveDemoRequest::class, \App\Http\Middleware\BootstrapAppData::class);
$middleware->appendToPriorityList(\App\Http\Middleware\BootstrapAppData::class, \App\Http\Middleware\SetLocale::class);
$middleware->prependToPriorityList(AuthenticatesRequests::class, ResolveDemoRequest::class);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@ -9,6 +9,7 @@
"php": "^8.2",
"a909m/filament-statefusion": "^2.3",
"ariaieboy/filament-currency": "^3.0",
"aws/aws-sdk-php": "^3.322",
"bezhansalleh/filament-language-switch": "^4.1",
"cheesegrits/filament-google-maps": "^5.0",
"dutchcodingcompany/filament-developer-logins": "^2.1",
@ -21,6 +22,7 @@
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.25",
"mwguerra/filemanager": "^2.0",
"nwidart/laravel-modules": "^11.0",
"pxlrbt/filament-activity-log": "^2.1",

View File

@ -41,9 +41,9 @@ return [
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'connection' => env('DB_CACHE_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],

View File

@ -98,6 +98,21 @@ return [
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'pgsql_public' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => env('DEMO_PUBLIC_SCHEMA', 'public'),
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),

11
config/demo.php Normal file
View File

@ -0,0 +1,11 @@
<?php
return [
'enabled' => (bool) env('DEMO', false),
'provisioning' => false,
'ttl_minutes' => (int) env('DEMO_TTL_MINUTES', 360),
'schema_prefix' => env('DEMO_SCHEMA_PREFIX', 'demo_'),
'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'),
'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'),
'public_schema' => env('DEMO_PUBLIC_SCHEMA', 'public'),
];

View File

@ -13,7 +13,7 @@ return [
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
'default' => env('FILESYSTEM_DISK', env('MEDIA_DISK', 's3')),
/*
|--------------------------------------------------------------------------
@ -49,13 +49,14 @@ return [
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'key' => env('AWS_ACCESS_KEY_ID', env('OBJECT_STORAGE_ACCESS_KEY_ID')),
'secret' => env('AWS_SECRET_ACCESS_KEY', env('OBJECT_STORAGE_SECRET_ACCESS_KEY')),
'region' => env('AWS_DEFAULT_REGION', env('OBJECT_STORAGE_REGION', 'hel1')),
'bucket' => env('AWS_BUCKET', env('OBJECT_STORAGE_BUCKET')),
'url' => env('AWS_URL', env('OBJECT_STORAGE_URL')),
'endpoint' => env('AWS_ENDPOINT', env('OBJECT_STORAGE_ENDPOINT')),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'visibility' => 'public',
'throw' => false,
'report' => false,
],

View File

@ -37,7 +37,7 @@ return [
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'connection' => env('DB_QUEUE_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
@ -103,7 +103,7 @@ return [
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'database' => env('DB_BATCHING_CONNECTION', env('DEMO', false) ? 'pgsql_public' : env('DB_CONNECTION', 'sqlite')),
'table' => 'job_batches',
],
@ -122,7 +122,7 @@ return [
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'database' => env('DB_FAILED_CONNECTION', env('DEMO', false) ? 'pgsql_public' : env('DB_CONNECTION', 'sqlite')),
'table' => 'failed_jobs',
],

View File

@ -73,7 +73,7 @@ return [
|
*/
'connection' => env('SESSION_CONNECTION'),
'connection' => env('SESSION_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null),
/*
|--------------------------------------------------------------------------

View File

@ -1,42 +1,24 @@
<?php
namespace Database\Seeders;
use Modules\User\App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$admin = User::updateOrCreate(
['email' => 'a@a.com'],
['name' => 'Admin', 'password' => Hash::make('236330'), 'status' => 'active']
);
$partner = User::updateOrCreate(
['email' => 'b@b.com'],
['name' => 'Partner', 'password' => Hash::make('36330'), 'status' => 'active']
);
if (class_exists(Role::class)) {
$adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$partnerRole = Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']);
$admin->syncRoles([$adminRole->name]);
$partner->syncRoles([$partnerRole->name]);
}
$this->call([
HomeSliderSettingsSeeder::class,
\Modules\Location\Database\Seeders\LocationSeeder::class,
\Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class,
\Modules\Listing\Database\Seeders\ListingSeeder::class,
\Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class,
\Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class,
\Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class,
]);
if ((bool) config('demo.enabled') || (bool) config('demo.provisioning')) {
$this->call([
\Modules\Demo\Database\Seeders\DemoContentSeeder::class,
]);
}
}
}

View File

@ -0,0 +1,83 @@
<?php
use Illuminate\Support\Facades\DB;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
if (! $this->migrator->exists('general.media_disk')) {
$this->migrator->add('general.media_disk', 's3');
} else {
$this->migrator->update('general.media_disk', fn ($value) => in_array($value, ['local', 's3'], true) ? $value : 's3');
}
if (! $this->migrator->exists('general.site_logo_disk')) {
$this->migrator->add('general.site_logo_disk', $this->legacyDiskForPath($this->settingValue('site_logo')));
} else {
$this->migrator->update('general.site_logo_disk', fn ($value) => is_string($value) && trim($value) !== '' ? trim($value) : $this->legacyDiskForPath($this->settingValue('site_logo')));
}
if (! $this->migrator->exists('general.home_slides')) {
return;
}
$this->migrator->update('general.home_slides', function ($slides) {
if (! is_array($slides)) {
return $slides;
}
return collect($slides)
->map(function ($slide) {
if (! is_array($slide)) {
return $slide;
}
$imagePath = is_string($slide['image_path'] ?? null) ? trim($slide['image_path']) : '';
$slide['disk'] = is_string($slide['disk'] ?? null) && trim($slide['disk']) !== ''
? trim($slide['disk'])
: $this->legacyDiskForPath($imagePath);
return $slide;
})
->all();
});
}
private function legacyDiskForPath(mixed $path): ?string
{
if (! is_string($path) || trim($path) === '') {
return null;
}
$path = trim($path);
if (
str_starts_with($path, 'http://')
|| str_starts_with($path, 'https://')
|| str_starts_with($path, '//')
|| str_starts_with($path, 'images/')
) {
return null;
}
return 'public';
}
private function settingValue(string $name): mixed
{
$payload = DB::table('settings')
->where('group', 'general')
->where('name', $name)
->value('payload');
if (! is_string($payload)) {
return $payload;
}
$decoded = json_decode($payload, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : $payload;
}
};

View File

@ -8,5 +8,7 @@
"Conversation": true,
"Favorite": true,
"User": true,
"Video": true
"Video": true,
"S3": true,
"Demo": true
}

View File

@ -5,6 +5,25 @@
$heroListing = $featuredListings->first() ?? $recentListings->first();
$heroImage = $heroListing?->getFirstMediaUrl('listing-images');
$listingCards = $recentListings->take(6);
$demoEnabled = (bool) config('demo.enabled');
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
$prepareDemoRedirect = url()->full();
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
$demoLandingMode = $demoEnabled && !auth()->check() && !$hasDemoSession;
$demoTtlMinutes = (int) config('demo.ttl_minutes', 360);
$demoTtlHours = intdiv($demoTtlMinutes, 60);
$demoTtlRemainderMinutes = $demoTtlMinutes % 60;
$demoTtlLabelParts = [];
if ($demoTtlHours > 0) {
$demoTtlLabelParts[] = $demoTtlHours.' '.\Illuminate\Support\Str::plural('hour', $demoTtlHours);
}
if ($demoTtlRemainderMinutes > 0) {
$demoTtlLabelParts[] = $demoTtlRemainderMinutes.' '.\Illuminate\Support\Str::plural('minute', $demoTtlRemainderMinutes);
}
$demoTtlLabel = $demoTtlLabelParts !== [] ? implode(' ', $demoTtlLabelParts) : '0 minutes';
$homeSlides = collect($generalSettings['home_slides'] ?? [])
->filter(fn ($slide): bool => is_array($slide))
->map(function (array $slide): array {
@ -21,13 +40,7 @@
'subtitle' => $subtitle !== '' ? $subtitle : 'Buy and sell everything in your area',
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : 'Browse Listings',
'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : 'Post Listing',
'image_url' => $imagePath !== ''
? (str_starts_with($imagePath, 'http://') || str_starts_with($imagePath, 'https://')
? $imagePath
: (str_starts_with($imagePath, 'images/')
? asset($imagePath)
: \Illuminate\Support\Facades\Storage::disk('public')->url($imagePath)))
: null,
'image_url' => \Modules\S3\Support\MediaStorage::url($imagePath, $slide['disk'] ?? null),
];
})
->values();
@ -67,6 +80,24 @@
];
@endphp
@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">
@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>
<p class="mt-5 text-base md:text-lg leading-8 text-slate-600">
Launch a private seeded marketplace for this browser. Listings, favorites, inbox data, and admin access are prepared automatically.
</p>
<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
</button>
</form>
</div>
@else
<div class="max-w-[1320px] mx-auto px-4 py-5 md:py-7 space-y-7">
<section class="relative overflow-hidden rounded-[28px] bg-gradient-to-r from-blue-900 via-blue-700 to-blue-600 text-white shadow-xl">
<div class="absolute -top-20 -left-24 w-80 h-80 rounded-full bg-blue-400/20 blur-3xl"></div>
@ -292,15 +323,17 @@
@endphp
<article class="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition">
<div class="relative h-64 md:h-[290px] bg-slate-100">
@if($listingImage)
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400">
<svg class="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M4 16l4.5-4.5a2 2 0 012.8 0L16 16m-1.5-1.5l1.8-1.8a2 2 0 012.8 0L21 14m-7-8h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
@endif
<a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
@if($listingImage)
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400">
<svg class="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M4 16l4.5-4.5a2 2 0 012.8 0L16 16m-1.5-1.5l1.8-1.8a2 2 0 012.8 0L21 14m-7-8h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
@endif
</a>
<div class="absolute top-3 left-3 flex items-center gap-2">
@if($listing->is_featured)
<span class="bg-amber-300 text-amber-950 text-xs font-bold px-2.5 py-1 rounded-full">Öne Çıkan</span>
@ -330,12 +363,6 @@
<span class="truncate">{{ $locationLabel !== '' ? $locationLabel : 'Konum belirtilmedi' }}</span>
<span>{{ $listing->created_at->diffForHumans() }}</span>
</div>
<a href="{{ route('listings.show', $listing) }}" class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-blue-700 hover:text-blue-900 transition">
İlan detayını
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</article>
@empty
@ -364,6 +391,7 @@
</div>
</section>
</div>
@endif
<script>
(() => {
const setupTrendCategories = () => {

View File

@ -14,6 +14,31 @@
$panelListingsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute;
$inboxRoute = auth()->check() ? route('panel.inbox.index') : $loginRoute;
$favoritesRoute = auth()->check() ? route('favorites.index') : $loginRoute;
$demoEnabled = (bool) config('demo.enabled');
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
$prepareDemoRedirect = url()->full();
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
$demoLandingMode = $demoEnabled && request()->routeIs('home') && !auth()->check() && !$hasDemoSession;
$demoExpiresAt = session('demo_expires_at');
$demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null;
$demoRemainingLabel = null;
if ($demoExpiresAt?->isFuture()) {
$remainingMinutes = now()->diffInMinutes($demoExpiresAt, false);
$remainingHours = intdiv($remainingMinutes, 60);
$remainingRemainderMinutes = $remainingMinutes % 60;
$remainingParts = [];
if ($remainingHours > 0) {
$remainingParts[] = $remainingHours.' '.\Illuminate\Support\Str::plural('hour', $remainingHours);
}
if ($remainingRemainderMinutes > 0) {
$remainingParts[] = $remainingRemainderMinutes.' '.\Illuminate\Support\Str::plural('minute', $remainingRemainderMinutes);
}
$demoRemainingLabel = $remainingParts !== [] ? implode(' ', $remainingParts) : 'less than a minute';
}
$availableLocales = config('app.available_locales', ['en']);
$localeLabels = [
'en' => 'English',
@ -49,7 +74,11 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="min-h-screen font-sans antialiased">
<body @class([
'min-h-screen font-sans antialiased',
'bg-slate-50' => $demoLandingMode,
])>
@unless($demoLandingMode)
<nav class="market-nav-surface sticky top-0 z-50">
<div class="oc-nav-wrap">
<div class="oc-nav-main">
@ -162,12 +191,21 @@
<button type="submit" class="oc-text-link">{{ __('messages.logout') }}</button>
</form>
@else
@if(!$demoLandingMode && $demoEnabled && $prepareDemoRoute)
<form method="POST" action="{{ $prepareDemoRoute }}" class="oc-demo-prepare">
@csrf
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
<button type="submit" class="oc-text-link oc-auth-link">Prepare Demo</button>
</form>
@endif
@if(!$demoLandingMode)
<a href="{{ $loginRoute }}" class="oc-text-link oc-auth-link">
{{ __('messages.login') }}
</a>
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
Sell
</a>
@endif
@endauth
</div>
</div>
@ -227,6 +265,7 @@
</svg>
</a>
@else
@if(!$demoLandingMode)
<a href="{{ $loginRoute }}" class="oc-mobile-menu-link">
<span>Login</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -239,6 +278,19 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
@endif
@if($demoEnabled && $prepareDemoRoute)
<form method="POST" action="{{ $prepareDemoRoute }}" class="w-full">
@csrf
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
<button type="submit" class="oc-mobile-menu-link w-full text-left">
<span>Prepare Demo</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</button>
</form>
@endif
@endauth
</div>
</div>
@ -283,6 +335,15 @@
</div>
</div>
</nav>
@endunless
@if(!$demoLandingMode && $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>
</div>
@endif
@unless($demoLandingMode)
@if(session('success'))
<div class="max-w-[1320px] mx-auto px-4 pt-3">
<div class="bg-emerald-100 border border-emerald-300 text-emerald-800 px-4 py-3 rounded-xl text-sm">{{ session('success') }}</div>
@ -293,7 +354,12 @@
<div class="bg-rose-100 border border-rose-300 text-rose-700 px-4 py-3 rounded-xl text-sm">{{ session('error') }}</div>
</div>
@endif
<main class="site-main">@yield('content')</main>
@endunless
<main @class([
'site-main',
'min-h-screen' => $demoLandingMode,
])>@yield('content')</main>
@unless($demoLandingMode)
<footer class="mt-14 bg-slate-100 text-slate-600 border-t border-slate-200">
<div class="max-w-[1320px] mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
@ -345,6 +411,7 @@
</div>
</div>
</footer>
@endunless
@livewireScripts
<script>
(() => {

View File

@ -2,7 +2,12 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
if (config('demo.enabled')) {
Schedule::command('demo:cleanup')->hourly();
}