mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239fd0d2bf | ||
|
|
f06943ce9d | ||
|
|
057620b715 | ||
|
|
6b3a8b8581 | ||
|
|
d2345cbeda |
@ -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'));
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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([
|
||||
|
||||
@ -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);
|
||||
}
|
||||
{}
|
||||
}
|
||||
|
||||
35
Modules/Admin/Support/Filament/ResourceTableActions.php
Normal file
35
Modules/Admin/Support/Filament/ResourceTableActions.php
Normal 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]));
|
||||
}
|
||||
}
|
||||
27
Modules/Admin/Support/Filament/ResourceTableColumns.php
Normal file
27
Modules/Admin/Support/Filament/ResourceTableColumns.php
Normal 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);
|
||||
}
|
||||
}
|
||||
29
Modules/Category/CategoryPlugin.php
Normal file
29
Modules/Category/CategoryPlugin.php
Normal 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 {}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
{}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -29,4 +29,9 @@ return new class extends Migration
|
||||
$table->nullableTimestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('media');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
@ -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
|
||||
{
|
||||
@ -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
|
||||
{
|
||||
@ -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
|
||||
{
|
||||
41
Modules/Listing/Filament/Admin/Resources/ListingResource.php
Normal file
41
Modules/Listing/Filament/Admin/Resources/ListingResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
34
Modules/Listing/ListingPlugin.php
Normal file
34
Modules/Listing/ListingPlugin.php
Normal 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 {}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{}
|
||||
}
|
||||
|
||||
180
Modules/Listing/Support/Filament/AdminListingResourceSchema.php
Normal file
180
Modules/Listing/Support/Filament/AdminListingResourceSchema.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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 === []) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@ -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
|
||||
@ -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
|
||||
{
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
{
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
29
Modules/Location/LocationPlugin.php
Normal file
29
Modules/Location/LocationPlugin.php
Normal 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 {}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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');
|
||||
});
|
||||
|
||||
171
Modules/Panel/App/Http/Controllers/PanelController.php
Normal file
171
Modules/Panel/App/Http/Controllers/PanelController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
33
Modules/Panel/App/Http/Requests/StoreVideoRequest.php
Normal file
33
Modules/Panel/App/Http/Requests/StoreVideoRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
42
Modules/Panel/App/Http/Requests/UpdateListingRequest.php
Normal file
42
Modules/Panel/App/Http/Requests/UpdateListingRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
33
Modules/Panel/App/Http/Requests/UpdateVideoRequest.php
Normal file
33
Modules/Panel/App/Http/Requests/UpdateVideoRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user