mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Demo hazırla sayfa ekle
This commit is contained in:
parent
93ce5a0925
commit
d5f88c79af
17
.env.example
17
.env.example
@ -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
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
29
Modules/Demo/App/Console/CleanupDemoCommand.php
Normal file
29
Modules/Demo/App/Console/CleanupDemoCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Modules/Demo/App/Console/PrepareDemoCommand.php
Normal file
33
Modules/Demo/App/Console/PrepareDemoCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Modules/Demo/App/Http/Controllers/DemoController.php
Normal file
86
Modules/Demo/App/Http/Controllers/DemoController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
Modules/Demo/App/Http/Middleware/ResolveDemoRequest.php
Normal file
77
Modules/Demo/App/Http/Middleware/ResolveDemoRequest.php
Normal 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()));
|
||||
}
|
||||
}
|
||||
33
Modules/Demo/App/Models/DemoInstance.php
Normal file
33
Modules/Demo/App/Models/DemoInstance.php
Normal 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());
|
||||
}
|
||||
}
|
||||
42
Modules/Demo/App/Providers/DemoServiceProvider.php
Normal file
42
Modules/Demo/App/Providers/DemoServiceProvider.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
276
Modules/Demo/App/Support/DemoSchemaManager.php
Normal file
276
Modules/Demo/App/Support/DemoSchemaManager.php
Normal 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.'"';
|
||||
}
|
||||
}
|
||||
47
Modules/Demo/database/Seeders/DemoContentSeeder.php
Normal file
47
Modules/Demo/database/Seeders/DemoContentSeeder.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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
11
Modules/Demo/module.json
Normal 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": []
|
||||
}
|
||||
8
Modules/Demo/routes/web.php
Normal file
8
Modules/Demo/routes/web.php
Normal 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');
|
||||
});
|
||||
@ -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,
|
||||
|
||||
13
Modules/S3/Providers/S3ServiceProvider.php
Normal file
13
Modules/S3/Providers/S3ServiceProvider.php
Normal 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');
|
||||
}
|
||||
}
|
||||
147
Modules/S3/Support/MediaStorage.php
Normal file
147
Modules/S3/Support/MediaStorage.php
Normal 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
7
Modules/S3/config/s3.php
Normal 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
12
Modules/S3/module.json
Normal 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": []
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
62
README.md
62
README.md
@ -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
|
||||
|
||||
21
app/Http/Middleware/BootstrapAppData.php
Normal file
21
app/Http/Middleware/BootstrapAppData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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))
|
||||
|
||||
228
app/Support/RequestAppData.php
Normal file
228
app/Support/RequestAppData.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
//
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
|
||||
|
||||
@ -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
11
config/demo.php
Normal 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'),
|
||||
];
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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',
|
||||
],
|
||||
|
||||
|
||||
@ -73,7 +73,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
'connection' => env('SESSION_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -8,5 +8,7 @@
|
||||
"Conversation": true,
|
||||
"Favorite": true,
|
||||
"User": true,
|
||||
"Video": true
|
||||
"Video": true,
|
||||
"S3": true,
|
||||
"Demo": true
|
||||
}
|
||||
|
||||
@ -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ı aç
|
||||
<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 = () => {
|
||||
|
||||
@ -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>
|
||||
(() => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user