diff --git a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php b/Modules/Admin/Filament/Pages/ManageGeneralSettings.php index 56992c914..53328ef87 100644 --- a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php +++ b/Modules/Admin/Filament/Pages/ManageGeneralSettings.php @@ -2,23 +2,23 @@ namespace Modules\Admin\Filament\Pages; -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\TextInput; 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\Location\Support\CountryCodeManager; use Modules\S3\Support\MediaStorage; +use Modules\Site\App\Settings\GeneralSettings; +use Modules\Site\App\Support\HomeSlideDefaults; use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect; use UnitEnum; use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput; @@ -31,9 +31,9 @@ class ManageGeneralSettings extends SettingsPage protected static ?string $navigationLabel = 'Genel Ayarlar'; - protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-cog-6-tooth'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth'; - protected static string | UnitEnum | null $navigationGroup = 'Ayarlar'; + protected static string|UnitEnum|null $navigationGroup = 'Ayarlar'; protected static ?int $navigationSort = 1; @@ -246,7 +246,7 @@ class ManageGeneralSettings extends SettingsPage 'home_slides' => $this->defaultHomeSlides(), 'site_logo_disk' => null, 'sender_name' => $siteName, - 'sender_email' => (string) config('mail.from.address', 'info@' . $siteHost), + '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') : 'en', 'default_country_code' => CountryCodeManager::normalizeCountryCode(config('app.default_country_code', '+90')), 'currencies' => $this->normalizeCurrencies(config('app.currencies', ['TRY'])), @@ -272,7 +272,7 @@ class ManageGeneralSettings extends SettingsPage ->all(); } - private function normalizeCurrencies(null | array | string $state): array + private function normalizeCurrencies(null|array|string $state): array { $source = is_array($state) ? $state : (filled($state) ? [$state] : []); diff --git a/Modules/Admin/Filament/Pages/ManageHomeSlides.php b/Modules/Admin/Filament/Pages/ManageHomeSlides.php deleted file mode 100644 index c311e6728..000000000 --- a/Modules/Admin/Filament/Pages/ManageHomeSlides.php +++ /dev/null @@ -1,68 +0,0 @@ - $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, MediaStorage::activeDisk()), - ), - ]); - } - - private function defaultHomeSlides(): array - { - return HomeSlideDefaults::defaults(); - } - - private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array - { - return HomeSlideDefaults::normalize($state, $defaultDisk); - } -} diff --git a/Modules/Admin/Filament/Resources/ListingResource.php b/Modules/Admin/Filament/Resources/ListingResource.php index 2d38d2b18..64044882d 100644 --- a/Modules/Admin/Filament/Resources/ListingResource.php +++ b/Modules/Admin/Filament/Resources/ListingResource.php @@ -2,42 +2,14 @@ namespace Modules\Admin\Filament\Resources; -use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect; -use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn; -use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter; -use App\Support\CountryCodeManager; use BackedEnum; -use Cheesegrits\FilamentGoogleMaps\Fields\Map; -use Filament\Forms\Components\DatePicker; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\SpatieMediaLibraryFileUpload; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; -use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; -use Filament\Tables\Columns\IconColumn; -use Filament\Tables\Columns\SpatieMediaLibraryImageColumn; -use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Enums\FiltersLayout; -use Filament\Tables\Filters\Filter; -use Filament\Tables\Filters\SelectFilter; -use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; use Modules\Admin\Filament\Resources\ListingResource\Pages; -use Modules\Admin\Support\Filament\ResourceTableActions; -use Modules\Category\Models\Category; use Modules\Listing\Models\Listing; -use Modules\Listing\Support\ListingCustomFieldSchemaBuilder; -use Modules\Listing\Support\ListingPanelHelper; -use Modules\Location\Models\City; -use Modules\Location\Models\Country; -use Modules\Video\Support\Filament\VideoFormSchema; +use Modules\Listing\Support\Filament\AdminListingResourceSchema; use UnitEnum; -use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput; class ListingResource extends Resource { @@ -49,138 +21,12 @@ class ListingResource extends Resource public static function form(Schema $schema): Schema { - return $schema->schema([ - TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state).'-'.\Illuminate\Support\Str::random(4))), - TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true), - Textarea::make('description')->rows(4), - TextInput::make('price') - ->numeric() - ->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2), - Select::make('currency') - ->options(fn () => ListingPanelHelper::currencyOptions()) - ->default(fn () => ListingPanelHelper::defaultCurrency()) - ->required(), - Select::make('category_id') - ->label('Category') - ->options(fn (): array => Category::activeIdNameOptions()) - ->searchable() - ->live() - ->afterStateUpdated(fn ($state, $set) => $set('custom_fields', [])) - ->nullable(), - Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(), - Section::make('Custom Fields') - ->description('Category specific listing attributes.') - ->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents( - ($categoryId = $get('category_id')) ? (int) $categoryId : null - )) - ->columns(2) - ->columnSpanFull() - ->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields( - ($categoryId = $get('category_id')) ? (int) $categoryId : null - )), - StateFusionSelect::make('status')->required(), - PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(), - TextInput::make('contact_email')->email()->maxLength(255), - Toggle::make('is_featured')->default(false), - Select::make('country') - ->label('Country') - ->options(fn (): array => Country::nameOptions()) - ->searchable() - ->preload() - ->live() - ->afterStateUpdated(fn ($state, $set) => $set('city', null)) - ->nullable(), - Select::make('city') - ->label('City') - ->options(fn (Get $get): array => City::nameOptions($get('country'))) - ->searchable() - ->preload() - ->nullable(), - Map::make('location') - ->label('Location') - ->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled()) - ->draggable() - ->clickable() - ->autocomplete('city') - ->autocompleteReverse(true) - ->reverseGeocode([ - 'city' => '%L', - ]) - ->defaultLocation([41.0082, 28.9784]) - ->defaultZoom(10) - ->height('320px') - ->columnSpanFull(), - SpatieMediaLibraryFileUpload::make('images') - ->collection('listing-images') - ->multiple() - ->image() - ->reorderable(), - VideoFormSchema::listingSection(), - ]); + return $schema->schema(AdminListingResourceSchema::form()); } public static function table(Table $table): Table { - return $table->columns([ - SpatieMediaLibraryImageColumn::make('images') - ->collection('listing-images') - ->circular(), - TextColumn::make('id')->sortable(), - TextColumn::make('title')->searchable()->sortable()->limit(40), - TextColumn::make('category.name')->label('Category')->sortable(), - TextColumn::make('user.email')->label('Owner')->searchable()->toggleable()->sortable(), - TextColumn::make('price') - ->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency()) - ->sortable(), - StateFusionSelectColumn::make('status')->sortable(), - IconColumn::make('is_featured')->boolean()->label('Featured')->sortable(), - TextColumn::make('city')->sortable(), - TextColumn::make('country')->sortable(), - TextColumn::make('created_at')->dateTime()->sortable(), - ])->filters([ - StateFusionSelectFilter::make('status'), - SelectFilter::make('category_id') - ->label('Category') - ->relationship('category', 'name') - ->searchable() - ->preload(), - SelectFilter::make('user_id') - ->label('Owner') - ->relationship('user', 'email') - ->searchable() - ->preload(), - SelectFilter::make('country') - ->options(fn (): array => Country::nameOptions()) - ->searchable(), - SelectFilter::make('city') - ->options(fn (): array => City::nameOptions(null, false)) - ->searchable(), - TernaryFilter::make('is_featured')->label('Featured'), - Filter::make('created_at') - ->label('Created Date') - ->schema([ - DatePicker::make('from')->label('From'), - DatePicker::make('until')->label('Until'), - ]) - ->query(fn (Builder $query, array $data): Builder => $query - ->when($data['from'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date)) - ->when($data['until'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date))), - Filter::make('price') - ->label('Price Range') - ->schema([ - TextInput::make('min')->numeric()->label('Min'), - TextInput::make('max')->numeric()->label('Max'), - ]) - ->query(fn (Builder $query, array $data): Builder => $query - ->when($data['min'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '>=', (float) $amount)) - ->when($data['max'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '<=', (float) $amount))), - ]) - ->filtersLayout(FiltersLayout::AboveContent) - ->filtersFormColumns(3) - ->filtersFormWidth('7xl') - ->persistFiltersInSession() - ->defaultSort('id', 'desc') - ->actions(ResourceTableActions::editActivityDelete(static::class)); + return AdminListingResourceSchema::configureTable($table, static::class); } public static function getPages(): array diff --git a/Modules/Admin/Filament/Widgets/ListingsTrendChart.php b/Modules/Admin/Filament/Widgets/ListingsTrendChart.php index 416f32710..f1011b330 100644 --- a/Modules/Admin/Filament/Widgets/ListingsTrendChart.php +++ b/Modules/Admin/Filament/Widgets/ListingsTrendChart.php @@ -1,4 +1,5 @@ all(); } + public static function activeCount(): int + { + return (int) static::query() + ->active() + ->count(); + } + + public static function homeParentCategories(int $limit = 8): Collection + { + return static::query() + ->active() + ->whereNull('parent_id') + ->ordered() + ->limit($limit) + ->get(); + } + + public static function headerNavigationItems(int $limit = 8): array + { + return static::query() + ->active() + ->whereNull('parent_id') + ->ordered() + ->limit($limit) + ->get(['id', 'name', 'icon']) + ->map(fn (self $category): array => [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + 'icon_url' => $category->iconUrl(), + ]) + ->all(); + } + + public static function activeAiCatalog(): Collection + { + return static::query() + ->active() + ->ordered() + ->get(['id', 'name', 'parent_id']); + } + + public static function panelQuickCatalog(): array + { + $all = static::query() + ->active() + ->ordered() + ->get(['id', 'name', 'parent_id', 'icon']); + + $childrenCount = static::query() + ->active() + ->selectRaw('parent_id, count(*) as aggregate') + ->whereNotNull('parent_id') + ->groupBy('parent_id') + ->pluck('aggregate', 'parent_id'); + + return $all + ->map(fn (self $category): array => [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + 'parent_id' => $category->parent_id ? (int) $category->parent_id : null, + 'icon' => $category->icon, + 'has_children' => ((int) ($childrenCount[$category->id] ?? 0)) > 0, + ]) + ->all(); + } + public static function rootIdNameOptions(): array { return static::query() diff --git a/Modules/Conversation/database/seeders/ConversationDemoSeeder.php b/Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php similarity index 99% rename from Modules/Conversation/database/seeders/ConversationDemoSeeder.php rename to Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php index 4018ee4a9..e866e7d9e 100644 --- a/Modules/Conversation/database/seeders/ConversationDemoSeeder.php +++ b/Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php @@ -107,7 +107,7 @@ class ConversationDemoSeeder extends Seeder $readAfterMinutes = $payload['read_after_minutes']; $readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null; - $message = new ConversationMessage(); + $message = new ConversationMessage; $message->forceFill([ 'conversation_id' => $conversation->getKey(), 'sender_id' => $sender->getKey(), diff --git a/Modules/Conversation/database/migrations/2026_03_04_000000_create_conversation_tables.php b/Modules/Conversation/Database/migrations/2026_03_04_000000_create_conversation_tables.php similarity index 100% rename from Modules/Conversation/database/migrations/2026_03_04_000000_create_conversation_tables.php rename to Modules/Conversation/Database/migrations/2026_03_04_000000_create_conversation_tables.php diff --git a/Modules/Conversation/resources/views/inbox.blade.php b/Modules/Conversation/resources/views/inbox.blade.php index 053ec63fb..2d7b73056 100644 --- a/Modules/Conversation/resources/views/inbox.blade.php +++ b/Modules/Conversation/resources/views/inbox.blade.php @@ -5,10 +5,10 @@ @section('content')
- @include('panel.partials.sidebar', ['activeMenu' => 'inbox']) + @include('panel::partials.sidebar', ['activeMenu' => 'inbox'])
- @include('panel.partials.page-header', [ + @include('panel::partials.page-header', [ 'title' => 'Inbox', 'description' => 'Read and reply to buyer messages from the same panel shell used across the site.', 'actions' => $requiresLogin ?? false diff --git a/Modules/Demo/App/Support/DemoSchemaManager.php b/Modules/Demo/App/Support/DemoSchemaManager.php index 85aabbcb5..0f9b65d86 100644 --- a/Modules/Demo/App/Support/DemoSchemaManager.php +++ b/Modules/Demo/App/Support/DemoSchemaManager.php @@ -2,11 +2,11 @@ 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\Site\App\Settings\GeneralSettings; use Modules\User\App\Models\User; use Spatie\Permission\PermissionRegistrar; use Throwable; diff --git a/Modules/Demo/database/Seeders/DemoContentSeeder.php b/Modules/Demo/Database/Seeders/DemoContentSeeder.php similarity index 100% rename from Modules/Demo/database/Seeders/DemoContentSeeder.php rename to Modules/Demo/Database/Seeders/DemoContentSeeder.php diff --git a/Modules/Demo/database/migrations/2026_03_07_000000_create_demo_instances_table.php b/Modules/Demo/Database/migrations/2026_03_07_000000_create_demo_instances_table.php similarity index 100% rename from Modules/Demo/database/migrations/2026_03_07_000000_create_demo_instances_table.php rename to Modules/Demo/Database/migrations/2026_03_07_000000_create_demo_instances_table.php diff --git a/Modules/Favorite/database/seeders/FavoriteDemoSeeder.php b/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php similarity index 100% rename from Modules/Favorite/database/seeders/FavoriteDemoSeeder.php rename to Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php diff --git a/Modules/Favorite/database/migrations/2026_03_04_000000_create_favorites_tables.php b/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php similarity index 100% rename from Modules/Favorite/database/migrations/2026_03_04_000000_create_favorites_tables.php rename to Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php diff --git a/Modules/Favorite/resources/views/index.blade.php b/Modules/Favorite/resources/views/index.blade.php index 1b4c53603..bc2e87d34 100644 --- a/Modules/Favorite/resources/views/index.blade.php +++ b/Modules/Favorite/resources/views/index.blade.php @@ -5,7 +5,7 @@ @section('content')
- @include('panel.partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab]) + @include('panel::partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab])
@if($requiresLogin ?? false) diff --git a/Modules/Listing/database/migrations/2024_01_01_000003_create_listings_table.php b/Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php similarity index 67% rename from Modules/Listing/database/migrations/2024_01_01_000003_create_listings_table.php rename to Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php index 62dffcae1..74edd7796 100644 --- a/Modules/Listing/database/migrations/2024_01_01_000003_create_listings_table.php +++ b/Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php @@ -1,4 +1,5 @@ decimal('longitude', 10, 7)->nullable(); $table->timestamps(); }); + + Schema::create('listing_custom_fields', function (Blueprint $table): void { + $table->id(); + $table->string('name')->unique(); + $table->string('label'); + $table->string('type', 32); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->text('placeholder')->nullable(); + $table->text('help_text')->nullable(); + $table->json('options')->nullable(); + $table->boolean('is_required')->default(false); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); } public function down(): void { + Schema::dropIfExists('listing_custom_fields'); Schema::dropIfExists('listings'); } }; diff --git a/Modules/Listing/Database/migrations/2024_01_01_000004_create_listing_custom_fields_table.php b/Modules/Listing/Database/migrations/2024_01_01_000004_create_listing_custom_fields_table.php deleted file mode 100644 index f83dbf8c6..000000000 --- a/Modules/Listing/Database/migrations/2024_01_01_000004_create_listing_custom_fields_table.php +++ /dev/null @@ -1,31 +0,0 @@ -id(); - $table->string('name')->unique(); - $table->string('label'); - $table->string('type', 32); - $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); - $table->text('placeholder')->nullable(); - $table->text('help_text')->nullable(); - $table->json('options')->nullable(); - $table->boolean('is_required')->default(false); - $table->boolean('is_active')->default(true); - $table->unsignedInteger('sort_order')->default(0); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('listing_custom_fields'); - } -}; diff --git a/Modules/Listing/Models/Listing.php b/Modules/Listing/Models/Listing.php index b958dfea9..977d9c77b 100644 --- a/Modules/Listing/Models/Listing.php +++ b/Modules/Listing/Models/Listing.php @@ -1,22 +1,25 @@ where('status', 'active'); } - public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder + public function scopeOwnedByUser(Builder $query, int|string|null $userId): Builder { return $query->where('user_id', $userId); } @@ -127,6 +130,24 @@ class Listing extends Model implements HasMedia }); } + public function scopeWithPanelIndexState(Builder $query): Builder + { + return $query + ->with('category:id,name') + ->withCount('favoritedByUsers') + ->withCount('videos') + ->withCount([ + 'videos as ready_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery + ->whereNotNull('path') + ->where('is_active', true), + 'videos as pending_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery + ->whereIn('status', [ + VideoStatus::Pending->value, + VideoStatus::Processing->value, + ]), + ]); + } + public function scopeForCategory(Builder $query, ?int $categoryId): Builder { return $query->forCategoryIds(Category::listingFilterIds($categoryId)); @@ -272,7 +293,7 @@ class Listing extends Model implements HasMedia ]; } - public static function panelStatusCountsForUser(int | string $userId): array + public static function panelStatusCountsForUser(int|string $userId): array { $counts = static::query() ->ownedByUser($userId) @@ -289,6 +310,49 @@ class Listing extends Model implements HasMedia ]; } + public static function activeCount(): int + { + return (int) static::query() + ->active() + ->count(); + } + + public static function homeFeatured(int $limit = 4): Collection + { + return static::query() + ->active() + ->where('is_featured', true) + ->latest() + ->take($limit) + ->get(); + } + + public static function homeRecent(int $limit = 8): Collection + { + return static::query() + ->active() + ->latest() + ->take($limit) + ->get(); + } + + public static function panelIndexDataForUser(User $user, string $search, string $status): array + { + $listings = static::query() + ->ownedByUser($user->getKey()) + ->withPanelIndexState() + ->searchTerm($search) + ->forPanelStatus($status) + ->latest('id') + ->paginate(10) + ->withQueryString(); + + return [ + 'listings' => $listings, + 'counts' => static::panelStatusCountsForUser($user->getKey()), + ]; + } + public function panelPrimaryImageUrl(): ?string { return $this->primaryImageUrl('card', 'desktop'); @@ -435,6 +499,34 @@ class Listing extends Model implements HasMedia }; } + public function loadPanelEditor(): self + { + return $this->load([ + 'category:id,name', + 'videos:id,listing_id,title,status,is_active,path,upload_path,duration_seconds,size', + ]); + } + + public function assertOwnedBy(User $user): void + { + abort_unless((int) $this->user_id === (int) $user->getKey(), 403); + } + + public function markAsSold(): void + { + $this->forceFill([ + 'status' => 'sold', + ])->save(); + } + + public function republish(): void + { + $this->forceFill([ + 'status' => 'active', + 'expires_at' => now()->addDays(self::DEFAULT_PANEL_EXPIRY_WINDOW_DAYS), + ])->save(); + } + public function updateFromPanel(array $attributes): void { $payload = Arr::only($attributes, [ @@ -460,7 +552,7 @@ class Listing extends Model implements HasMedia $this->forceFill($payload)->save(); } - public static function createFromFrontend(array $data, null | int | string $userId): self + public static function createFromFrontend(array $data, null|int|string $userId): self { $baseSlug = Str::slug((string) ($data['title'] ?? 'listing')); $baseSlug = $baseSlug !== '' ? $baseSlug : 'listing'; diff --git a/Modules/Listing/Models/ListingCustomField.php b/Modules/Listing/Models/ListingCustomField.php index a02d1e4c7..6fbd4e07e 100644 --- a/Modules/Listing/Models/ListingCustomField.php +++ b/Modules/Listing/Models/ListingCustomField.php @@ -9,10 +9,15 @@ use Modules\Category\Models\Category; class ListingCustomField extends Model { public const TYPE_TEXT = 'text'; + public const TYPE_TEXTAREA = 'textarea'; + public const TYPE_NUMBER = 'number'; + public const TYPE_SELECT = 'select'; + public const TYPE_BOOLEAN = 'boolean'; + public const TYPE_DATE = 'date'; protected $fillable = [ @@ -100,4 +105,26 @@ class ListingCustomField extends Model ], ); } + + public static function panelFieldDefinitions(?int $categoryId): array + { + return static::query() + ->active() + ->forCategory($categoryId) + ->ordered() + ->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options']) + ->map(fn (self $field): array => [ + 'name' => (string) $field->name, + 'label' => (string) $field->label, + 'type' => (string) $field->type, + 'is_required' => (bool) $field->is_required, + 'placeholder' => $field->placeholder, + 'help_text' => $field->help_text, + 'options' => collect($field->options ?? []) + ->map(fn ($option): string => (string) $option) + ->values() + ->all(), + ]) + ->all(); + } } diff --git a/Modules/Listing/Support/Filament/AdminListingResourceSchema.php b/Modules/Listing/Support/Filament/AdminListingResourceSchema.php new file mode 100644 index 000000000..1bad5c693 --- /dev/null +++ b/Modules/Listing/Support/Filament/AdminListingResourceSchema.php @@ -0,0 +1,176 @@ +required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state).'-'.\Illuminate\Support\Str::random(4))), + TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true), + Textarea::make('description')->rows(4), + TextInput::make('price') + ->numeric() + ->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2), + Select::make('currency') + ->options(fn (): array => ListingPanelHelper::currencyOptions()) + ->default(fn (): string => ListingPanelHelper::defaultCurrency()) + ->required(), + Select::make('category_id') + ->label('Category') + ->options(fn (): array => Category::activeIdNameOptions()) + ->searchable() + ->live() + ->afterStateUpdated(fn ($state, $set) => $set('custom_fields', [])) + ->nullable(), + Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(), + Section::make('Custom Fields') + ->description('Category specific listing attributes.') + ->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents( + ($categoryId = $get('category_id')) ? (int) $categoryId : null + )) + ->columns(2) + ->columnSpanFull() + ->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields( + ($categoryId = $get('category_id')) ? (int) $categoryId : null + )), + StateFusionSelect::make('status')->required(), + PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(), + TextInput::make('contact_email')->email()->maxLength(255), + Toggle::make('is_featured')->default(false), + Select::make('country') + ->label('Country') + ->options(fn (): array => Country::nameOptions()) + ->searchable() + ->preload() + ->live() + ->afterStateUpdated(fn ($state, $set) => $set('city', null)) + ->nullable(), + Select::make('city') + ->label('City') + ->options(fn (Get $get): array => City::nameOptions($get('country'))) + ->searchable() + ->preload() + ->nullable(), + Map::make('location') + ->label('Location') + ->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled()) + ->draggable() + ->clickable() + ->autocomplete('city') + ->autocompleteReverse(true) + ->reverseGeocode([ + 'city' => '%L', + ]) + ->defaultLocation([41.0082, 28.9784]) + ->defaultZoom(10) + ->height('320px') + ->columnSpanFull(), + SpatieMediaLibraryFileUpload::make('images') + ->collection('listing-images') + ->multiple() + ->image() + ->reorderable(), + VideoFormSchema::listingSection(), + ]; + } + + public static function configureTable(Table $table, string $resourceClass): Table + { + return $table + ->columns([ + SpatieMediaLibraryImageColumn::make('images') + ->collection('listing-images') + ->circular(), + TextColumn::make('id')->sortable(), + TextColumn::make('title')->searchable()->sortable()->limit(40), + TextColumn::make('category.name')->label('Category')->sortable(), + TextColumn::make('user.email')->label('Owner')->searchable()->toggleable()->sortable(), + TextColumn::make('price') + ->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency()) + ->sortable(), + StateFusionSelectColumn::make('status')->sortable(), + IconColumn::make('is_featured')->boolean()->label('Featured')->sortable(), + TextColumn::make('city')->sortable(), + TextColumn::make('country')->sortable(), + TextColumn::make('created_at')->dateTime()->sortable(), + ]) + ->filters([ + StateFusionSelectFilter::make('status'), + SelectFilter::make('category_id') + ->label('Category') + ->relationship('category', 'name') + ->searchable() + ->preload(), + SelectFilter::make('user_id') + ->label('Owner') + ->relationship('user', 'email') + ->searchable() + ->preload(), + SelectFilter::make('country') + ->options(fn (): array => Country::nameOptions()) + ->searchable(), + SelectFilter::make('city') + ->options(fn (): array => City::nameOptions(null, false)) + ->searchable(), + TernaryFilter::make('is_featured')->label('Featured'), + Filter::make('created_at') + ->label('Created Date') + ->schema([ + DatePicker::make('from')->label('From'), + DatePicker::make('until')->label('Until'), + ]) + ->query(fn (Builder $query, array $data): Builder => $query + ->when($data['from'] ?? null, fn (Builder $builder, string $date): Builder => $builder->whereDate('created_at', '>=', $date)) + ->when($data['until'] ?? null, fn (Builder $builder, string $date): Builder => $builder->whereDate('created_at', '<=', $date))), + Filter::make('price') + ->label('Price Range') + ->schema([ + TextInput::make('min')->numeric()->label('Min'), + TextInput::make('max')->numeric()->label('Max'), + ]) + ->query(fn (Builder $query, array $data): Builder => $query + ->when($data['min'] ?? null, fn (Builder $builder, string $amount): Builder => $builder->where('price', '>=', (float) $amount)) + ->when($data['max'] ?? null, fn (Builder $builder, string $amount): Builder => $builder->where('price', '<=', (float) $amount))), + ]) + ->filtersLayout(FiltersLayout::AboveContentCollapsible) + ->filtersFormColumns(3) + ->filtersFormWidth('7xl') + ->persistFiltersInSession() + ->defaultSort('id', 'desc') + ->actions(ResourceTableActions::editActivityDelete($resourceClass)); + } +} diff --git a/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php b/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php index 43731d971..66a6bf271 100644 --- a/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php +++ b/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php @@ -4,8 +4,8 @@ namespace Modules\Listing\Support; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Component; use Illuminate\Support\Carbon; @@ -22,9 +22,6 @@ class ListingCustomFieldSchemaBuilder ->exists(); } - /** - * @return array - */ public static function formComponents(?int $categoryId): array { return ListingCustomField::query() @@ -38,10 +35,6 @@ class ListingCustomFieldSchemaBuilder ->all(); } - /** - * @param array $values - * @return array - */ public static function presentableValues(?int $categoryId, array $values): array { if ($values === []) { diff --git a/Modules/Listing/Support/ListingPanelHelper.php b/Modules/Listing/Support/ListingPanelHelper.php index bdba42345..59cad8202 100644 --- a/Modules/Listing/Support/ListingPanelHelper.php +++ b/Modules/Listing/Support/ListingPanelHelper.php @@ -2,7 +2,7 @@ namespace Modules\Listing\Support; -use App\Settings\GeneralSettings; +use Modules\Site\App\Settings\GeneralSettings; use Throwable; class ListingPanelHelper @@ -32,7 +32,7 @@ class ListingPanelHelper return self::currencyCodes()[0] ?? 'USD'; } - public static function normalizeCurrency(null | string $currency): string + public static function normalizeCurrency(?string $currency): string { $normalized = strtoupper(substr(trim((string) $currency), 0, 3)); $codes = self::currencyCodes(); diff --git a/app/Support/QuickListingCategorySuggester.php b/Modules/Listing/Support/QuickListingCategorySuggester.php similarity index 89% rename from app/Support/QuickListingCategorySuggester.php rename to Modules/Listing/Support/QuickListingCategorySuggester.php index 1cc2422d3..54d63efb9 100644 --- a/app/Support/QuickListingCategorySuggester.php +++ b/Modules/Listing/Support/QuickListingCategorySuggester.php @@ -1,6 +1,6 @@ , - * error: string|null - * } - */ public function suggestFromImage(UploadedFile $image): array { $provider = (string) config('quick-listing.ai_provider', 'openai'); @@ -39,11 +29,7 @@ class QuickListingCategorySuggester ]; } - $categories = Category::query() - ->where('is_active', true) - ->orderBy('sort_order') - ->orderBy('name') - ->get(['id', 'name', 'parent_id']); + $categories = Category::activeAiCatalog(); if ($categories->isEmpty()) { return [ @@ -131,10 +117,6 @@ class QuickListingCategorySuggester } } - /** - * @param Collection $categories - * @return Collection - */ private function buildCatalog(Collection $categories): Collection { $byId = $categories->keyBy('id'); @@ -156,4 +138,3 @@ class QuickListingCategorySuggester }); } } - diff --git a/Modules/Listing/resources/views/partials/index-content.blade.php b/Modules/Listing/resources/views/partials/index-content.blade.php index 814f964e7..9fba863bc 100644 --- a/Modules/Listing/resources/views/partials/index-content.blade.php +++ b/Modules/Listing/resources/views/partials/index-content.blade.php @@ -229,12 +229,12 @@

-