Compare commits

...

5 Commits
3.0 ... master

Author SHA1 Message Date
fatihalp
239fd0d2bf Fix public media routing for demo storage files 2026-03-28 02:34:47 +03:00
fatihalp
f06943ce9d feat: add User management resource with CRUD operations and activity logging
- Created UserResource for managing users with form and table configurations.
- Implemented pages for creating, editing, listing users, and viewing user activities.
- Added UserPlugin for resource registration in Filament admin panel.
- Introduced CSS styles for panel quick creation and listing filters.
- Developed JavaScript modules for handling listing filters and home slider functionality.
2026-03-23 01:39:30 +03:00
fatihalp
057620b715 Remove S3 storage support and standardize local media handling 2026-03-19 23:42:57 +03:00
fatihalp
6b3a8b8581 Refactor modules to SOLID structure 2026-03-14 01:57:30 +03:00
fatihalp
d2345cbeda Fix module seeders PSR-4 namespaces 2026-03-10 21:01:30 +03:00
216 changed files with 4214 additions and 7782 deletions

View File

@ -0,0 +1,31 @@
<?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::connection(config('activitylog.database_connection'))
->create(config('activitylog.table_name'), function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('log_name')->nullable();
$table->text('description');
$table->nullableMorphs('subject', 'subject');
$table->string('event')->nullable();
$table->nullableMorphs('causer', 'causer');
$table->json('properties')->nullable();
$table->uuid('batch_uuid')->nullable();
$table->timestamps();
$table->index('log_name');
});
}
public function down(): void
{
Schema::connection(config('activitylog.database_connection'))
->dropIfExists(config('activitylog.table_name'));
}
};

View File

@ -1,68 +0,0 @@
<?php
namespace Modules\Admin\Filament\Pages;
use App\Settings\GeneralSettings;
use App\Support\HomeSlideDefaults;
use BackedEnum;
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
{
protected static string $settings = GeneralSettings::class;
protected static ?string $title = 'Home Slides';
protected static ?string $navigationLabel = 'Home Slides';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-photo';
protected static string | UnitEnum | null $navigationGroup = 'Content';
protected static ?int $navigationSort = 2;
protected Width | string | null $maxContentWidth = Width::Full;
protected function mutateFormDataBeforeFill(array $data): array
{
return [
'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, MediaStorage::activeDisk()),
),
]);
}
private function defaultHomeSlides(): array
{
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
{
return HomeSlideDefaults::normalize($state, $defaultDisk);
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\CategoryResource;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\CityResource;
class EditCity extends EditRecord
{
protected static string $resource = CityResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\CityResource;
class ListCities extends ListRecords
{
protected static string $resource = CityResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\DistrictResource;
class EditDistrict extends EditRecord
{
protected static string $resource = DistrictResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\DistrictResource;
class ListDistricts extends ListRecords
{
protected static string $resource = DistrictResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -1,218 +0,0 @@
<?php
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\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
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\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 UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class ListingResource extends Resource
{
protected static ?string $model = Listing::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
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 () => Category::where('is_active', true)->pluck('name', 'id'))
->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::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable()
->preload()
->live()
->afterStateUpdated(fn ($state, $set) => $set('city', null))
->nullable(),
Select::make('city')
->label('City')
->options(function (Get $get): array {
$country = $get('country');
return City::query()
->where('is_active', true)
->when($country, fn (Builder $query, string $country): Builder => $query->whereHas('country', fn (Builder $countryQuery): Builder => $countryQuery->where('name', $country)))
->orderBy('name')
->pluck('name', 'name')
->all();
})
->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 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::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable(),
SelectFilter::make('city')
->options(fn (): array => City::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->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([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (Listing $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'),
'activities' => Pages\ListListingActivities::route('/{record}/activities'),
'edit' => Pages\EditListing::route('/{record}/edit'),
];
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\ListingResource;
class EditListing extends EditRecord
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\ListingResource;
class ListListings extends ListRecords
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -1,10 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\LocationResource;
class CreateLocation extends CreateRecord
{
protected static string $resource = LocationResource::class;
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\LocationResource;
class EditLocation extends EditRecord
{
protected static string $resource = LocationResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -1,10 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Modules\Admin\Filament\Resources\LocationResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListLocationActivities extends ListActivities
{
protected static string $resource = LocationResource::class;
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\LocationResource;
class ListLocations extends ListRecords
{
protected static string $resource = LocationResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\UserResource;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -1,7 +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;
@ -13,7 +13,6 @@ use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@ -21,13 +20,16 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Jeffgreco13\FilamentBreezy\BreezyCore;
use MWGuerra\FileManager\FileManagerPlugin;
use MWGuerra\FileManager\Filament\Pages\FileManager;
use Modules\Category\CategoryPlugin;
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;
use Modules\Admin\Filament\Resources\UserResource;
use Modules\Listing\ListingPlugin;
use Modules\Location\LocationPlugin;
use Modules\Site\App\Http\Middleware\BootstrapAppData;
use Modules\Site\SitePlugin;
use Modules\User\UserPlugin;
use Modules\Video\VideoPlugin;
use MWGuerra\FileManager\Filament\Pages\FileManager;
use MWGuerra\FileManager\FileManagerPlugin;
class AdminPanelProvider extends PanelProvider
{
@ -39,11 +41,6 @@ class AdminPanelProvider extends PanelProvider
->path('admin')
->login()
->colors(['primary' => Color::Blue])
->discoverResources(in: module_path('Admin', 'Filament/Resources'), for: 'Modules\\Admin\\Filament\\Resources')
->discoverResources(in: module_path('Video', 'Filament/Admin/Resources'), for: 'Modules\\Video\\Filament\\Admin\\Resources')
->discoverPages(in: module_path('Admin', 'Filament/Pages'), for: 'Modules\\Admin\\Filament\\Pages')
->discoverWidgets(in: module_path('Admin', 'Filament/Widgets'), for: 'Modules\\Admin\\Filament\\Widgets')
->renderHook(PanelsRenderHook::BODY_END, fn () => view('video::partials.video-upload-optimizer'))
->userMenuItems([
'view-site' => MenuItem::make()
->label('View Site')
@ -70,6 +67,12 @@ class AdminPanelProvider extends PanelProvider
->users([
'Admin' => 'a@a.com',
]),
CategoryPlugin::make(),
ListingPlugin::make(),
LocationPlugin::make(),
SitePlugin::make(),
UserPlugin::make(),
VideoPlugin::make(),
])
->pages([Dashboard::class])
->middleware([

View File

@ -1,4 +1,5 @@
<?php
namespace Modules\Admin\Providers;
use Illuminate\Support\ServiceProvider;
@ -7,11 +8,9 @@ class AdminServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Admin', 'database/migrations'));
$this->loadMigrationsFrom(module_path('Admin', 'Database/migrations'));
}
public function register(): void
{
$this->app->register(AdminPanelProvider::class);
}
{}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Modules\Admin\Support\Filament;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
final class ResourceTableActions
{
public static function editDelete(): array
{
return [
EditAction::make(),
DeleteAction::make(),
];
}
public static function editActivityDelete(string $resourceClass, array $afterActivity = []): array
{
return [
EditAction::make(),
self::activities($resourceClass),
...$afterActivity,
DeleteAction::make(),
];
}
public static function activities(string $resourceClass): Action
{
return Action::make('activities')
->icon('heroicon-o-clock')
->url(fn ($record): string => $resourceClass::getUrl('activities', ['record' => $record]));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Modules\Admin\Support\Filament;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
final class ResourceTableColumns
{
public static function id(string $name = 'id'): TextColumn
{
return TextColumn::make($name)->sortable();
}
public static function activeIcon(string $name = 'is_active', string $label = 'Active'): IconColumn
{
return IconColumn::make($name)->label($label)->boolean();
}
public static function createdAtHidden(string $name = 'created_at'): TextColumn
{
return TextColumn::make($name)
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Modules\Category;
use Filament\Contracts\Plugin;
use Filament\Panel;
final class CategoryPlugin implements Plugin
{
public function getId(): string
{
return 'category';
}
public static function make(): static
{
return app(static::class);
}
public function register(Panel $panel): void
{
$panel->discoverResources(
in: module_path('Category', 'Filament/Admin/Resources'),
for: 'Modules\\Category\\Filament\\Admin\\Resources',
);
}
public function boot(Panel $panel): void {}
}

View File

@ -1,4 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

View File

@ -1,27 +1,29 @@
<?php
namespace Modules\Admin\Filament\Resources;
namespace Modules\Category\Filament\Admin\Resources;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Category\Models\Category;
use Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
use UnitEnum;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-tag';
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-tag';
protected static string|UnitEnum|null $navigationGroup = 'Catalog';
public static function form(Schema $schema): Schema
{
@ -30,7 +32,7 @@ class CategoryResource extends Resource
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
TextInput::make('description')->maxLength(500),
TextInput::make('icon')->maxLength(100),
Select::make('parent_id')->label('Parent Category')->options(fn () => Category::whereNull('parent_id')->pluck('name', 'id'))->nullable()->searchable(),
Select::make('parent_id')->label('Parent Category')->options(fn (): array => Category::rootIdNameOptions())->nullable()->searchable(),
TextInput::make('sort_order')->numeric()->default(0),
Toggle::make('is_active')->default(true),
]);
@ -39,15 +41,15 @@ class CategoryResource extends Resource
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
ResourceTableColumns::id(),
TextColumn::make('name')
->searchable()
->formatStateUsing(fn (string $state, Category $record): string => $record->parent_id === null ? $state : '↳ ' . $state)
->formatStateUsing(fn (string $state, Category $record): string => $record->parent_id === null ? $state : '↳ '.$state)
->weight(fn (Category $record): string => $record->parent_id === null ? 'semi-bold' : 'normal'),
TextColumn::make('parent.name')->label('Parent')->default('-'),
TextColumn::make('children_count')->label('Subcategories'),
TextColumn::make('listings_count')->label('Listings'),
IconColumn::make('is_active')->boolean(),
ResourceTableColumns::activeIcon(),
TextColumn::make('sort_order')->sortable(),
])->actions([
Action::make('toggleChildren')
@ -55,11 +57,7 @@ class CategoryResource extends Resource
->icon(fn (Category $record, Pages\ListCategories $livewire): string => $livewire->hasExpandedChildren($record) ? 'heroicon-o-chevron-down' : 'heroicon-o-chevron-right')
->action(fn (Category $record, Pages\ListCategories $livewire) => $livewire->toggleChildren($record))
->visible(fn (Category $record): bool => $record->parent_id === null && $record->children_count > 0),
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (Category $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
...ResourceTableActions::editActivityDelete(static::class),
]);
}

View File

@ -1,8 +1,9 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Category\Filament\Admin\Resources\CategoryResource;
class CreateCategory extends CreateRecord
{

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Category\Filament\Admin\Resources\CategoryResource;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [DeleteAction::make()];
}
}

View File

@ -1,11 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Url;
use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Category\Filament\Admin\Resources\CategoryResource;
use Modules\Category\Models\Category;
class ListCategories extends ListRecords

View File

@ -1,7 +1,8 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Modules\Admin\Filament\Resources\CategoryResource;
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
use Modules\Category\Filament\Admin\Resources\CategoryResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListCategoryActivities extends ListActivities

View File

@ -1,4 +1,5 @@
<?php
namespace Modules\Category\Models;
use Illuminate\Database\Eloquent\Builder;
@ -32,6 +33,7 @@ class Category extends Model
];
protected $fillable = ['name', 'slug', 'description', 'icon', 'parent_id', 'level', 'sort_order', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function getActivitylogOptions(): LogOptions
@ -103,6 +105,91 @@ class Category extends Model
->get(['id', 'name']);
}
public static function activeIdNameOptions(): array
{
return static::query()
->active()
->ordered()
->pluck('name', 'id')
->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()
->active()
->whereNull('parent_id')
->ordered()
->pluck('name', 'id')
->all();
}
public static function themePills(int $limit = 8): Collection
{
return static::query()

View File

@ -1,4 +1,5 @@
<?php
namespace Modules\Category\Providers;
use Illuminate\Support\ServiceProvider;
@ -9,10 +10,11 @@ class CategoryServiceProvider extends ServiceProvider
public function boot(): void
{
$this->loadMigrationsFrom(module_path($this->moduleName, 'database/migrations'));
$this->loadMigrationsFrom(module_path($this->moduleName, 'Database/migrations'));
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category');
}
public function register(): void {}
public function register(): void
{}
}

View File

@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Modules\Conversation\App\Events\ConversationReadUpdated;
use Modules\Conversation\App\Events\InboxMessageCreated;
@ -14,7 +13,6 @@ use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Conversation\App\Support\QuickMessageCatalog;
use Modules\Listing\Models\Listing;
use Throwable;
class ConversationController extends Controller
{
@ -28,28 +26,23 @@ class ConversationController extends Controller
$conversations = collect();
$selectedConversation = null;
if ($userId && $this->messagingTablesReady()) {
try {
[
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'markedRead' => $markedRead,
] = $this->resolveInboxState(
$userId,
$messageFilter,
$request->integer('conversation'),
true,
);
if ($userId) {
[
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'markedRead' => $markedRead,
] = $this->resolveInboxState(
$userId,
$messageFilter,
$request->integer('conversation'),
true,
);
if ($selectedConversation && $markedRead) {
broadcast(new ConversationReadUpdated(
$userId,
$selectedConversation->readPayloadFor($userId),
));
}
} catch (Throwable) {
$conversations = collect();
$selectedConversation = null;
if ($selectedConversation && $markedRead) {
broadcast(new ConversationReadUpdated(
$userId,
$selectedConversation->readPayloadFor($userId),
));
}
}
@ -64,8 +57,6 @@ class ConversationController extends Controller
public function state(Request $request): JsonResponse
{
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
$userId = (int) $request->user()->getKey();
$messageFilter = $this->resolveMessageFilter($request);
@ -91,14 +82,6 @@ class ConversationController extends Controller
public function start(Request $request, Listing $listing): RedirectResponse | JsonResponse
{
if (! $this->messagingTablesReady()) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Messaging is not available yet.'], 503);
}
return back()->with('error', 'Messaging is not available yet.');
}
$user = $request->user();
if (! $listing->user_id) {
@ -124,8 +107,7 @@ class ConversationController extends Controller
}
$conversation = Conversation::openForListingBuyer($listing, (int) $user->getKey());
$user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
$user->rememberListing($listing);
$message = null;
if ($messageBody !== '') {
@ -144,14 +126,6 @@ class ConversationController extends Controller
public function send(Request $request, Conversation $conversation): RedirectResponse | JsonResponse
{
if (! $this->messagingTablesReady()) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Messaging is not available yet.'], 503);
}
return back()->with('error', 'Messaging is not available yet.');
}
$user = $request->user();
$userId = (int) $user->getKey();
@ -187,8 +161,6 @@ class ConversationController extends Controller
public function read(Request $request, Conversation $conversation): JsonResponse
{
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
$userId = (int) $request->user()->getKey();
abort_unless($conversation->hasParticipant($userId), 403);
@ -310,12 +282,4 @@ class ConversationController extends Controller
}
}
private function messagingTablesReady(): bool
{
try {
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
} catch (Throwable) {
return false;
}
}
}

View File

@ -284,6 +284,42 @@ class Conversation extends Model
return is_null($value) ? null : (int) $value;
}
public static function detailForBuyerListing(int $listingId, int $buyerId): ?self
{
$conversationId = static::buyerListingConversationId($listingId, $buyerId);
if (! $conversationId) {
return null;
}
$conversation = static::query()
->forUser($buyerId)
->find($conversationId);
if (! $conversation) {
return null;
}
$conversation->loadThread();
$conversation->loadCount([
'messages as unread_count' => fn (Builder $query) => $query
->where('sender_id', '!=', $buyerId)
->whereNull('read_at'),
]);
return $conversation;
}
public static function listingMapForBuyer(int $buyerId, array $listingIds = []): array
{
return static::query()
->where('buyer_id', $buyerId)
->when($listingIds !== [], fn (Builder $query): Builder => $query->whereIn('listing_id', $listingIds))
->pluck('id', 'listing_id')
->map(fn ($conversationId): int => (int) $conversationId)
->all();
}
public static function unreadCountForUser(int $userId): int
{
return (int) ConversationMessage::query()

View File

@ -9,7 +9,7 @@ class ConversationServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Conversation', 'database/migrations'));
$this->loadMigrationsFrom(module_path('Conversation', 'Database/migrations'));
$this->loadRoutesFrom(module_path('Conversation', 'routes/web.php'));
$this->loadViewsFrom(module_path('Conversation', 'resources/views'), 'conversation');
@ -18,7 +18,5 @@ class ConversationServiceProvider extends ServiceProvider
});
}
public function register(): void
{
}
public function register(): void {}
}

View File

@ -3,7 +3,6 @@
namespace Modules\Conversation\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing;
@ -14,10 +13,6 @@ class ConversationDemoSeeder extends Seeder
{
public function run(): void
{
if (! $this->conversationTablesExist()) {
return;
}
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
@ -73,11 +68,6 @@ class ConversationDemoSeeder extends Seeder
}
}
private function conversationTablesExist(): bool
{
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
}
private function seedConversationThread(
User $seller,
User $buyer,
@ -107,7 +97,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(),

View File

@ -0,0 +1,42 @@
<?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('conversations', function (Blueprint $table): void {
$table->id();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('last_message_at')->nullable();
$table->timestamps();
$table->unique(['listing_id', 'buyer_id']);
$table->index(['seller_id', 'last_message_at']);
$table->index(['buyer_id', 'last_message_at']);
});
Schema::create('conversation_messages', function (Blueprint $table): void {
$table->id();
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
$table->text('body');
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
$table->index(['conversation_id', 'read_at']);
});
}
public function down(): void
{
Schema::dropIfExists('conversation_messages');
Schema::dropIfExists('conversations');
}
};

View File

@ -1,46 +0,0 @@
<?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
{
if (! Schema::hasTable('conversations')) {
Schema::create('conversations', function (Blueprint $table): void {
$table->id();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('last_message_at')->nullable();
$table->timestamps();
$table->unique(['listing_id', 'buyer_id']);
$table->index(['seller_id', 'last_message_at']);
$table->index(['buyer_id', 'last_message_at']);
});
}
if (! Schema::hasTable('conversation_messages')) {
Schema::create('conversation_messages', function (Blueprint $table): void {
$table->id();
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
$table->text('body');
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
$table->index(['conversation_id', 'read_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('conversation_messages');
Schema::dropIfExists('conversations');
}
};

View File

@ -5,10 +5,10 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'inbox'])
@include('panel::partials.sidebar', ['activeMenu' => 'inbox'])
<section class="space-y-4">
@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

View File

@ -25,7 +25,7 @@ class DemoServiceProvider extends ServiceProvider
public function boot(): void
{
$this->guardConfiguration();
$this->loadMigrationsFrom(module_path('Demo', 'database/migrations'));
$this->loadMigrationsFrom(module_path('Demo', 'Database/migrations'));
$this->loadRoutesFrom(module_path('Demo', 'routes/web.php'));
}

View File

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

View File

@ -5,14 +5,12 @@ namespace Modules\Favorite\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Schema;
use Modules\Category\Models\Category;
use Modules\Conversation\App\Models\Conversation;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\AuthRedirector;
use Throwable;
class FavoriteController extends Controller
{
@ -40,13 +38,7 @@ class FavoriteController extends Controller
$user = $request->user();
$requiresLogin = ! $user;
$categories = collect();
if ($this->tableExists('categories')) {
$categories = Category::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name']);
}
$categories = Category::filterOptions();
$favoriteListings = $this->emptyPaginator();
$favoriteSearches = $this->emptyPaginator();
@ -54,64 +46,22 @@ class FavoriteController extends Controller
$buyerConversationListingMap = [];
if ($user && $activeTab === 'listings') {
try {
if ($this->tableExists('favorite_listings')) {
$favoriteListings = $user->favoriteListings()
->with(['category:id,name', 'user:id,name'])
->wherePivot('created_at', '>=', now()->subYear())
->when($statusFilter === 'active', fn ($query) => $query->where('status', 'active'))
->when($selectedCategoryId, fn ($query) => $query->where('category_id', $selectedCategoryId))
->orderByPivot('created_at', 'desc')
->paginate(10)
->withQueryString();
}
$favoriteListings = $user->favoriteListingsPage($statusFilter, $selectedCategoryId);
if (
$favoriteListings->isNotEmpty()
&& $this->tableExists('conversations')
) {
$userId = (int) $user->getKey();
$buyerConversationListingMap = Conversation::query()
->where('buyer_id', $userId)
->whereIn('listing_id', $favoriteListings->pluck('id')->all())
->pluck('id', 'listing_id')
->map(fn ($conversationId) => (int) $conversationId)
->all();
}
} catch (Throwable) {
$favoriteListings = $this->emptyPaginator();
$buyerConversationListingMap = [];
if ($favoriteListings->isNotEmpty()) {
$buyerConversationListingMap = Conversation::listingMapForBuyer(
(int) $user->getKey(),
$favoriteListings->pluck('id')->all(),
);
}
}
if ($user && $activeTab === 'searches') {
try {
if ($this->tableExists('favorite_searches')) {
$favoriteSearches = $user->favoriteSearches()
->with('category:id,name')
->latest()
->paginate(10)
->withQueryString();
}
} catch (Throwable) {
$favoriteSearches = $this->emptyPaginator();
}
$favoriteSearches = $user->favoriteSearchesPage();
}
if ($user && $activeTab === 'sellers') {
try {
if ($this->tableExists('favorite_sellers')) {
$favoriteSellers = $user->favoriteSellers()
->withCount([
'listings as active_listings_count' => fn ($query) => $query->where('status', 'active'),
])
->orderByPivot('created_at', 'desc')
->paginate(10)
->withQueryString();
}
} catch (Throwable) {
$favoriteSellers = $this->emptyPaginator();
}
$favoriteSellers = $user->favoriteSellersPage();
}
return view('favorite::index', [
@ -163,24 +113,7 @@ class FavoriteController extends Controller
return back()->with('error', 'Select at least one filter before saving a search.');
}
$signature = FavoriteSearch::signatureFor($filters);
$categoryName = null;
if (isset($filters['category'])) {
$categoryName = Category::query()->whereKey($filters['category'])->value('name');
}
$label = FavoriteSearch::labelFor($filters, is_string($categoryName) ? $categoryName : null);
$favoriteSearch = $request->user()->favoriteSearches()->firstOrCreate(
['signature' => $signature],
[
'label' => $label,
'search_term' => $filters['search'] ?? null,
'category_id' => $filters['category'] ?? null,
'filters' => $filters,
]
);
$favoriteSearch = FavoriteSearch::storeForUser($request->user(), $filters);
if (! $favoriteSearch->wasRecentlyCreated) {
return back()->with('success', 'This search is already in your favorites.');
@ -200,15 +133,6 @@ class FavoriteController extends Controller
return back()->with('success', 'Saved search deleted.');
}
private function tableExists(string $table): bool
{
try {
return Schema::hasTable($table);
} catch (Throwable) {
return false;
}
}
private function emptyPaginator(): LengthAwarePaginator
{
return new LengthAwarePaginator([], 0, 10, 1, [

View File

@ -53,4 +53,36 @@ class FavoriteSearch extends Model
return $labelParts !== [] ? implode(' · ', $labelParts) : 'Filtered search';
}
public static function isSavedForUser(User $user, array $filters): bool
{
$normalized = static::normalizeFilters($filters);
if ($normalized === []) {
return false;
}
return $user->favoriteSearches()
->where('signature', static::signatureFor($normalized))
->exists();
}
public static function storeForUser(User $user, array $filters): self
{
$normalized = static::normalizeFilters($filters);
$signature = static::signatureFor($normalized);
$categoryName = isset($normalized['category'])
? Category::query()->whereKey($normalized['category'])->value('name')
: null;
return $user->favoriteSearches()->firstOrCreate(
['signature' => $signature],
[
'label' => static::labelFor($normalized, is_string($categoryName) ? $categoryName : null),
'search_term' => $normalized['search'] ?? null,
'category_id' => $normalized['category'] ?? null,
'filters' => $normalized,
]
);
}
}

View File

@ -8,12 +8,10 @@ class FavoriteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Favorite', 'database/migrations'));
$this->loadMigrationsFrom(module_path('Favorite', 'Database/migrations'));
$this->loadRoutesFrom(module_path('Favorite', 'routes/web.php'));
$this->loadViewsFrom(module_path('Favorite', 'resources/views'), 'favorite');
}
public function register(): void
{
}
public function register(): void {}
}

View File

@ -4,8 +4,6 @@ namespace Modules\Favorite\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Category\Models\Category;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
@ -16,10 +14,6 @@ class FavoriteDemoSeeder extends Seeder
{
public function run(): void
{
if (! $this->favoriteTablesExist()) {
return;
}
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
@ -30,8 +24,11 @@ class FavoriteDemoSeeder extends Seeder
return;
}
DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete();
DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete();
$users->each(function (User $user): void {
$user->favoriteListings()->detach();
$user->favoriteSellers()->detach();
});
FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
foreach ($users as $index => $user) {
@ -56,38 +53,25 @@ class FavoriteDemoSeeder extends Seeder
}
}
private function favoriteTablesExist(): bool
{
return Schema::hasTable('favorite_listings')
&& Schema::hasTable('favorite_sellers')
&& Schema::hasTable('favorite_searches');
}
private function seedFavoriteListings(User $user, Collection $listings): void
{
$rows = $listings
$payload = $listings
->values()
->map(function (Listing $listing, int $index) use ($user): array {
->mapWithKeys(function (Listing $listing, int $index): array {
$timestamp = now()->subHours(8 + ($index * 3));
return [
'user_id' => $user->getKey(),
'listing_id' => $listing->getKey(),
return [$listing->getKey() => [
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
]];
})
->all();
if ($rows === []) {
if ($payload === []) {
return;
}
DB::table('favorite_listings')->upsert(
$rows,
['user_id', 'listing_id'],
['updated_at']
);
$user->favoriteListings()->syncWithoutDetaching($payload);
}
private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void
@ -96,16 +80,12 @@ class FavoriteDemoSeeder extends Seeder
return;
}
DB::table('favorite_sellers')->upsert(
[[
'user_id' => $user->getKey(),
'seller_id' => $seller->getKey(),
$user->favoriteSellers()->syncWithoutDetaching([
$seller->getKey() => [
'created_at' => $timestamp,
'updated_at' => $timestamp,
]],
['user_id', 'seller_id'],
['updated_at']
);
],
]);
}
private function seedFavoriteSearches(User $user, array $payloads): void

View File

@ -0,0 +1,49 @@
<?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('favorite_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'listing_id']);
});
Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'seller_id']);
});
Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('search_term')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->json('filters')->nullable();
$table->string('signature', 64);
$table->timestamps();
$table->unique(['user_id', 'signature']);
});
}
public function down(): void
{
Schema::dropIfExists('favorite_searches');
Schema::dropIfExists('favorite_sellers');
Schema::dropIfExists('favorite_listings');
}
};

View File

@ -1,55 +0,0 @@
<?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
{
if (! Schema::hasTable('favorite_listings')) {
Schema::create('favorite_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'listing_id']);
});
}
if (! Schema::hasTable('favorite_sellers')) {
Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'seller_id']);
});
}
if (! Schema::hasTable('favorite_searches')) {
Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('search_term')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->json('filters')->nullable();
$table->string('signature', 64);
$table->timestamps();
$table->unique(['user_id', 'signature']);
});
}
}
public function down(): void
{
Schema::dropIfExists('favorite_searches');
Schema::dropIfExists('favorite_sellers');
Schema::dropIfExists('favorite_listings');
}
};

View File

@ -5,7 +5,7 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab])
@include('panel::partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab])
<section class="bg-white border border-slate-200">
@if($requiresLogin ?? false)

View File

@ -4,7 +4,6 @@ namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
@ -107,10 +106,6 @@ class ListingSeeder extends Seeder
private function resolveCountries(): Collection
{
if (! class_exists(Country::class) || ! Schema::hasTable('countries')) {
return collect();
}
return Country::query()
->where('is_active', true)
->orderBy('name')
@ -120,10 +115,6 @@ class ListingSeeder extends Seeder
private function resolveTurkeyCities(): Collection
{
if (! class_exists(City::class) || ! Schema::hasTable('cities') || ! Schema::hasTable('countries')) {
return collect(['Istanbul', 'Ankara', 'Izmir', 'Bursa', 'Antalya']);
}
$turkey = Country::query()
->where('code', 'TR')
->first(['id']);

View File

@ -1,4 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -30,10 +31,26 @@ return new class extends Migration
$table->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');
}
};

View File

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

View File

@ -29,4 +29,9 @@ return new class extends Migration
$table->nullableTimestamps();
});
}
public function down(): void
{
Schema::dropIfExists('media');
}
};

View File

@ -1,30 +1,32 @@
<?php
namespace Modules\Admin\Filament\Resources;
namespace Modules\Listing\Filament\Admin\Resources;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
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\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Category\Models\Category;
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
use Modules\Listing\Models\ListingCustomField;
use UnitEnum;
class ListingCustomFieldResource extends Resource
{
protected static ?string $model = ListingCustomField::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static string|UnitEnum|null $navigationGroup = 'Catalog';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
@ -35,21 +37,7 @@ class ListingCustomFieldResource extends Resource
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void {
$baseName = \Illuminate\Support\Str::slug((string) $state, '_');
$baseName = $baseName !== '' ? $baseName : 'custom_field';
$name = $baseName;
$counter = 1;
while (ListingCustomField::query()
->where('name', $name)
->when($record, fn ($query) => $query->whereKeyNot($record->getKey()))
->exists()) {
$name = "{$baseName}_{$counter}";
$counter++;
}
$set('name', $name);
$set('name', ListingCustomField::uniqueNameFromLabel((string) $state, $record));
}),
TextInput::make('name')
->required()
@ -63,11 +51,7 @@ class ListingCustomFieldResource extends Resource
->live(),
Select::make('category_id')
->label('Category')
->options(fn (): array => Category::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->all())
->options(fn (): array => Category::activeIdNameOptions())
->searchable()
->preload()
->nullable()
@ -106,10 +90,7 @@ class ListingCustomFieldResource extends Resource
TextColumn::make('sort_order')->sortable(),
])
->defaultSort('id', 'desc')
->actions([
EditAction::make(),
DeleteAction::make(),
]);
->actions(ResourceTableActions::editDelete());
}
public static function getPages(): array

View File

@ -1,9 +1,9 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
class CreateListingCustomField extends CreateRecord
{

View File

@ -1,10 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
class EditListingCustomField extends EditRecord
{

View File

@ -1,10 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
class ListListingCustomFields extends ListRecords
{

View File

@ -0,0 +1,41 @@
<?php
namespace Modules\Listing\Filament\Admin\Resources;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\Filament\AdminListingResourceSchema;
use UnitEnum;
class ListingResource extends Resource
{
protected static ?string $model = Listing::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static string|UnitEnum|null $navigationGroup = 'Catalog';
public static function form(Schema $schema): Schema
{
return $schema->schema(AdminListingResourceSchema::form());
}
public static function table(Table $table): Table
{
return AdminListingResourceSchema::configureTable($table, static::class);
}
public static function getPages(): array
{
return [
'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'),
'activities' => Pages\ListListingActivities::route('/{record}/activities'),
'edit' => Pages\EditListing::route('/{record}/edit'),
];
}
}

View File

@ -1,8 +1,9 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\ListingResource;
use Modules\Listing\Filament\Admin\Resources\ListingResource;
class CreateListing extends CreateRecord
{

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Listing\Filament\Admin\Resources\ListingResource;
class EditListing extends EditRecord
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array
{
return [DeleteAction::make()];
}
}

View File

@ -1,7 +1,8 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
use Modules\Admin\Filament\Resources\ListingResource;
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
use Modules\Listing\Filament\Admin\Resources\ListingResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListListingActivities extends ListActivities

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Listing\Filament\Admin\Resources\ListingResource;
class ListListings extends ListRecords
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array
{
return [CreateAction::make()];
}
}

View File

@ -1,5 +1,6 @@
<?php
namespace Modules\Admin\Filament\Widgets;
namespace Modules\Listing\Filament\Admin\Widgets;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
@ -13,31 +14,27 @@ class ListingOverview extends StatsOverviewWidget
protected function getStats(): array
{
$totalListings = Listing::query()->count();
$activeListings = Listing::query()->where('status', 'active')->count();
$pendingListings = Listing::query()->where('status', 'pending')->count();
$featuredListings = Listing::query()->where('is_featured', true)->count();
$createdToday = Listing::query()->where('created_at', '>=', now()->startOfDay())->count();
$stats = Listing::overviewStats();
$featuredRatio = $totalListings > 0
? number_format(($featuredListings / $totalListings) * 100, 1).'% of all listings'
$featuredRatio = $stats['total'] > 0
? number_format(($stats['featured'] / $stats['total']) * 100, 1).'% of all listings'
: '0.0% of all listings';
return [
Stat::make('Total Listings', number_format($totalListings))
Stat::make('Total Listings', number_format($stats['total']))
->description('All listings in the system')
->icon('heroicon-o-clipboard-document-list')
->color('primary'),
Stat::make('Active Listings', number_format($activeListings))
->description(number_format($pendingListings).' pending review')
Stat::make('Active Listings', number_format($stats['active']))
->description(number_format($stats['pending']).' pending review')
->descriptionIcon('heroicon-o-clock')
->icon('heroicon-o-check-circle')
->color('success'),
Stat::make('Created Today', number_format($createdToday))
Stat::make('Created Today', number_format($stats['created_today']))
->description('New listings added today')
->icon('heroicon-o-calendar-days')
->color('info'),
Stat::make('Featured Listings', number_format($featuredListings))
Stat::make('Featured Listings', number_format($stats['featured']))
->description($featuredRatio)
->icon('heroicon-o-star')
->color('warning'),

View File

@ -1,5 +1,6 @@
<?php
namespace Modules\Admin\Filament\Widgets;
namespace Modules\Listing\Filament\Admin\Widgets;
use Filament\Widgets\ChartWidget;
use Modules\Listing\Models\Listing;
@ -8,6 +9,8 @@ class ListingsTrendChart extends ChartWidget
{
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
protected ?string $heading = 'Listing Creation Trend';
protected ?string $description = 'Daily listing volume by selected period.';
@ -24,39 +27,20 @@ class ListingsTrendChart extends ChartWidget
protected function getData(): array
{
$days = (int) ($this->filter ?? '30');
$startDate = now()->startOfDay()->subDays($days - 1);
$countsByDate = Listing::query()
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $startDate)
->groupBy('day')
->orderBy('day')
->pluck('total', 'day')
->all();
$labels = [];
$data = [];
for ($index = 0; $index < $days; $index++) {
$date = $startDate->copy()->addDays($index);
$dateKey = $date->toDateString();
$labels[] = $date->format('M j');
$data[] = (int) ($countsByDate[$dateKey] ?? 0);
}
$trend = Listing::creationTrend($days);
return [
'datasets' => [
[
'label' => 'Listings',
'data' => $data,
'data' => $trend['data'],
'fill' => true,
'borderColor' => '#2563eb',
'backgroundColor' => 'rgba(37, 99, 235, 0.12)',
'tension' => 0.35,
],
],
'labels' => $labels,
'labels' => $trend['labels'],
];
}

View File

@ -1,18 +1,15 @@
<?php
namespace Modules\Listing\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Conversation\App\Models\Conversation;
use Modules\Favorite\App\Models\FavoriteSearch;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Location\Models\Country;
use Modules\Theme\Support\ThemeManager;
use Throwable;
class ListingController extends Controller
{
@ -53,19 +50,13 @@ class ListingController extends Controller
$sort = 'smart';
}
$countries = collect();
$cities = collect();
$selectedCountryName = null;
$selectedCityName = null;
$this->resolveLocationFilters(
$countryId,
$cityId,
$countries,
$cities,
$selectedCountryName,
$selectedCityName
);
$locationSelection = Country::browseSelection($countryId, $cityId);
$countryId = $locationSelection['country_id'];
$cityId = $locationSelection['city_id'];
$countries = $locationSelection['countries'];
$cities = $locationSelection['cities'];
$selectedCountryName = $locationSelection['selected_country_name'];
$selectedCityName = $locationSelection['selected_city_name'];
$listingDirectory = Category::listingDirectory($categoryId);
@ -109,29 +100,13 @@ class ListingController extends Controller
if (auth()->check()) {
$userId = (int) auth()->id();
$favoriteListingIds = auth()->user()
->favoriteListings()
->pluck('listings.id')
->all();
$favoriteListingIds = auth()->user()->favoriteListingIds();
$conversationListingMap = Conversation::listingMapForBuyer($userId);
$conversationListingMap = Conversation::query()
->where('buyer_id', $userId)
->pluck('id', 'listing_id')
->map(fn ($conversationId) => (int) $conversationId)
->all();
$filters = FavoriteSearch::normalizeFilters([
$isCurrentSearchSaved = FavoriteSearch::isSavedForUser(auth()->user(), [
'search' => $search,
'category' => $categoryId,
]);
if ($filters !== []) {
$signature = FavoriteSearch::signatureFor($filters);
$isCurrentSearchSaved = auth()->user()
->favoriteSearches()
->where('signature', $signature)
->exists();
}
}
return view($this->themes->view('listing', 'index'), compact(
@ -159,13 +134,7 @@ class ListingController extends Controller
public function show(Listing $listing)
{
if (
Schema::hasColumn('listings', 'view_count')
&& (! auth()->check() || (int) auth()->id() !== (int) $listing->user_id)
) {
$listing->increment('view_count');
$listing->refresh();
}
$listing->trackViewBy(auth()->id());
$listing->loadMissing([
'user:id,name,email',
@ -193,10 +162,7 @@ class ListingController extends Controller
if (auth()->check()) {
$userId = (int) auth()->id();
$isListingFavorited = auth()->user()
->favoriteListings()
->whereKey($listing->getKey())
->exists();
$isListingFavorited = in_array((int) $listing->getKey(), auth()->user()->favoriteListingIds(), true);
if ($listing->user_id) {
$isSellerFavorited = auth()->user()
@ -206,25 +172,10 @@ class ListingController extends Controller
}
if ($listing->user_id && (int) $listing->user_id !== $userId) {
$existingConversationId = Conversation::buyerListingConversationId(
$detailConversation = Conversation::detailForBuyerListing(
(int) $listing->getKey(),
$userId,
);
if ($existingConversationId) {
$detailConversation = Conversation::query()
->forUser($userId)
->find($existingConversationId);
if ($detailConversation) {
$detailConversation->loadThread();
$detailConversation->loadCount([
'messages as unread_count' => fn ($query) => $query
->where('sender_id', '!=', $userId)
->whereNull('read_at'),
]);
}
}
}
}
@ -261,81 +212,4 @@ class ListingController extends Controller
->route('panel.listings.create')
->with('success', 'You were redirected to the listing creation screen.');
}
private function resolveLocationFilters(
?int &$countryId,
?int &$cityId,
Collection &$countries,
Collection &$cities,
?string &$selectedCountryName,
?string &$selectedCityName
): void {
try {
if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) {
return;
}
$countries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name']);
$selectedCountry = $countryId
? $countries->firstWhere('id', $countryId)
: null;
if (! $selectedCountry && $countryId) {
$selectedCountry = Country::query()->whereKey($countryId)->first(['id', 'name']);
}
$selectedCity = null;
if ($cityId) {
$selectedCity = City::query()->whereKey($cityId)->first(['id', 'name', 'country_id']);
if (! $selectedCity) {
$cityId = null;
}
}
if ($selectedCity && ! $selectedCountry) {
$countryId = (int) $selectedCity->country_id;
$selectedCountry = Country::query()->whereKey($countryId)->first(['id', 'name']);
}
if ($selectedCountry) {
$selectedCountryName = (string) $selectedCountry->name;
$cities = City::query()
->where('country_id', $selectedCountry->id)
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($cities->isEmpty()) {
$cities = City::query()
->where('country_id', $selectedCountry->id)
->orderBy('name')
->get(['id', 'name', 'country_id']);
}
} else {
$countryId = null;
$cities = collect();
}
if ($selectedCity) {
if ($selectedCountry && (int) $selectedCity->country_id !== (int) $selectedCountry->id) {
$selectedCity = null;
$cityId = null;
} else {
$selectedCityName = (string) $selectedCity->name;
}
}
} catch (Throwable) {
$countryId = null;
$cityId = null;
$selectedCountryName = null;
$selectedCityName = null;
$countries = collect();
$cities = collect();
}
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Modules\Listing;
use Filament\Contracts\Plugin;
use Filament\Panel;
final class ListingPlugin implements Plugin
{
public function getId(): string
{
return 'listing';
}
public static function make(): static
{
return app(static::class);
}
public function register(Panel $panel): void
{
$panel
->discoverResources(
in: module_path('Listing', 'Filament/Admin/Resources'),
for: 'Modules\\Listing\\Filament\\Admin\\Resources',
)
->discoverWidgets(
in: module_path('Listing', 'Filament/Admin/Widgets'),
for: 'Modules\\Listing\\Filament\\Admin\\Widgets',
);
}
public function boot(Panel $panel): void {}
}

View File

@ -1,22 +1,27 @@
<?php
namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Support\ListingImageViewData;
use Modules\Conversation\App\Models\Conversation;
use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingImageViewData;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Site\App\Support\LocalMedia;
use Modules\User\App\Models\User;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
use Spatie\Image\Enums\Fit;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -60,23 +65,23 @@ class Listing extends Model implements HasMedia
public function category()
{
return $this->belongsTo(\Modules\Category\Models\Category::class);
return $this->belongsTo(Category::class);
}
public function user()
{
return $this->belongsTo(\Modules\User\App\Models\User::class);
return $this->belongsTo(User::class);
}
public function favoritedByUsers()
{
return $this->belongsToMany(\Modules\User\App\Models\User::class, 'favorite_listings')
return $this->belongsToMany(User::class, 'favorite_listings')
->withTimestamps();
}
public function conversations()
{
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
return $this->hasMany(Conversation::class);
}
public function videos()
@ -97,7 +102,7 @@ class Listing extends Model implements HasMedia
return $query->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 +132,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 +295,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 +312,97 @@ class Listing extends Model implements HasMedia
];
}
public static function activeCount(): int
{
return (int) static::query()
->active()
->count();
}
public static function overviewStats(): array
{
$counts = static::query()
->selectRaw('COUNT(*) as total')
->selectRaw("SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active")
->selectRaw("SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending")
->selectRaw('SUM(CASE WHEN is_featured = true THEN 1 ELSE 0 END) as featured')
->first();
return [
'total' => (int) ($counts?->total ?? 0),
'active' => (int) ($counts?->active ?? 0),
'pending' => (int) ($counts?->pending ?? 0),
'featured' => (int) ($counts?->featured ?? 0),
'created_today' => (int) static::query()
->where('created_at', '>=', now()->startOfDay())
->count(),
];
}
public static function creationTrend(int $days): array
{
$safeDays = max(1, $days);
$startDate = now()->startOfDay()->subDays($safeDays - 1);
$countsByDate = static::query()
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $startDate)
->groupBy('day')
->orderBy('day')
->pluck('total', 'day')
->all();
$labels = [];
$data = [];
for ($index = 0; $index < $safeDays; $index++) {
$date = $startDate->copy()->addDays($index);
$dateKey = $date->toDateString();
$labels[] = $date->format('M j');
$data[] = (int) ($countsByDate[$dateKey] ?? 0);
}
return [
'labels' => $labels,
'data' => $data,
];
}
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');
@ -389,6 +503,7 @@ class Listing extends Model implements HasMedia
return;
}
$disk = $this->mediaDisk();
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
$existingMediaItems = $this->getMedia('listing-images');
@ -398,7 +513,7 @@ class Listing extends Model implements HasMedia
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === 'public'
&& (string) $existingMedia->disk === $disk
) {
try {
if (is_file($existingMedia->getPath())) {
@ -410,12 +525,25 @@ class Listing extends Model implements HasMedia
}
$this->clearMediaCollection('listing-images');
$this->attachListingImage($absolutePath, $targetFileName, $disk);
}
public function attachListingImage(string $absolutePath, string $fileName, ?string $disk = null): void
{
if (! is_file($absolutePath)) {
return;
}
$targetDisk = is_string($disk) && trim($disk) !== ''
? trim($disk)
: $this->mediaDisk();
$this
->addMedia($absolutePath)
->usingFileName($targetFileName)
->usingFileName(trim($fileName))
->withCustomProperties(self::mediaCustomProperties())
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
->toMediaCollection('listing-images', $targetDisk);
}
public function statusValue(): string
@ -435,6 +563,44 @@ 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 trackViewBy(null|int|string $viewerId): void
{
if ((int) $this->user_id === (int) $viewerId) {
return;
}
$this->increment('view_count');
$this->refresh();
}
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 +626,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';
@ -479,7 +645,7 @@ class Listing extends Model implements HasMedia
public function registerMediaCollections(): void
{
$this->addMediaCollection('listing-images')->useDisk('public');
$this->addMediaCollection('listing-images')->useDisk($this->mediaDisk());
}
public function registerMediaConversions(?Media $media = null): void
@ -552,6 +718,37 @@ class Listing extends Model implements HasMedia
return str_contains($argv, 'db:seed') || str_contains($argv, '--seed');
}
private function mediaDisk(): string
{
return LocalMedia::disk();
}
public static function mediaCustomProperties(): array
{
$scope = static::mediaPathScope();
return $scope !== null
? ['path_scope' => $scope]
: [];
}
public static function mediaPathScope(): ?string
{
$connection = (string) config('database.default', 'pgsql');
$searchPath = config("database.connections.{$connection}.search_path");
$value = is_array($searchPath)
? implode('_', $searchPath)
: (string) $searchPath;
$scope = (string) Str::of($value)
->before(',')
->trim()
->lower()
->replaceMatches('/[^a-z0-9_]+/', '_')
->trim('_');
return $scope !== '' ? $scope : null;
}
protected function location(): Attribute
{
return Attribute::make(

View File

@ -4,15 +4,21 @@ namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
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 = [
@ -83,6 +89,24 @@ class ListingCustomField extends Model
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
}
public static function uniqueNameFromLabel(string $label, ?self $record = null): string
{
$baseName = Str::slug($label, '_');
$baseName = $baseName !== '' ? $baseName : 'custom_field';
$name = $baseName;
$counter = 1;
while (static::query()
->where('name', $name)
->when($record, fn (Builder $query): Builder => $query->whereKeyNot($record->getKey()))
->exists()) {
$name = "{$baseName}_{$counter}";
$counter++;
}
return $name;
}
public static function upsertSeeded(Category $category, array $attributes): self
{
return static::query()->updateOrCreate(
@ -100,4 +124,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();
}
}

View File

@ -1,4 +1,5 @@
<?php
namespace Modules\Listing\Providers;
use Illuminate\Support\ServiceProvider;
@ -6,14 +7,16 @@ use Illuminate\Support\ServiceProvider;
class ListingServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Listing';
protected string $moduleNameLower = 'listing';
public function boot(): void
{
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), $this->moduleNameLower);
$this->loadMigrationsFrom(module_path($this->moduleName, 'database/migrations'));
$this->loadMigrationsFrom(module_path($this->moduleName, 'Database/migrations'));
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
}
public function register(): void {}
public function register(): void
{}
}

View File

@ -0,0 +1,180 @@
<?php
namespace Modules\Listing\Support\Filament;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
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\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
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 Illuminate\Support\Str;
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\Location\Support\CountryCodeManager;
use Modules\Site\App\Support\LocalMedia;
use Modules\Video\Support\Filament\VideoFormSchema;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class AdminListingResourceSchema
{
public static function form(): array
{
return [
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', Str::slug($state).'-'.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')
->disk(fn (): string => LocalMedia::disk())
->customProperties(fn (): array => Listing::mediaCustomProperties())
->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));
}
}

View File

@ -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<int, Component>
*/
public static function formComponents(?int $categoryId): array
{
return ListingCustomField::query()
@ -38,10 +35,6 @@ class ListingCustomFieldSchemaBuilder
->all();
}
/**
* @param array<string, mixed> $values
* @return array<int, array{label: string, value: string}>
*/
public static function presentableValues(?int $categoryId, array $values): array
{
if ($values === []) {

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace App\Support;
namespace Modules\Listing\Support;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Http\UploadedFile;
@ -12,16 +12,6 @@ use function Laravel\Ai\agent;
class QuickListingCategorySuggester
{
/**
* @return array{
* detected: bool,
* category_id: int|null,
* confidence: float|null,
* reason: string,
* alternatives: array<int>,
* 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<int, Category> $categories
* @return Collection<int, array{id: int, path: string}>
*/
private function buildCatalog(Collection $categories): Collection
{
$byId = $categories->keyBy('id');
@ -156,4 +138,3 @@ class QuickListingCategorySuggester
});
}
}

View File

@ -229,12 +229,12 @@
</p>
</div>
<div class="listing-filter-card px-4 py-3 hidden lg:flex flex-col xl:flex-row xl:items-center gap-3">
<p class="text-sm text-slate-700 mr-auto">
<div class="listing-results-bar listing-filter-card hidden lg:flex">
<p class="listing-results-meta">
<strong>{{ number_format($resultListingsCount) }}</strong>
{{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }}
</p>
<div class="flex flex-wrap items-center gap-2">
<div class="listing-results-actions">
@auth
<form method="POST" action="{{ route('favorites.searches.store') }}">
@csrf
@ -276,9 +276,9 @@
<input type="hidden" name="date_filter" value="{{ $dateFilter }}">
@endif
<label class="h-10 px-4 rounded-full border border-slate-300 bg-white inline-flex items-center gap-2 text-sm font-semibold text-slate-700">
<label class="listing-results-sort">
<span>Sort by</span>
<select name="sort" class="bg-transparent text-sm font-semibold focus:outline-none" onchange="this.form.submit()">
<select name="sort" class="listing-results-sort-select" onchange="this.form.submit()">
<option value="smart" @selected($sort === 'smart')>Recommended</option>
<option value="newest" @selected($sort === 'newest')>Newest</option>
<option value="oldest" @selected($sort === 'oldest')>Oldest</option>
@ -381,197 +381,3 @@
</section>
</div>
</div>
<script>
(() => {
const countrySelect = document.querySelector('[data-listing-country]');
const citySelect = document.querySelector('[data-listing-city]');
const currentLocationButton = document.querySelector('[data-use-current-location]');
const filterDrawer = document.querySelector('[data-listing-filter-drawer]');
const filterOpenButtons = Array.from(document.querySelectorAll('[data-listing-filter-open]'));
const filterCloseButtons = Array.from(document.querySelectorAll('[data-listing-filter-close]'));
const citiesTemplate = countrySelect?.dataset.citiesUrlTemplate ?? '';
const locationStorageKey = 'oc2.header.location';
const drawerMediaQuery = window.matchMedia('(max-width: 1023px)');
const setDrawerExpanded = (expanded) => {
filterOpenButtons.forEach((button) => button.setAttribute('aria-expanded', expanded ? 'true' : 'false'));
};
const closeFilterDrawer = () => {
if (!filterDrawer) {
return;
}
filterDrawer.classList.remove('is-open');
filterDrawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('listing-filters-open');
setDrawerExpanded(false);
};
const openFilterDrawer = () => {
if (!filterDrawer || !drawerMediaQuery.matches) {
return;
}
filterDrawer.classList.add('is-open');
filterDrawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('listing-filters-open');
setDrawerExpanded(true);
};
filterOpenButtons.forEach((button) => button.addEventListener('click', openFilterDrawer));
filterCloseButtons.forEach((button) => button.addEventListener('click', closeFilterDrawer));
window.addEventListener('resize', () => {
if (!drawerMediaQuery.matches) {
closeFilterDrawer();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeFilterDrawer();
}
});
if (drawerMediaQuery.matches) {
closeFilterDrawer();
} else if (filterDrawer) {
filterDrawer.setAttribute('aria-hidden', 'false');
setDrawerExpanded(false);
}
if (!countrySelect || !citySelect || citiesTemplate === '') {
return;
}
const normalize = (value) => (value ?? '')
.toString()
.toLocaleLowerCase('tr-TR')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
const setCityOptions = (cities, selectedCityName = '') => {
citySelect.innerHTML = '<option value="">Select city</option>';
cities.forEach((city) => {
const option = document.createElement('option');
option.value = String(city.id ?? '');
option.textContent = city.name ?? '';
option.dataset.name = city.name ?? '';
citySelect.appendChild(option);
});
citySelect.disabled = false;
if (selectedCityName) {
const matched = Array.from(citySelect.options).find((option) => normalize(option.dataset.name) === normalize(selectedCityName));
if (matched) {
citySelect.value = matched.value;
}
}
};
const fetchCityOptions = async (url) => {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('city_fetch_failed');
}
const payload = await response.json();
if (Array.isArray(payload)) {
return payload;
}
return Array.isArray(payload?.data) ? payload.data : [];
};
const loadCities = async (countryId, selectedCityName = '') => {
if (!countryId) {
citySelect.innerHTML = '<option value="">Select country first</option>';
citySelect.disabled = true;
return;
}
citySelect.disabled = true;
citySelect.innerHTML = '<option value="">Loading cities...</option>';
const primaryUrl = citiesTemplate.replace('__COUNTRY__', encodeURIComponent(String(countryId)));
try {
let cities = [];
try {
cities = await fetchCityOptions(primaryUrl);
} catch (primaryError) {
if (!/^https?:\/\//i.test(primaryUrl)) {
throw primaryError;
}
let fallbackUrl = null;
try {
const parsed = new URL(primaryUrl);
fallbackUrl = `${parsed.pathname}${parsed.search}`;
} catch (urlError) {
fallbackUrl = null;
}
if (!fallbackUrl) {
throw primaryError;
}
cities = await fetchCityOptions(fallbackUrl);
}
setCityOptions(cities, selectedCityName);
} catch (error) {
citySelect.innerHTML = '<option value="">Cities could not be loaded</option>';
citySelect.disabled = true;
}
};
countrySelect.addEventListener('change', () => {
citySelect.value = '';
void loadCities(countrySelect.value);
});
currentLocationButton?.addEventListener('click', async () => {
try {
const rawLocation = localStorage.getItem(locationStorageKey);
if (!rawLocation) {
return;
}
const parsedLocation = JSON.parse(rawLocation);
const countryName = parsedLocation?.countryName ?? '';
const cityName = parsedLocation?.cityName ?? '';
const countryId = parsedLocation?.countryId ? String(parsedLocation.countryId) : null;
const matchedCountryOption = Array.from(countrySelect.options).find((option) => {
if (countryId && option.value === countryId) {
return true;
}
return normalize(option.textContent) === normalize(countryName);
});
if (!matchedCountryOption) {
return;
}
countrySelect.value = matchedCountryOption.value;
await loadCities(matchedCountryOption.value, cityName);
} catch (error) {
// no-op
}
});
})();
</script>

View File

@ -43,9 +43,6 @@ class LocationSeeder extends Seeder
->delete();
}
/**
* @return array<int, array{code: string, name: string, phone_code: string}>
*/
private function countries(): array
{
$countries = [];
@ -84,7 +81,7 @@ class LocationSeeder extends Seeder
continue;
}
$key = 'filament-country-code-field::countries.' . $value;
$key = 'filament-country-code-field::countries.'.$value;
$labelEn = trim((string) trans($key, [], 'en'));
$name = $labelEn !== '' && $labelEn !== $key ? $labelEn : strtoupper($value);
@ -112,9 +109,6 @@ class LocationSeeder extends Seeder
return substr($normalized, 0, 10);
}
/**
* @return array<int, string>
*/
private function turkeyCities(): array
{
return [

View File

@ -1,4 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

View File

@ -1,32 +1,36 @@
<?php
namespace Modules\Admin\Filament\Resources;
namespace Modules\Location\Filament\Admin\Resources;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\CityResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Location\Filament\Admin\Resources\CityResource\Pages;
use Modules\Location\Models\City;
use UnitEnum;
class CityResource extends Resource
{
protected static ?string $model = City::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
protected static string | UnitEnum | null $navigationGroup = 'Location';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Location';
protected static ?string $label = 'City';
protected static ?string $pluralLabel = 'Cities';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
@ -41,12 +45,12 @@ class CityResource extends Resource
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
ResourceTableColumns::id(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('country.name')->label('Country')->searchable()->sortable(),
TextColumn::make('districts_count')->counts('districts')->label('Districts')->sortable(),
IconColumn::make('is_active')->boolean(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
ResourceTableColumns::activeIcon(),
ResourceTableColumns::createdAtHidden(),
])->defaultSort('id', 'desc')->filters([
SelectFilter::make('country_id')
->label('Country')
@ -61,13 +65,7 @@ class CityResource extends Resource
blank: fn (Builder $query): Builder => $query,
),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (City $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
])->actions(ResourceTableActions::editActivityDelete(static::class));
}
public static function getPages(): array

View File

@ -1,8 +1,9 @@
<?php
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\CityResource;
use Modules\Location\Filament\Admin\Resources\CityResource;
class CreateCity extends CreateRecord
{

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Location\Filament\Admin\Resources\CityResource;
class EditCity extends EditRecord
{
protected static string $resource = CityResource::class;
protected function getHeaderActions(): array
{
return [DeleteAction::make()];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Location\Filament\Admin\Resources\CityResource;
class ListCities extends ListRecords
{
protected static string $resource = CityResource::class;
protected function getHeaderActions(): array
{
return [CreateAction::make()];
}
}

View File

@ -1,7 +1,8 @@
<?php
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
use Modules\Admin\Filament\Resources\CityResource;
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
use Modules\Location\Filament\Admin\Resources\CityResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListCityActivities extends ListActivities

View File

@ -1,38 +1,42 @@
<?php
namespace Modules\Admin\Filament\Resources;
namespace Modules\Location\Filament\Admin\Resources;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\LocationResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
use Modules\Location\Models\Country;
use UnitEnum;
class LocationResource extends Resource
class CountryResource extends Resource
{
protected static ?string $model = Country::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-globe-alt';
protected static string | UnitEnum | null $navigationGroup = 'Location';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
protected static string|UnitEnum|null $navigationGroup = 'Location';
protected static ?string $label = 'Country';
protected static ?string $pluralLabel = 'Countries';
protected static ?int $navigationSort = 2;
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(100),
TextInput::make('code')->required()->maxLength(2)->unique(ignoreRecord: true),
TextInput::make('code')->required()->maxLength(3)->unique(ignoreRecord: true),
TextInput::make('phone_code')->maxLength(10),
Toggle::make('is_active')->default(true),
]);
@ -41,17 +45,17 @@ class LocationResource extends Resource
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
ResourceTableColumns::id(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('code')->searchable()->sortable(),
TextColumn::make('phone_code'),
TextColumn::make('cities_count')->counts('cities')->label('Cities')->sortable(),
IconColumn::make('is_active')->boolean(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
ResourceTableColumns::activeIcon(),
ResourceTableColumns::createdAtHidden(),
])->defaultSort('id', 'desc')->filters([
SelectFilter::make('code')
->label('Code')
->options(fn (): array => Country::query()->orderBy('code')->pluck('code', 'code')->all()),
->options(fn (): array => Country::codeOptions()),
TernaryFilter::make('has_cities')
->label('Has cities')
->queries(
@ -60,22 +64,16 @@ class LocationResource extends Resource
blank: fn (Builder $query): Builder => $query,
),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (Country $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
])->actions(ResourceTableActions::editActivityDelete(static::class));
}
public static function getPages(): array
{
return [
'index' => Pages\ListLocations::route('/'),
'create' => Pages\CreateLocation::route('/create'),
'activities' => Pages\ListLocationActivities::route('/{record}/activities'),
'edit' => Pages\EditLocation::route('/{record}/edit'),
'index' => Pages\ListCountries::route('/'),
'create' => Pages\CreateCountry::route('/create'),
'activities' => Pages\ListCountryActivities::route('/{record}/activities'),
'edit' => Pages\EditCountry::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Location\Filament\Admin\Resources\CountryResource;
class CreateCountry extends CreateRecord
{
protected static string $resource = CountryResource::class;
}

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Location\Filament\Admin\Resources\CountryResource;
class EditCountry extends EditRecord
{
protected static string $resource = CountryResource::class;
protected function getHeaderActions(): array
{
return [DeleteAction::make()];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Location\Filament\Admin\Resources\CountryResource;
class ListCountries extends ListRecords
{
protected static string $resource = CountryResource::class;
protected function getHeaderActions(): array
{
return [CreateAction::make()];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
use Modules\Location\Filament\Admin\Resources\CountryResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListCountryActivities extends ListActivities
{
protected static string $resource = CountryResource::class;
}

View File

@ -1,22 +1,21 @@
<?php
namespace Modules\Admin\Filament\Resources;
namespace Modules\Location\Filament\Admin\Resources;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
use Modules\Location\Models\Country;
use Modules\Location\Models\District;
use UnitEnum;
@ -24,10 +23,15 @@ use UnitEnum;
class DistrictResource extends Resource
{
protected static ?string $model = District::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-map';
protected static string | UnitEnum | null $navigationGroup = 'Location';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-map';
protected static string|UnitEnum|null $navigationGroup = 'Location';
protected static ?string $label = 'District';
protected static ?string $pluralLabel = 'Districts';
protected static ?int $navigationSort = 4;
public static function form(Schema $schema): Schema
@ -42,16 +46,16 @@ class DistrictResource extends Resource
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
ResourceTableColumns::id(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('city.name')->label('City')->searchable()->sortable(),
TextColumn::make('city.country.name')->label('Country'),
IconColumn::make('is_active')->boolean(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
ResourceTableColumns::activeIcon(),
ResourceTableColumns::createdAtHidden(),
])->defaultSort('id', 'desc')->filters([
SelectFilter::make('country_id')
->label('Country')
->options(fn (): array => Country::query()->orderBy('name')->pluck('name', 'id')->all())
->options(fn (): array => Country::idNameOptions())
->query(fn (Builder $query, array $data): Builder => $query->when($data['value'] ?? null, fn (Builder $query, string $countryId): Builder => $query->whereHas('city', fn (Builder $cityQuery): Builder => $cityQuery->where('country_id', $countryId)))),
SelectFilter::make('city_id')
->label('City')
@ -59,13 +63,7 @@ class DistrictResource extends Resource
->searchable()
->preload(),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (District $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
])->actions(ResourceTableActions::editActivityDelete(static::class));
}
public static function getPages(): array

View File

@ -1,8 +1,9 @@
<?php
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\DistrictResource;
use Modules\Location\Filament\Admin\Resources\DistrictResource;
class CreateDistrict extends CreateRecord
{

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Location\Filament\Admin\Resources\DistrictResource;
class EditDistrict extends EditRecord
{
protected static string $resource = DistrictResource::class;
protected function getHeaderActions(): array
{
return [DeleteAction::make()];
}
}

View File

@ -1,7 +1,8 @@
<?php
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Modules\Admin\Filament\Resources\DistrictResource;
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
use Modules\Location\Filament\Admin\Resources\DistrictResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListDistrictActivities extends ListActivities

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Location\Filament\Admin\Resources\DistrictResource;
class ListDistricts extends ListRecords
{
protected static string $resource = DistrictResource::class;
protected function getHeaderActions(): array
{
return [CreateAction::make()];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Modules\Location\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
class LocationLookupController extends Controller
{
public function cities(string $country): JsonResponse
{
$countryModel = Country::resolveLookup($country);
if (! $countryModel) {
return response()->json([]);
}
return response()->json($countryModel->cityPayloads());
}
public function districts(City $city): JsonResponse
{
return response()->json($city->districtPayloads());
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Modules\Location;
use Filament\Contracts\Plugin;
use Filament\Panel;
final class LocationPlugin implements Plugin
{
public function getId(): string
{
return 'location';
}
public static function make(): static
{
return app(static::class);
}
public function register(Panel $panel): void
{
$panel->discoverResources(
in: module_path('Location', 'Filament/Admin/Resources'),
for: 'Modules\\Location\\Filament\\Admin\\Resources',
);
}
public function boot(Panel $panel): void {}
}

View File

@ -1,7 +1,11 @@
<?php
namespace Modules\Location\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -10,8 +14,14 @@ class City extends Model
use LogsActivity;
protected $fillable = ['name', 'country_id', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
@ -20,6 +30,56 @@ class City extends Model
->dontSubmitEmptyLogs();
}
public function country() { return $this->belongsTo(Country::class); }
public function districts() { return $this->hasMany(District::class); }
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
public function districts(): HasMany
{
return $this->hasMany(District::class);
}
public static function nameOptions(?string $countryName = null, bool $onlyActive = true): array
{
return static::query()
->when($onlyActive, fn (Builder $query): Builder => $query->active())
->when(
$countryName && trim($countryName) !== '',
fn (Builder $query): Builder => $query->whereHas(
'country',
fn (Builder $countryQuery): Builder => $countryQuery->where('name', trim($countryName)),
),
)
->orderBy('name')
->pluck('name', 'name')
->all();
}
public static function quickCreateOptions(): array
{
return static::query()
->active()
->orderBy('name')
->get(['id', 'name', 'country_id'])
->map(fn (self $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
}
public function districtPayloads(): array
{
return $this->districts()
->orderBy('name')
->get()
->map(fn (District $district): array => [
'id' => (int) $district->id,
'name' => (string) $district->name,
'city_id' => (int) $district->city_id,
])
->all();
}
}

View File

@ -1,7 +1,10 @@
<?php
namespace Modules\Location\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -10,8 +13,14 @@ class Country extends Model
use LogsActivity;
protected $fillable = ['name', 'code', 'phone_code', 'flag', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
@ -20,8 +29,165 @@ class Country extends Model
->dontSubmitEmptyLogs();
}
public function cities()
public function cities(): HasMany
{
return $this->hasMany(City::class);
}
public static function idNameOptions(bool $onlyActive = false): array
{
return static::query()
->when($onlyActive, fn (Builder $query): Builder => $query->active())
->orderBy('name')
->pluck('name', 'id')
->all();
}
public static function codeOptions(bool $onlyActive = false): array
{
return static::query()
->when($onlyActive, fn (Builder $query): Builder => $query->active())
->orderBy('code')
->pluck('code', 'code')
->all();
}
public static function nameOptions(bool $onlyActive = false): array
{
return static::query()
->when($onlyActive, fn (Builder $query): Builder => $query->active())
->orderBy('name')
->pluck('name', 'name')
->all();
}
public static function quickCreateOptions(): array
{
return static::query()
->active()
->orderBy('name')
->get(['id', 'name'])
->map(fn (self $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
])
->all();
}
public static function headerLocationOptions(): array
{
return static::query()
->active()
->orderBy('name')
->get(['id', 'name', 'code'])
->map(fn (self $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
'code' => strtoupper((string) $country->code),
])
->all();
}
public static function resolveLookup(string $value): ?self
{
$lookupValue = trim($value);
if ($lookupValue === '') {
return null;
}
$lookupCode = strtoupper($lookupValue);
$lookupName = mb_strtolower($lookupValue);
return static::query()
->where(function (Builder $query) use ($lookupCode, $lookupName, $lookupValue): void {
if (ctype_digit($lookupValue)) {
$query->orWhere('id', (int) $lookupValue);
}
$query
->orWhereRaw('UPPER(code) = ?', [$lookupCode])
->orWhereRaw('LOWER(name) = ?', [$lookupName]);
})
->first();
}
public function cityPayloads(bool $onlyActive = true): array
{
$cities = $this->cities()
->when($onlyActive, fn (Builder $query): Builder => $query->active())
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($onlyActive && $cities->isEmpty()) {
return $this->cityPayloads(false);
}
return $cities
->map(fn (City $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
}
public static function browseSelection(?int $countryId, ?int $cityId): array
{
$countries = static::query()
->active()
->orderBy('name')
->get(['id', 'name']);
$selectedCountry = $countryId
? ($countries->firstWhere('id', $countryId) ?? static::query()->whereKey($countryId)->first(['id', 'name']))
: null;
$selectedCity = $cityId
? City::query()->whereKey($cityId)->first(['id', 'name', 'country_id'])
: null;
if ($selectedCity && ! $selectedCountry) {
$countryId = (int) $selectedCity->country_id;
$selectedCountry = static::query()->whereKey($countryId)->first(['id', 'name']);
}
$cities = collect();
if ($selectedCountry) {
$countryId = (int) $selectedCountry->getKey();
$cities = City::query()
->where('country_id', $countryId)
->active()
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($cities->isEmpty()) {
$cities = City::query()
->where('country_id', $countryId)
->orderBy('name')
->get(['id', 'name', 'country_id']);
}
} else {
$countryId = null;
$cityId = null;
}
if ($selectedCity && $countryId && (int) $selectedCity->country_id !== $countryId) {
$selectedCity = null;
$cityId = null;
}
if ($selectedCity) {
$cityId = (int) $selectedCity->getKey();
}
return [
'country_id' => $countryId,
'city_id' => $cityId,
'countries' => $countries,
'cities' => $cities,
'selected_country_name' => $selectedCountry?->name ? (string) $selectedCountry->name : null,
'selected_city_name' => $selectedCity?->name ? (string) $selectedCity->name : null,
];
}
}

View File

@ -1,4 +1,5 @@
<?php
namespace Modules\Location\Providers;
use Illuminate\Support\ServiceProvider;
@ -9,9 +10,10 @@ class LocationServiceProvider extends ServiceProvider
public function boot(): void
{
$this->loadMigrationsFrom(module_path($this->moduleName, 'database/migrations'));
$this->loadMigrationsFrom(module_path($this->moduleName, 'Database/migrations'));
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
}
public function register(): void {}
public function register(): void
{}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Support;
namespace Modules\Location\Support;
use Illuminate\Support\Collection;
use Tapp\FilamentCountryCodeField\Enums\CountriesEnum;
@ -17,7 +17,7 @@ class CountryCodeManager
return self::iso2FromCountryCode(self::defaultCountryCode()) ?? 'TR';
}
public static function normalizeCountryCode(null | string $value): string
public static function normalizeCountryCode(?string $value): string
{
$value = trim((string) $value);
@ -32,7 +32,7 @@ class CountryCodeManager
return self::countryCodeFromIso2($value) ?? '+90';
}
public static function isValidCountryCode(null | string $value): bool
public static function isValidCountryCode(?string $value): bool
{
if (! filled($value)) {
return false;
@ -41,7 +41,7 @@ class CountryCodeManager
return self::countries()->contains(fn (array $country): bool => $country['country_code'] === trim((string) $value));
}
public static function countryCodeFromIso2(null | string $iso2): ?string
public static function countryCodeFromIso2(?string $iso2): ?string
{
$iso2 = strtoupper(trim((string) $iso2));
@ -53,7 +53,7 @@ class CountryCodeManager
->first(fn (array $country): bool => $country['iso2'] === $iso2)['country_code'] ?? null;
}
public static function iso2FromCountryCode(null | string $countryCode): ?string
public static function iso2FromCountryCode(?string $countryCode): ?string
{
$countryCode = trim((string) $countryCode);
@ -65,7 +65,7 @@ class CountryCodeManager
->first(fn (array $country): bool => $country['country_code'] === $countryCode)['iso2'] ?? null;
}
public static function labelFromCountryCode(null | string $countryCode): ?string
public static function labelFromCountryCode(?string $countryCode): ?string
{
$countryCode = trim((string) $countryCode);
@ -77,7 +77,7 @@ class CountryCodeManager
->first(fn (array $country): bool => $country['country_code'] === $countryCode)['english_label'] ?? null;
}
public static function countryCodeFromLabelOrCode(null | string $value): ?string
public static function countryCodeFromLabelOrCode(?string $value): ?string
{
$value = trim((string) $value);
@ -111,7 +111,7 @@ class CountryCodeManager
})['country_code'] ?? null;
}
public static function normalizeStoredCountry(null | string $value): ?string
public static function normalizeStoredCountry(?string $value): ?string
{
$value = trim((string) $value);
@ -128,9 +128,6 @@ class CountryCodeManager
return self::labelFromCountryCode($countryCode) ?? $value;
}
/**
* @return Collection<int, array{country_code: string, iso2: string, label: string, english_label: string}>
*/
private static function countries(): Collection
{
static $countries;

View File

@ -1,50 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Location\Models\Country;
use Modules\Location\Http\Controllers\LocationLookupController;
Route::get('/locations/cities/{country}', function (string $country) {
$lookupValue = trim($country);
if ($lookupValue === '') {
return response()->json([]);
}
$lookupCode = strtoupper($lookupValue);
$lookupName = mb_strtolower($lookupValue);
$countryModel = Country::query()
->where(function ($query) use ($lookupValue, $lookupCode, $lookupName): void {
if (ctype_digit($lookupValue)) {
$query->orWhere('id', (int) $lookupValue);
}
$query
->orWhereRaw('UPPER(code) = ?', [$lookupCode])
->orWhereRaw('LOWER(name) = ?', [$lookupName]);
})
->first();
if (! $countryModel) {
return response()->json([]);
}
$activeCities = $countryModel->cities()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($activeCities->isNotEmpty()) {
return response()->json($activeCities);
}
return response()->json(
$countryModel->cities()
->orderBy('name')
->get(['id', 'name', 'country_id'])
);
})->name('locations.cities');
Route::get('/locations/districts/{city}', function (\Modules\Location\Models\City $city) {
return response()->json($city->districts);
})->name('locations.districts');
Route::middleware('web')->group(function () {
Route::get('/locations/cities/{country}', [LocationLookupController::class, 'cities'])
->name('locations.cities');
Route::get('/locations/districts/{city}', [LocationLookupController::class, 'districts'])
->name('locations.districts');
});

View File

@ -0,0 +1,171 @@
<?php
namespace Modules\Panel\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Panel\App\Http\Requests\StoreVideoRequest;
use Modules\Panel\App\Http\Requests\UpdateListingRequest;
use Modules\Panel\App\Http\Requests\UpdateVideoRequest;
use Modules\Video\Models\Video;
class PanelController extends Controller
{
public function index(): RedirectResponse
{
return redirect()->route('panel.listings.index');
}
public function create(): View
{
return view('panel::create');
}
public function listings(Request $request): View
{
$user = $request->user();
$search = trim((string) $request->string('search'));
$status = (string) $request->string('status', 'all');
if (! in_array($status, ['all', 'sold', 'expired'], true)) {
$status = 'all';
}
$payload = Listing::panelIndexDataForUser($user, $search, $status);
return view('panel::listings', [
'listings' => $payload['listings'],
'status' => $status,
'search' => $search,
'counts' => $payload['counts'],
]);
}
public function editListing(Request $request, Listing $listing): View
{
$listing->assertOwnedBy($request->user());
return view('panel::edit-listing', [
'listing' => $listing->loadPanelEditor(),
'customFieldValues' => ListingCustomFieldSchemaBuilder::presentableValues(
$listing->category_id ? (int) $listing->category_id : null,
(array) $listing->custom_fields,
),
'statusOptions' => Listing::panelStatusOptions(),
]);
}
public function updateListing(UpdateListingRequest $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->updateFromPanel($request->validated() + [
'currency' => $listing->currency ?: ListingPanelHelper::defaultCurrency(),
]);
return redirect()
->route('panel.listings.edit', $listing)
->with('success', 'Listing updated.');
}
public function videos(Request $request): View
{
return view('panel::videos', Video::panelIndexDataForUser($request->user()));
}
public function storeVideo(StoreVideoRequest $request): RedirectResponse
{
$validated = $request->validated();
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video = Video::createFromUploadedFile($listing, $request->file('video_file'), [
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'sort_order' => Video::nextSortOrderForListing($listing),
'is_active' => true,
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video uploaded.');
}
public function editVideo(Request $request, Video $video): View
{
$video->assertOwnedBy($request->user());
return view('panel::video-edit', [
'video' => $video->load('listing:id,title,user_id'),
'listingOptions' => $request->user()->panelListingOptions(),
]);
}
public function updateVideo(UpdateVideoRequest $request, Video $video): RedirectResponse
{
$video->assertOwnedBy($request->user());
$validated = $request->validated();
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video->updateFromPanel([
'listing_id' => $listing->getKey(),
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'video_file' => $request->file('video_file'),
'is_active' => $request->boolean('is_active'),
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video updated.');
}
public function destroyVideo(Request $request, Video $video): RedirectResponse
{
$video->assertOwnedBy($request->user());
$video->delete();
return redirect()
->route('panel.videos.index')
->with('success', 'Video deleted.');
}
public function profile(Request $request): View
{
$user = $request->user()->loadPanelProfile();
return view('panel::profile', [
'user' => $user,
]);
}
public function destroyListing(Request $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->delete();
return back()->with('success', 'Listing removed.');
}
public function markListingAsSold(Request $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->markAsSold();
return back()->with('success', 'Listing marked as sold.');
}
public function republishListing(Request $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->republish();
return back()->with('success', 'Listing republished.');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\Panel\App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreVideoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'listing_id' => ['required', 'integer', 'exists:listings,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['required', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
];
}
public function messages(): array
{
return [
'listing_id.required' => 'Choose a listing for the video.',
'listing_id.exists' => 'Choose a valid listing for the video.',
'video_file.required' => 'A video file is required.',
'video_file.mimes' => 'Video must be an mp4, mov, webm, or m4v file.',
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\Panel\App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Listing\Models\Listing;
class UpdateListingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'price' => ['nullable', 'numeric', 'min:0'],
'status' => ['required', Rule::in(array_keys(Listing::panelStatusOptions()))],
'contact_phone' => ['nullable', 'string', 'max:60'],
'contact_email' => ['nullable', 'email', 'max:255'],
'country' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'Listing title is required.',
'price.numeric' => 'Listing price must be numeric.',
'status.required' => 'Listing status is required.',
'status.in' => 'Listing status is invalid.',
'contact_email.email' => 'Contact email must be valid.',
'expires_at.date' => 'Expiry date must be a valid date.',
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\Panel\App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVideoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'listing_id' => ['required', 'integer', 'exists:listings,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['nullable', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
'is_active' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'listing_id.required' => 'Choose a listing for the video.',
'listing_id.exists' => 'Choose a valid listing for the video.',
'video_file.mimes' => 'Video must be an mp4, mov, webm, or m4v file.',
];
}
}

View File

@ -1,8 +1,7 @@
<?php
namespace App\Livewire;
namespace Modules\Panel\App\Livewire;
use App\Support\QuickListingCategorySuggester;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
@ -16,9 +15,10 @@ use Modules\Listing\Models\Listing;
use Modules\Listing\Models\ListingCustomField;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Listing\Support\QuickListingCategorySuggester;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\S3\Support\MediaStorage;
use Modules\Site\App\Support\LocalMedia;
use Modules\User\App\Models\Profile;
use Modules\Video\Models\Video;
use Throwable;
@ -28,36 +28,59 @@ class PanelQuickListingForm extends Component
use WithFileUploads;
private const TOTAL_STEPS = 5;
private const DRAFT_SESSION_KEY = 'panel_quick_listing_draft';
private const OTHER_CITY_ID = -1;
public array $photos = [];
public array $videos = [];
public array $categories = [];
public array $countries = [];
public array $cities = [];
public array $listingCustomFields = [];
public array $customFieldValues = [];
public int $currentStep = 1;
public string $categorySearch = '';
public ?int $selectedCategoryId = null;
public ?int $activeParentCategoryId = null;
public ?int $detectedCategoryId = null;
public ?float $detectedConfidence = null;
public ?string $detectedReason = null;
public ?string $detectedError = null;
public array $detectedAlternatives = [];
public bool $isDetecting = false;
public string $listingTitle = '';
public string $price = '';
public string $description = '';
public ?int $selectedCountryId = null;
public ?int $selectedCityId = null;
public bool $isPublishing = false;
public bool $shouldPersistDraft = true;
public ?string $publishError = null;
public function mount(): void
@ -70,7 +93,7 @@ class PanelQuickListingForm extends Component
public function render()
{
return view('panel.quick-create');
return view('panel::quick-create');
}
public function dehydrate(): void
@ -596,10 +619,6 @@ class PanelQuickListingForm extends Component
abort(403);
}
$profilePhone = Profile::query()
->where('user_id', $user->getKey())
->value('phone');
$payload = [
'title' => trim($this->listingTitle),
'description' => trim($this->description),
@ -609,7 +628,7 @@ class PanelQuickListingForm extends Component
'status' => 'pending',
'custom_fields' => $this->sanitizedCustomFieldValues(),
'contact_email' => (string) $user->email,
'contact_phone' => $profilePhone,
'contact_phone' => Profile::phoneForUser($user),
'country' => $this->selectedCountryName,
'city' => $this->selectedCityName,
];
@ -622,10 +641,11 @@ class PanelQuickListingForm extends Component
continue;
}
$listing
->addMedia($photo->getRealPath())
->usingFileName($photo->getClientOriginalName())
->toMediaCollection('listing-images', $mediaDisk);
$listing->attachListingImage(
$photo->getRealPath(),
$photo->getClientOriginalName(),
$mediaDisk
);
}
foreach ($this->videos as $index => $video) {
@ -674,75 +694,18 @@ class PanelQuickListingForm extends Component
private function loadCategories(): void
{
$all = Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'parent_id', 'icon']);
$childrenCount = Category::query()
->where('is_active', true)
->selectRaw('parent_id, count(*) as aggregate')
->whereNotNull('parent_id')
->groupBy('parent_id')
->pluck('aggregate', 'parent_id');
$this->categories = $all
->map(fn (Category $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,
])
->values()
->all();
$this->categories = Category::panelQuickCatalog();
}
private function loadLocations(): void
{
$this->countries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name'])
->map(fn (Country $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
])
->all();
$this->cities = City::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id'])
->map(fn (City $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
$this->countries = Country::quickCreateOptions();
$this->cities = City::quickCreateOptions();
}
private function loadListingCustomFields(): void
{
$this->listingCustomFields = ListingCustomField::query()
->active()
->forCategory($this->selectedCategoryId)
->ordered()
->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options'])
->map(fn (ListingCustomField $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();
$this->listingCustomFields = ListingCustomField::panelFieldDefinitions($this->selectedCategoryId);
$allowed = collect($this->listingCustomFields)->pluck('name')->all();
$this->customFieldValues = collect($this->customFieldValues)->only($allowed)->all();
@ -762,7 +725,7 @@ class PanelQuickListingForm extends Component
return;
}
$profile = Profile::query()->where('user_id', $user->getKey())->first();
$profile = Profile::detailsForUser($user);
if (! $profile) {
return;
@ -795,7 +758,7 @@ class PanelQuickListingForm extends Component
private function frontendMediaDisk(): string
{
return (string) config('media_storage.local_disk', MediaStorage::diskFromDriver(MediaStorage::DRIVER_LOCAL));
return LocalMedia::disk();
}
private function handlePublishValidationFailure(ValidationException $exception): void

Some files were not shown because too many files have changed in this diff Show More