mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 19:22:10 -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
|
<?php
|
||||||
|
|
||||||
namespace Modules\Admin\Providers;
|
namespace Modules\Admin\Providers;
|
||||||
|
|
||||||
use App\Http\Middleware\BootstrapAppData;
|
|
||||||
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
|
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
|
||||||
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
|
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
@ -13,7 +13,6 @@ use Filament\Pages\Dashboard;
|
|||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
use Filament\View\PanelsRenderHook;
|
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
@ -21,13 +20,16 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
|||||||
use Illuminate\Session\Middleware\StartSession;
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
use Jeffgreco13\FilamentBreezy\BreezyCore;
|
use Jeffgreco13\FilamentBreezy\BreezyCore;
|
||||||
use MWGuerra\FileManager\FileManagerPlugin;
|
use Modules\Category\CategoryPlugin;
|
||||||
use MWGuerra\FileManager\Filament\Pages\FileManager;
|
|
||||||
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
|
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
|
||||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
use Modules\Listing\ListingPlugin;
|
||||||
use Modules\Admin\Filament\Resources\ListingResource;
|
use Modules\Location\LocationPlugin;
|
||||||
use Modules\Admin\Filament\Resources\LocationResource;
|
use Modules\Site\App\Http\Middleware\BootstrapAppData;
|
||||||
use Modules\Admin\Filament\Resources\UserResource;
|
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
|
class AdminPanelProvider extends PanelProvider
|
||||||
{
|
{
|
||||||
@ -39,11 +41,6 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->path('admin')
|
->path('admin')
|
||||||
->login()
|
->login()
|
||||||
->colors(['primary' => Color::Blue])
|
->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([
|
->userMenuItems([
|
||||||
'view-site' => MenuItem::make()
|
'view-site' => MenuItem::make()
|
||||||
->label('View Site')
|
->label('View Site')
|
||||||
@ -70,6 +67,12 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->users([
|
->users([
|
||||||
'Admin' => 'a@a.com',
|
'Admin' => 'a@a.com',
|
||||||
]),
|
]),
|
||||||
|
CategoryPlugin::make(),
|
||||||
|
ListingPlugin::make(),
|
||||||
|
LocationPlugin::make(),
|
||||||
|
SitePlugin::make(),
|
||||||
|
UserPlugin::make(),
|
||||||
|
VideoPlugin::make(),
|
||||||
])
|
])
|
||||||
->pages([Dashboard::class])
|
->pages([Dashboard::class])
|
||||||
->middleware([
|
->middleware([
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Admin\Providers;
|
namespace Modules\Admin\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@ -7,11 +8,9 @@ class AdminServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->loadMigrationsFrom(module_path('Admin', 'database/migrations'));
|
$this->loadMigrationsFrom(module_path('Admin', 'Database/migrations'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(): void
|
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
|
<?php
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -1,27 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources;
|
|
||||||
|
namespace Modules\Category\Filament\Admin\Resources;
|
||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
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\Models\Category;
|
||||||
|
use Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class CategoryResource extends Resource
|
class CategoryResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Category::class;
|
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
|
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('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
|
||||||
TextInput::make('description')->maxLength(500),
|
TextInput::make('description')->maxLength(500),
|
||||||
TextInput::make('icon')->maxLength(100),
|
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),
|
TextInput::make('sort_order')->numeric()->default(0),
|
||||||
Toggle::make('is_active')->default(true),
|
Toggle::make('is_active')->default(true),
|
||||||
]);
|
]);
|
||||||
@ -39,15 +41,15 @@ class CategoryResource extends Resource
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table->columns([
|
return $table->columns([
|
||||||
TextColumn::make('id')->sortable(),
|
ResourceTableColumns::id(),
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->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'),
|
->weight(fn (Category $record): string => $record->parent_id === null ? 'semi-bold' : 'normal'),
|
||||||
TextColumn::make('parent.name')->label('Parent')->default('-'),
|
TextColumn::make('parent.name')->label('Parent')->default('-'),
|
||||||
TextColumn::make('children_count')->label('Subcategories'),
|
TextColumn::make('children_count')->label('Subcategories'),
|
||||||
TextColumn::make('listings_count')->label('Listings'),
|
TextColumn::make('listings_count')->label('Listings'),
|
||||||
IconColumn::make('is_active')->boolean(),
|
ResourceTableColumns::activeIcon(),
|
||||||
TextColumn::make('sort_order')->sortable(),
|
TextColumn::make('sort_order')->sortable(),
|
||||||
])->actions([
|
])->actions([
|
||||||
Action::make('toggleChildren')
|
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')
|
->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))
|
->action(fn (Category $record, Pages\ListCategories $livewire) => $livewire->toggleChildren($record))
|
||||||
->visible(fn (Category $record): bool => $record->parent_id === null && $record->children_count > 0),
|
->visible(fn (Category $record): bool => $record->parent_id === null && $record->children_count > 0),
|
||||||
EditAction::make(),
|
...ResourceTableActions::editActivityDelete(static::class),
|
||||||
Action::make('activities')
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->url(fn (Category $record): string => static::getUrl('activities', ['record' => $record])),
|
|
||||||
DeleteAction::make(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
|
||||||
|
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||||
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
use Modules\Category\Filament\Admin\Resources\CategoryResource;
|
||||||
|
|
||||||
class CreateCategory extends CreateRecord
|
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
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
|
||||||
|
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||||
|
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
use Modules\Category\Filament\Admin\Resources\CategoryResource;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
|
|
||||||
class ListCategories extends ListRecords
|
class ListCategories extends ListRecords
|
||||||
@ -1,7 +1,8 @@
|
|||||||
<?php
|
<?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;
|
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||||
|
|
||||||
class ListCategoryActivities extends ListActivities
|
class ListCategoryActivities extends ListActivities
|
||||||
@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Category\Models;
|
namespace Modules\Category\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
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 $fillable = ['name', 'slug', 'description', 'icon', 'parent_id', 'level', 'sort_order', 'is_active'];
|
||||||
|
|
||||||
protected $casts = ['is_active' => 'boolean'];
|
protected $casts = ['is_active' => 'boolean'];
|
||||||
|
|
||||||
public function getActivitylogOptions(): LogOptions
|
public function getActivitylogOptions(): LogOptions
|
||||||
@ -103,6 +105,91 @@ class Category extends Model
|
|||||||
->get(['id', 'name']);
|
->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
|
public static function themePills(int $limit = 8): Collection
|
||||||
{
|
{
|
||||||
return static::query()
|
return static::query()
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Category\Providers;
|
namespace Modules\Category\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@ -9,10 +10,11 @@ class CategoryServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
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->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
|
||||||
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category');
|
$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\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Modules\Conversation\App\Events\ConversationReadUpdated;
|
use Modules\Conversation\App\Events\ConversationReadUpdated;
|
||||||
use Modules\Conversation\App\Events\InboxMessageCreated;
|
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\Models\ConversationMessage;
|
||||||
use Modules\Conversation\App\Support\QuickMessageCatalog;
|
use Modules\Conversation\App\Support\QuickMessageCatalog;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ConversationController extends Controller
|
class ConversationController extends Controller
|
||||||
{
|
{
|
||||||
@ -28,8 +26,7 @@ class ConversationController extends Controller
|
|||||||
$conversations = collect();
|
$conversations = collect();
|
||||||
$selectedConversation = null;
|
$selectedConversation = null;
|
||||||
|
|
||||||
if ($userId && $this->messagingTablesReady()) {
|
if ($userId) {
|
||||||
try {
|
|
||||||
[
|
[
|
||||||
'conversations' => $conversations,
|
'conversations' => $conversations,
|
||||||
'selectedConversation' => $selectedConversation,
|
'selectedConversation' => $selectedConversation,
|
||||||
@ -47,10 +44,6 @@ class ConversationController extends Controller
|
|||||||
$selectedConversation->readPayloadFor($userId),
|
$selectedConversation->readPayloadFor($userId),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (Throwable) {
|
|
||||||
$conversations = collect();
|
|
||||||
$selectedConversation = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('conversation::inbox', [
|
return view('conversation::inbox', [
|
||||||
@ -64,8 +57,6 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
public function state(Request $request): JsonResponse
|
public function state(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
|
|
||||||
|
|
||||||
$userId = (int) $request->user()->getKey();
|
$userId = (int) $request->user()->getKey();
|
||||||
$messageFilter = $this->resolveMessageFilter($request);
|
$messageFilter = $this->resolveMessageFilter($request);
|
||||||
|
|
||||||
@ -91,14 +82,6 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
public function start(Request $request, Listing $listing): RedirectResponse | JsonResponse
|
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();
|
$user = $request->user();
|
||||||
|
|
||||||
if (! $listing->user_id) {
|
if (! $listing->user_id) {
|
||||||
@ -124,8 +107,7 @@ class ConversationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$conversation = Conversation::openForListingBuyer($listing, (int) $user->getKey());
|
$conversation = Conversation::openForListingBuyer($listing, (int) $user->getKey());
|
||||||
|
$user->rememberListing($listing);
|
||||||
$user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
|
|
||||||
|
|
||||||
$message = null;
|
$message = null;
|
||||||
if ($messageBody !== '') {
|
if ($messageBody !== '') {
|
||||||
@ -144,14 +126,6 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
public function send(Request $request, Conversation $conversation): RedirectResponse | JsonResponse
|
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();
|
$user = $request->user();
|
||||||
$userId = (int) $user->getKey();
|
$userId = (int) $user->getKey();
|
||||||
|
|
||||||
@ -187,8 +161,6 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
public function read(Request $request, Conversation $conversation): JsonResponse
|
public function read(Request $request, Conversation $conversation): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
|
|
||||||
|
|
||||||
$userId = (int) $request->user()->getKey();
|
$userId = (int) $request->user()->getKey();
|
||||||
abort_unless($conversation->hasParticipant($userId), 403);
|
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;
|
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
|
public static function unreadCountForUser(int $userId): int
|
||||||
{
|
{
|
||||||
return (int) ConversationMessage::query()
|
return (int) ConversationMessage::query()
|
||||||
|
|||||||
@ -9,7 +9,7 @@ class ConversationServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function boot(): void
|
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->loadRoutesFrom(module_path('Conversation', 'routes/web.php'));
|
||||||
$this->loadViewsFrom(module_path('Conversation', 'resources/views'), 'conversation');
|
$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;
|
namespace Modules\Conversation\Database\Seeders;
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Modules\Conversation\App\Models\Conversation;
|
use Modules\Conversation\App\Models\Conversation;
|
||||||
use Modules\Conversation\App\Models\ConversationMessage;
|
use Modules\Conversation\App\Models\ConversationMessage;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
@ -14,10 +13,6 @@ class ConversationDemoSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
if (! $this->conversationTablesExist()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->whereIn('email', DemoUserCatalog::emails())
|
->whereIn('email', DemoUserCatalog::emails())
|
||||||
->orderBy('email')
|
->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(
|
private function seedConversationThread(
|
||||||
User $seller,
|
User $seller,
|
||||||
User $buyer,
|
User $buyer,
|
||||||
@ -107,7 +97,7 @@ class ConversationDemoSeeder extends Seeder
|
|||||||
$readAfterMinutes = $payload['read_after_minutes'];
|
$readAfterMinutes = $payload['read_after_minutes'];
|
||||||
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
|
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
|
||||||
|
|
||||||
$message = new ConversationMessage();
|
$message = new ConversationMessage;
|
||||||
$message->forceFill([
|
$message->forceFill([
|
||||||
'conversation_id' => $conversation->getKey(),
|
'conversation_id' => $conversation->getKey(),
|
||||||
'sender_id' => $sender->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')
|
@section('content')
|
||||||
<div class="max-w-[1320px] mx-auto px-4 py-8">
|
<div class="max-w-[1320px] mx-auto px-4 py-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
|
<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">
|
<section class="space-y-4">
|
||||||
@include('panel.partials.page-header', [
|
@include('panel::partials.page-header', [
|
||||||
'title' => 'Inbox',
|
'title' => 'Inbox',
|
||||||
'description' => 'Read and reply to buyer messages from the same panel shell used across the site.',
|
'description' => 'Read and reply to buyer messages from the same panel shell used across the site.',
|
||||||
'actions' => $requiresLogin ?? false
|
'actions' => $requiresLogin ?? false
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class DemoServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->guardConfiguration();
|
$this->guardConfiguration();
|
||||||
$this->loadMigrationsFrom(module_path('Demo', 'database/migrations'));
|
$this->loadMigrationsFrom(module_path('Demo', 'Database/migrations'));
|
||||||
$this->loadRoutesFrom(module_path('Demo', 'routes/web.php'));
|
$this->loadRoutesFrom(module_path('Demo', 'routes/web.php'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace Modules\Demo\App\Support;
|
namespace Modules\Demo\App\Support;
|
||||||
|
|
||||||
use App\Settings\GeneralSettings;
|
|
||||||
use Illuminate\Contracts\Foundation\Application;
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Modules\Demo\App\Models\DemoInstance;
|
use Modules\Demo\App\Models\DemoInstance;
|
||||||
|
use Modules\Site\App\Settings\GeneralSettings;
|
||||||
use Modules\User\App\Models\User;
|
use Modules\User\App\Models\User;
|
||||||
use Spatie\Permission\PermissionRegistrar;
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|||||||
@ -5,14 +5,12 @@ namespace Modules\Favorite\App\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
use Modules\Conversation\App\Models\Conversation;
|
use Modules\Conversation\App\Models\Conversation;
|
||||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
use Modules\Favorite\App\Models\FavoriteSearch;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
use Modules\User\App\Models\User;
|
use Modules\User\App\Models\User;
|
||||||
use Modules\User\App\Support\AuthRedirector;
|
use Modules\User\App\Support\AuthRedirector;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class FavoriteController extends Controller
|
class FavoriteController extends Controller
|
||||||
{
|
{
|
||||||
@ -40,13 +38,7 @@ class FavoriteController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$requiresLogin = ! $user;
|
$requiresLogin = ! $user;
|
||||||
|
|
||||||
$categories = collect();
|
$categories = Category::filterOptions();
|
||||||
if ($this->tableExists('categories')) {
|
|
||||||
$categories = Category::query()
|
|
||||||
->where('is_active', true)
|
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$favoriteListings = $this->emptyPaginator();
|
$favoriteListings = $this->emptyPaginator();
|
||||||
$favoriteSearches = $this->emptyPaginator();
|
$favoriteSearches = $this->emptyPaginator();
|
||||||
@ -54,64 +46,22 @@ class FavoriteController extends Controller
|
|||||||
$buyerConversationListingMap = [];
|
$buyerConversationListingMap = [];
|
||||||
|
|
||||||
if ($user && $activeTab === 'listings') {
|
if ($user && $activeTab === 'listings') {
|
||||||
try {
|
$favoriteListings = $user->favoriteListingsPage($statusFilter, $selectedCategoryId);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if ($favoriteListings->isNotEmpty()) {
|
||||||
$favoriteListings->isNotEmpty()
|
$buyerConversationListingMap = Conversation::listingMapForBuyer(
|
||||||
&& $this->tableExists('conversations')
|
(int) $user->getKey(),
|
||||||
) {
|
$favoriteListings->pluck('id')->all(),
|
||||||
$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 ($user && $activeTab === 'searches') {
|
if ($user && $activeTab === 'searches') {
|
||||||
try {
|
$favoriteSearches = $user->favoriteSearchesPage();
|
||||||
if ($this->tableExists('favorite_searches')) {
|
|
||||||
$favoriteSearches = $user->favoriteSearches()
|
|
||||||
->with('category:id,name')
|
|
||||||
->latest()
|
|
||||||
->paginate(10)
|
|
||||||
->withQueryString();
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
$favoriteSearches = $this->emptyPaginator();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $activeTab === 'sellers') {
|
if ($user && $activeTab === 'sellers') {
|
||||||
try {
|
$favoriteSellers = $user->favoriteSellersPage();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('favorite::index', [
|
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.');
|
return back()->with('error', 'Select at least one filter before saving a search.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$signature = FavoriteSearch::signatureFor($filters);
|
$favoriteSearch = FavoriteSearch::storeForUser($request->user(), $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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $favoriteSearch->wasRecentlyCreated) {
|
if (! $favoriteSearch->wasRecentlyCreated) {
|
||||||
return back()->with('success', 'This search is already in your favorites.');
|
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.');
|
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
|
private function emptyPaginator(): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return new LengthAwarePaginator([], 0, 10, 1, [
|
return new LengthAwarePaginator([], 0, 10, 1, [
|
||||||
|
|||||||
@ -53,4 +53,36 @@ class FavoriteSearch extends Model
|
|||||||
|
|
||||||
return $labelParts !== [] ? implode(' · ', $labelParts) : 'Filtered search';
|
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
|
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->loadRoutesFrom(module_path('Favorite', 'routes/web.php'));
|
||||||
$this->loadViewsFrom(module_path('Favorite', 'resources/views'), 'favorite');
|
$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\Database\Seeder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
use Modules\Favorite\App\Models\FavoriteSearch;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
@ -16,10 +14,6 @@ class FavoriteDemoSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
if (! $this->favoriteTablesExist()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->whereIn('email', DemoUserCatalog::emails())
|
->whereIn('email', DemoUserCatalog::emails())
|
||||||
->orderBy('email')
|
->orderBy('email')
|
||||||
@ -30,8 +24,11 @@ class FavoriteDemoSeeder extends Seeder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete();
|
$users->each(function (User $user): void {
|
||||||
DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete();
|
$user->favoriteListings()->detach();
|
||||||
|
$user->favoriteSellers()->detach();
|
||||||
|
});
|
||||||
|
|
||||||
FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
|
FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
|
||||||
|
|
||||||
foreach ($users as $index => $user) {
|
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
|
private function seedFavoriteListings(User $user, Collection $listings): void
|
||||||
{
|
{
|
||||||
$rows = $listings
|
$payload = $listings
|
||||||
->values()
|
->values()
|
||||||
->map(function (Listing $listing, int $index) use ($user): array {
|
->mapWithKeys(function (Listing $listing, int $index): array {
|
||||||
$timestamp = now()->subHours(8 + ($index * 3));
|
$timestamp = now()->subHours(8 + ($index * 3));
|
||||||
|
|
||||||
return [
|
return [$listing->getKey() => [
|
||||||
'user_id' => $user->getKey(),
|
|
||||||
'listing_id' => $listing->getKey(),
|
|
||||||
'created_at' => $timestamp,
|
'created_at' => $timestamp,
|
||||||
'updated_at' => $timestamp,
|
'updated_at' => $timestamp,
|
||||||
];
|
]];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($rows === []) {
|
if ($payload === []) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('favorite_listings')->upsert(
|
$user->favoriteListings()->syncWithoutDetaching($payload);
|
||||||
$rows,
|
|
||||||
['user_id', 'listing_id'],
|
|
||||||
['updated_at']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void
|
private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void
|
||||||
@ -96,16 +80,12 @@ class FavoriteDemoSeeder extends Seeder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('favorite_sellers')->upsert(
|
$user->favoriteSellers()->syncWithoutDetaching([
|
||||||
[[
|
$seller->getKey() => [
|
||||||
'user_id' => $user->getKey(),
|
|
||||||
'seller_id' => $seller->getKey(),
|
|
||||||
'created_at' => $timestamp,
|
'created_at' => $timestamp,
|
||||||
'updated_at' => $timestamp,
|
'updated_at' => $timestamp,
|
||||||
]],
|
],
|
||||||
['user_id', 'seller_id'],
|
]);
|
||||||
['updated_at']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedFavoriteSearches(User $user, array $payloads): void
|
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')
|
@section('content')
|
||||||
<div class="max-w-[1320px] mx-auto px-4 py-8">
|
<div class="max-w-[1320px] mx-auto px-4 py-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
|
<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">
|
<section class="bg-white border border-slate-200">
|
||||||
@if($requiresLogin ?? false)
|
@if($requiresLogin ?? false)
|
||||||
|
|||||||
@ -4,7 +4,6 @@ namespace Modules\Listing\Database\Seeders;
|
|||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
@ -107,10 +106,6 @@ class ListingSeeder extends Seeder
|
|||||||
|
|
||||||
private function resolveCountries(): Collection
|
private function resolveCountries(): Collection
|
||||||
{
|
{
|
||||||
if (! class_exists(Country::class) || ! Schema::hasTable('countries')) {
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Country::query()
|
return Country::query()
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
@ -120,10 +115,6 @@ class ListingSeeder extends Seeder
|
|||||||
|
|
||||||
private function resolveTurkeyCities(): Collection
|
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()
|
$turkey = Country::query()
|
||||||
->where('code', 'TR')
|
->where('code', 'TR')
|
||||||
->first(['id']);
|
->first(['id']);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -30,10 +31,26 @@ return new class extends Migration
|
|||||||
$table->decimal('longitude', 10, 7)->nullable();
|
$table->decimal('longitude', 10, 7)->nullable();
|
||||||
$table->timestamps();
|
$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
|
public function down(): void
|
||||||
{
|
{
|
||||||
|
Schema::dropIfExists('listing_custom_fields');
|
||||||
Schema::dropIfExists('listings');
|
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();
|
$table->nullableTimestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('media');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@ -1,30 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Admin\Filament\Resources;
|
namespace Modules\Listing\Filament\Admin\Resources;
|
||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TagsInput;
|
use Filament\Forms\Components\TagsInput;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
|
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||||
use Modules\Listing\Models\ListingCustomField;
|
use Modules\Listing\Models\ListingCustomField;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class ListingCustomFieldResource extends Resource
|
class ListingCustomFieldResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = ListingCustomField::class;
|
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;
|
protected static ?int $navigationSort = 30;
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -35,21 +37,7 @@ class ListingCustomFieldResource extends Resource
|
|||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->live(onBlur: true)
|
->live(onBlur: true)
|
||||||
->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void {
|
->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void {
|
||||||
$baseName = \Illuminate\Support\Str::slug((string) $state, '_');
|
$set('name', ListingCustomField::uniqueNameFromLabel((string) $state, $record));
|
||||||
$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);
|
|
||||||
}),
|
}),
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
->required()
|
->required()
|
||||||
@ -63,11 +51,7 @@ class ListingCustomFieldResource extends Resource
|
|||||||
->live(),
|
->live(),
|
||||||
Select::make('category_id')
|
Select::make('category_id')
|
||||||
->label('Category')
|
->label('Category')
|
||||||
->options(fn (): array => Category::query()
|
->options(fn (): array => Category::activeIdNameOptions())
|
||||||
->where('is_active', true)
|
|
||||||
->orderBy('name')
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->all())
|
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->nullable()
|
->nullable()
|
||||||
@ -106,10 +90,7 @@ class ListingCustomFieldResource extends Resource
|
|||||||
TextColumn::make('sort_order')->sortable(),
|
TextColumn::make('sort_order')->sortable(),
|
||||||
])
|
])
|
||||||
->defaultSort('id', 'desc')
|
->defaultSort('id', 'desc')
|
||||||
->actions([
|
->actions(ResourceTableActions::editDelete());
|
||||||
EditAction::make(),
|
|
||||||
DeleteAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||||
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
|
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
|
||||||
|
|
||||||
class CreateListingCustomField extends CreateRecord
|
class CreateListingCustomField extends CreateRecord
|
||||||
{
|
{
|
||||||
@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||||
|
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
|
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
|
||||||
|
|
||||||
class EditListingCustomField extends EditRecord
|
class EditListingCustomField extends EditRecord
|
||||||
{
|
{
|
||||||
@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||||
|
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
|
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
|
||||||
|
|
||||||
class ListListingCustomFields extends ListRecords
|
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
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
|
|
||||||
|
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
|
||||||
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Modules\Admin\Filament\Resources\ListingResource;
|
use Modules\Listing\Filament\Admin\Resources\ListingResource;
|
||||||
|
|
||||||
class CreateListing extends CreateRecord
|
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
|
<?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;
|
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||||
|
|
||||||
class ListListingActivities extends 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
|
<?php
|
||||||
namespace Modules\Admin\Filament\Widgets;
|
|
||||||
|
namespace Modules\Listing\Filament\Admin\Widgets;
|
||||||
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
@ -13,31 +14,27 @@ class ListingOverview extends StatsOverviewWidget
|
|||||||
|
|
||||||
protected function getStats(): array
|
protected function getStats(): array
|
||||||
{
|
{
|
||||||
$totalListings = Listing::query()->count();
|
$stats = Listing::overviewStats();
|
||||||
$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();
|
|
||||||
|
|
||||||
$featuredRatio = $totalListings > 0
|
$featuredRatio = $stats['total'] > 0
|
||||||
? number_format(($featuredListings / $totalListings) * 100, 1).'% of all listings'
|
? number_format(($stats['featured'] / $stats['total']) * 100, 1).'% of all listings'
|
||||||
: '0.0% of all listings';
|
: '0.0% of all listings';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Stat::make('Total Listings', number_format($totalListings))
|
Stat::make('Total Listings', number_format($stats['total']))
|
||||||
->description('All listings in the system')
|
->description('All listings in the system')
|
||||||
->icon('heroicon-o-clipboard-document-list')
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
->color('primary'),
|
->color('primary'),
|
||||||
Stat::make('Active Listings', number_format($activeListings))
|
Stat::make('Active Listings', number_format($stats['active']))
|
||||||
->description(number_format($pendingListings).' pending review')
|
->description(number_format($stats['pending']).' pending review')
|
||||||
->descriptionIcon('heroicon-o-clock')
|
->descriptionIcon('heroicon-o-clock')
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
->color('success'),
|
->color('success'),
|
||||||
Stat::make('Created Today', number_format($createdToday))
|
Stat::make('Created Today', number_format($stats['created_today']))
|
||||||
->description('New listings added today')
|
->description('New listings added today')
|
||||||
->icon('heroicon-o-calendar-days')
|
->icon('heroicon-o-calendar-days')
|
||||||
->color('info'),
|
->color('info'),
|
||||||
Stat::make('Featured Listings', number_format($featuredListings))
|
Stat::make('Featured Listings', number_format($stats['featured']))
|
||||||
->description($featuredRatio)
|
->description($featuredRatio)
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('warning'),
|
->color('warning'),
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Widgets;
|
|
||||||
|
namespace Modules\Listing\Filament\Admin\Widgets;
|
||||||
|
|
||||||
use Filament\Widgets\ChartWidget;
|
use Filament\Widgets\ChartWidget;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
@ -8,6 +9,8 @@ class ListingsTrendChart extends ChartWidget
|
|||||||
{
|
{
|
||||||
protected static ?int $sort = 2;
|
protected static ?int $sort = 2;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
protected ?string $heading = 'Listing Creation Trend';
|
protected ?string $heading = 'Listing Creation Trend';
|
||||||
|
|
||||||
protected ?string $description = 'Daily listing volume by selected period.';
|
protected ?string $description = 'Daily listing volume by selected period.';
|
||||||
@ -24,39 +27,20 @@ class ListingsTrendChart extends ChartWidget
|
|||||||
protected function getData(): array
|
protected function getData(): array
|
||||||
{
|
{
|
||||||
$days = (int) ($this->filter ?? '30');
|
$days = (int) ($this->filter ?? '30');
|
||||||
$startDate = now()->startOfDay()->subDays($days - 1);
|
$trend = Listing::creationTrend($days);
|
||||||
|
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'datasets' => [
|
'datasets' => [
|
||||||
[
|
[
|
||||||
'label' => 'Listings',
|
'label' => 'Listings',
|
||||||
'data' => $data,
|
'data' => $trend['data'],
|
||||||
'fill' => true,
|
'fill' => true,
|
||||||
'borderColor' => '#2563eb',
|
'borderColor' => '#2563eb',
|
||||||
'backgroundColor' => 'rgba(37, 99, 235, 0.12)',
|
'backgroundColor' => 'rgba(37, 99, 235, 0.12)',
|
||||||
'tension' => 0.35,
|
'tension' => 0.35,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'labels' => $labels,
|
'labels' => $trend['labels'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,18 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Listing\Http\Controllers;
|
namespace Modules\Listing\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Modules\Conversation\App\Models\Conversation;
|
use Modules\Conversation\App\Models\Conversation;
|
||||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
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\Category\Models\Category;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
|
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
|
||||||
|
use Modules\Location\Models\Country;
|
||||||
use Modules\Theme\Support\ThemeManager;
|
use Modules\Theme\Support\ThemeManager;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ListingController extends Controller
|
class ListingController extends Controller
|
||||||
{
|
{
|
||||||
@ -53,19 +50,13 @@ class ListingController extends Controller
|
|||||||
$sort = 'smart';
|
$sort = 'smart';
|
||||||
}
|
}
|
||||||
|
|
||||||
$countries = collect();
|
$locationSelection = Country::browseSelection($countryId, $cityId);
|
||||||
$cities = collect();
|
$countryId = $locationSelection['country_id'];
|
||||||
$selectedCountryName = null;
|
$cityId = $locationSelection['city_id'];
|
||||||
$selectedCityName = null;
|
$countries = $locationSelection['countries'];
|
||||||
|
$cities = $locationSelection['cities'];
|
||||||
$this->resolveLocationFilters(
|
$selectedCountryName = $locationSelection['selected_country_name'];
|
||||||
$countryId,
|
$selectedCityName = $locationSelection['selected_city_name'];
|
||||||
$cityId,
|
|
||||||
$countries,
|
|
||||||
$cities,
|
|
||||||
$selectedCountryName,
|
|
||||||
$selectedCityName
|
|
||||||
);
|
|
||||||
|
|
||||||
$listingDirectory = Category::listingDirectory($categoryId);
|
$listingDirectory = Category::listingDirectory($categoryId);
|
||||||
|
|
||||||
@ -109,29 +100,13 @@ class ListingController extends Controller
|
|||||||
if (auth()->check()) {
|
if (auth()->check()) {
|
||||||
$userId = (int) auth()->id();
|
$userId = (int) auth()->id();
|
||||||
|
|
||||||
$favoriteListingIds = auth()->user()
|
$favoriteListingIds = auth()->user()->favoriteListingIds();
|
||||||
->favoriteListings()
|
$conversationListingMap = Conversation::listingMapForBuyer($userId);
|
||||||
->pluck('listings.id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$conversationListingMap = Conversation::query()
|
$isCurrentSearchSaved = FavoriteSearch::isSavedForUser(auth()->user(), [
|
||||||
->where('buyer_id', $userId)
|
|
||||||
->pluck('id', 'listing_id')
|
|
||||||
->map(fn ($conversationId) => (int) $conversationId)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$filters = FavoriteSearch::normalizeFilters([
|
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
'category' => $categoryId,
|
'category' => $categoryId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($filters !== []) {
|
|
||||||
$signature = FavoriteSearch::signatureFor($filters);
|
|
||||||
$isCurrentSearchSaved = auth()->user()
|
|
||||||
->favoriteSearches()
|
|
||||||
->where('signature', $signature)
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return view($this->themes->view('listing', 'index'), compact(
|
return view($this->themes->view('listing', 'index'), compact(
|
||||||
@ -159,13 +134,7 @@ class ListingController extends Controller
|
|||||||
|
|
||||||
public function show(Listing $listing)
|
public function show(Listing $listing)
|
||||||
{
|
{
|
||||||
if (
|
$listing->trackViewBy(auth()->id());
|
||||||
Schema::hasColumn('listings', 'view_count')
|
|
||||||
&& (! auth()->check() || (int) auth()->id() !== (int) $listing->user_id)
|
|
||||||
) {
|
|
||||||
$listing->increment('view_count');
|
|
||||||
$listing->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
$listing->loadMissing([
|
$listing->loadMissing([
|
||||||
'user:id,name,email',
|
'user:id,name,email',
|
||||||
@ -193,10 +162,7 @@ class ListingController extends Controller
|
|||||||
if (auth()->check()) {
|
if (auth()->check()) {
|
||||||
$userId = (int) auth()->id();
|
$userId = (int) auth()->id();
|
||||||
|
|
||||||
$isListingFavorited = auth()->user()
|
$isListingFavorited = in_array((int) $listing->getKey(), auth()->user()->favoriteListingIds(), true);
|
||||||
->favoriteListings()
|
|
||||||
->whereKey($listing->getKey())
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($listing->user_id) {
|
if ($listing->user_id) {
|
||||||
$isSellerFavorited = auth()->user()
|
$isSellerFavorited = auth()->user()
|
||||||
@ -206,25 +172,10 @@ class ListingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($listing->user_id && (int) $listing->user_id !== $userId) {
|
if ($listing->user_id && (int) $listing->user_id !== $userId) {
|
||||||
$existingConversationId = Conversation::buyerListingConversationId(
|
$detailConversation = Conversation::detailForBuyerListing(
|
||||||
(int) $listing->getKey(),
|
(int) $listing->getKey(),
|
||||||
$userId,
|
$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')
|
->route('panel.listings.create')
|
||||||
->with('success', 'You were redirected to the listing creation screen.');
|
->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
|
<?php
|
||||||
|
|
||||||
namespace Modules\Listing\Models;
|
namespace Modules\Listing\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
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\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
use Modules\Listing\Support\ListingImageViewData;
|
use Modules\Conversation\App\Models\Conversation;
|
||||||
use Modules\Listing\States\ListingStatus;
|
use Modules\Listing\States\ListingStatus;
|
||||||
|
use Modules\Listing\Support\ListingImageViewData;
|
||||||
use Modules\Listing\Support\ListingPanelHelper;
|
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 Modules\Video\Models\Video;
|
||||||
use Spatie\Image\Enums\Fit;
|
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Image\Enums\Fit;
|
||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
@ -60,23 +65,23 @@ class Listing extends Model implements HasMedia
|
|||||||
|
|
||||||
public function category()
|
public function category()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\Modules\Category\Models\Category::class);
|
return $this->belongsTo(Category::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user()
|
public function user()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\Modules\User\App\Models\User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function favoritedByUsers()
|
public function favoritedByUsers()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(\Modules\User\App\Models\User::class, 'favorite_listings')
|
return $this->belongsToMany(User::class, 'favorite_listings')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function conversations()
|
public function conversations()
|
||||||
{
|
{
|
||||||
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
|
return $this->hasMany(Conversation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function videos()
|
public function videos()
|
||||||
@ -97,7 +102,7 @@ class Listing extends Model implements HasMedia
|
|||||||
return $query->where('status', 'active');
|
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);
|
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
|
public function scopeForCategory(Builder $query, ?int $categoryId): Builder
|
||||||
{
|
{
|
||||||
return $query->forCategoryIds(Category::listingFilterIds($categoryId));
|
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()
|
$counts = static::query()
|
||||||
->ownedByUser($userId)
|
->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
|
public function panelPrimaryImageUrl(): ?string
|
||||||
{
|
{
|
||||||
return $this->primaryImageUrl('card', 'desktop');
|
return $this->primaryImageUrl('card', 'desktop');
|
||||||
@ -389,6 +503,7 @@ class Listing extends Model implements HasMedia
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$disk = $this->mediaDisk();
|
||||||
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
|
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
|
||||||
$existingMediaItems = $this->getMedia('listing-images');
|
$existingMediaItems = $this->getMedia('listing-images');
|
||||||
|
|
||||||
@ -398,7 +513,7 @@ class Listing extends Model implements HasMedia
|
|||||||
if (
|
if (
|
||||||
$existingMedia
|
$existingMedia
|
||||||
&& (string) $existingMedia->file_name === $targetFileName
|
&& (string) $existingMedia->file_name === $targetFileName
|
||||||
&& (string) $existingMedia->disk === 'public'
|
&& (string) $existingMedia->disk === $disk
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (is_file($existingMedia->getPath())) {
|
if (is_file($existingMedia->getPath())) {
|
||||||
@ -410,12 +525,25 @@ class Listing extends Model implements HasMedia
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->clearMediaCollection('listing-images');
|
$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
|
$this
|
||||||
->addMedia($absolutePath)
|
->addMedia($absolutePath)
|
||||||
->usingFileName($targetFileName)
|
->usingFileName(trim($fileName))
|
||||||
|
->withCustomProperties(self::mediaCustomProperties())
|
||||||
->preservingOriginal()
|
->preservingOriginal()
|
||||||
->toMediaCollection('listing-images', 'public');
|
->toMediaCollection('listing-images', $targetDisk);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function statusValue(): string
|
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
|
public function updateFromPanel(array $attributes): void
|
||||||
{
|
{
|
||||||
$payload = Arr::only($attributes, [
|
$payload = Arr::only($attributes, [
|
||||||
@ -460,7 +626,7 @@ class Listing extends Model implements HasMedia
|
|||||||
$this->forceFill($payload)->save();
|
$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 = Str::slug((string) ($data['title'] ?? 'listing'));
|
||||||
$baseSlug = $baseSlug !== '' ? $baseSlug : 'listing';
|
$baseSlug = $baseSlug !== '' ? $baseSlug : 'listing';
|
||||||
@ -479,7 +645,7 @@ class Listing extends Model implements HasMedia
|
|||||||
|
|
||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('listing-images')->useDisk('public');
|
$this->addMediaCollection('listing-images')->useDisk($this->mediaDisk());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerMediaConversions(?Media $media = null): void
|
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');
|
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
|
protected function location(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
@ -4,15 +4,21 @@ namespace Modules\Listing\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
|
|
||||||
class ListingCustomField extends Model
|
class ListingCustomField extends Model
|
||||||
{
|
{
|
||||||
public const TYPE_TEXT = 'text';
|
public const TYPE_TEXT = 'text';
|
||||||
|
|
||||||
public const TYPE_TEXTAREA = 'textarea';
|
public const TYPE_TEXTAREA = 'textarea';
|
||||||
|
|
||||||
public const TYPE_NUMBER = 'number';
|
public const TYPE_NUMBER = 'number';
|
||||||
|
|
||||||
public const TYPE_SELECT = 'select';
|
public const TYPE_SELECT = 'select';
|
||||||
|
|
||||||
public const TYPE_BOOLEAN = 'boolean';
|
public const TYPE_BOOLEAN = 'boolean';
|
||||||
|
|
||||||
public const TYPE_DATE = 'date';
|
public const TYPE_DATE = 'date';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@ -83,6 +89,24 @@ class ListingCustomField extends Model
|
|||||||
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
|
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
|
public static function upsertSeeded(Category $category, array $attributes): self
|
||||||
{
|
{
|
||||||
return static::query()->updateOrCreate(
|
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
|
<?php
|
||||||
|
|
||||||
namespace Modules\Listing\Providers;
|
namespace Modules\Listing\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@ -6,14 +7,16 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
class ListingServiceProvider extends ServiceProvider
|
class ListingServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
protected string $moduleName = 'Listing';
|
protected string $moduleName = 'Listing';
|
||||||
|
|
||||||
protected string $moduleNameLower = 'listing';
|
protected string $moduleNameLower = 'listing';
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), $this->moduleNameLower);
|
$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'));
|
$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\DatePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Schemas\Components\Component;
|
use Filament\Schemas\Components\Component;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@ -22,9 +22,6 @@ class ListingCustomFieldSchemaBuilder
|
|||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Component>
|
|
||||||
*/
|
|
||||||
public static function formComponents(?int $categoryId): array
|
public static function formComponents(?int $categoryId): array
|
||||||
{
|
{
|
||||||
return ListingCustomField::query()
|
return ListingCustomField::query()
|
||||||
@ -38,10 +35,6 @@ class ListingCustomFieldSchemaBuilder
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $values
|
|
||||||
* @return array<int, array{label: string, value: string}>
|
|
||||||
*/
|
|
||||||
public static function presentableValues(?int $categoryId, array $values): array
|
public static function presentableValues(?int $categoryId, array $values): array
|
||||||
{
|
{
|
||||||
if ($values === []) {
|
if ($values === []) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Listing\Support;
|
namespace Modules\Listing\Support;
|
||||||
|
|
||||||
use App\Settings\GeneralSettings;
|
use Modules\Site\App\Settings\GeneralSettings;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ListingPanelHelper
|
class ListingPanelHelper
|
||||||
@ -32,7 +32,7 @@ class ListingPanelHelper
|
|||||||
return self::currencyCodes()[0] ?? 'USD';
|
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));
|
$normalized = strtoupper(substr(trim((string) $currency), 0, 3));
|
||||||
$codes = self::currencyCodes();
|
$codes = self::currencyCodes();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Support;
|
namespace Modules\Listing\Support;
|
||||||
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
@ -12,16 +12,6 @@ use function Laravel\Ai\agent;
|
|||||||
|
|
||||||
class QuickListingCategorySuggester
|
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
|
public function suggestFromImage(UploadedFile $image): array
|
||||||
{
|
{
|
||||||
$provider = (string) config('quick-listing.ai_provider', 'openai');
|
$provider = (string) config('quick-listing.ai_provider', 'openai');
|
||||||
@ -39,11 +29,7 @@ class QuickListingCategorySuggester
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$categories = Category::query()
|
$categories = Category::activeAiCatalog();
|
||||||
->where('is_active', true)
|
|
||||||
->orderBy('sort_order')
|
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'name', 'parent_id']);
|
|
||||||
|
|
||||||
if ($categories->isEmpty()) {
|
if ($categories->isEmpty()) {
|
||||||
return [
|
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
|
private function buildCatalog(Collection $categories): Collection
|
||||||
{
|
{
|
||||||
$byId = $categories->keyBy('id');
|
$byId = $categories->keyBy('id');
|
||||||
@ -156,4 +138,3 @@ class QuickListingCategorySuggester
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,12 +229,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="listing-filter-card px-4 py-3 hidden lg:flex flex-col xl:flex-row xl:items-center gap-3">
|
<div class="listing-results-bar listing-filter-card hidden lg:flex">
|
||||||
<p class="text-sm text-slate-700 mr-auto">
|
<p class="listing-results-meta">
|
||||||
<strong>{{ number_format($resultListingsCount) }}</strong>
|
<strong>{{ number_format($resultListingsCount) }}</strong>
|
||||||
{{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }}
|
{{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="listing-results-actions">
|
||||||
@auth
|
@auth
|
||||||
<form method="POST" action="{{ route('favorites.searches.store') }}">
|
<form method="POST" action="{{ route('favorites.searches.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
@ -276,9 +276,9 @@
|
|||||||
<input type="hidden" name="date_filter" value="{{ $dateFilter }}">
|
<input type="hidden" name="date_filter" value="{{ $dateFilter }}">
|
||||||
@endif
|
@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>
|
<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="smart" @selected($sort === 'smart')>Recommended</option>
|
||||||
<option value="newest" @selected($sort === 'newest')>Newest</option>
|
<option value="newest" @selected($sort === 'newest')>Newest</option>
|
||||||
<option value="oldest" @selected($sort === 'oldest')>Oldest</option>
|
<option value="oldest" @selected($sort === 'oldest')>Oldest</option>
|
||||||
@ -381,197 +381,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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();
|
->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array{code: string, name: string, phone_code: string}>
|
|
||||||
*/
|
|
||||||
private function countries(): array
|
private function countries(): array
|
||||||
{
|
{
|
||||||
$countries = [];
|
$countries = [];
|
||||||
@ -84,7 +81,7 @@ class LocationSeeder extends Seeder
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$key = 'filament-country-code-field::countries.' . $value;
|
$key = 'filament-country-code-field::countries.'.$value;
|
||||||
$labelEn = trim((string) trans($key, [], 'en'));
|
$labelEn = trim((string) trans($key, [], 'en'));
|
||||||
|
|
||||||
$name = $labelEn !== '' && $labelEn !== $key ? $labelEn : strtoupper($value);
|
$name = $labelEn !== '' && $labelEn !== $key ? $labelEn : strtoupper($value);
|
||||||
@ -112,9 +109,6 @@ class LocationSeeder extends Seeder
|
|||||||
return substr($normalized, 0, 10);
|
return substr($normalized, 0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function turkeyCities(): array
|
private function turkeyCities(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -1,32 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources;
|
|
||||||
|
namespace Modules\Location\Filament\Admin\Resources;
|
||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
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 Modules\Location\Models\City;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class CityResource extends Resource
|
class CityResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = City::class;
|
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 $label = 'City';
|
||||||
|
|
||||||
protected static ?string $pluralLabel = 'Cities';
|
protected static ?string $pluralLabel = 'Cities';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 3;
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -41,12 +45,12 @@ class CityResource extends Resource
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table->columns([
|
return $table->columns([
|
||||||
TextColumn::make('id')->sortable(),
|
ResourceTableColumns::id(),
|
||||||
TextColumn::make('name')->searchable()->sortable(),
|
TextColumn::make('name')->searchable()->sortable(),
|
||||||
TextColumn::make('country.name')->label('Country')->searchable()->sortable(),
|
TextColumn::make('country.name')->label('Country')->searchable()->sortable(),
|
||||||
TextColumn::make('districts_count')->counts('districts')->label('Districts')->sortable(),
|
TextColumn::make('districts_count')->counts('districts')->label('Districts')->sortable(),
|
||||||
IconColumn::make('is_active')->boolean(),
|
ResourceTableColumns::activeIcon(),
|
||||||
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
ResourceTableColumns::createdAtHidden(),
|
||||||
])->defaultSort('id', 'desc')->filters([
|
])->defaultSort('id', 'desc')->filters([
|
||||||
SelectFilter::make('country_id')
|
SelectFilter::make('country_id')
|
||||||
->label('Country')
|
->label('Country')
|
||||||
@ -61,13 +65,7 @@ class CityResource extends Resource
|
|||||||
blank: fn (Builder $query): Builder => $query,
|
blank: fn (Builder $query): Builder => $query,
|
||||||
),
|
),
|
||||||
TernaryFilter::make('is_active')->label('Active'),
|
TernaryFilter::make('is_active')->label('Active'),
|
||||||
])->actions([
|
])->actions(ResourceTableActions::editActivityDelete(static::class));
|
||||||
EditAction::make(),
|
|
||||||
Action::make('activities')
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->url(fn (City $record): string => static::getUrl('activities', ['record' => $record])),
|
|
||||||
DeleteAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
|
|
||||||
|
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
|
||||||
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Modules\Admin\Filament\Resources\CityResource;
|
use Modules\Location\Filament\Admin\Resources\CityResource;
|
||||||
|
|
||||||
class CreateCity extends CreateRecord
|
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
|
<?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;
|
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||||
|
|
||||||
class ListCityActivities extends ListActivities
|
class ListCityActivities extends ListActivities
|
||||||
@ -1,38 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources;
|
|
||||||
|
namespace Modules\Location\Filament\Admin\Resources;
|
||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
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 Modules\Location\Models\Country;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class LocationResource extends Resource
|
class CountryResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Country::class;
|
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 $label = 'Country';
|
||||||
|
|
||||||
protected static ?string $pluralLabel = 'Countries';
|
protected static ?string $pluralLabel = 'Countries';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 2;
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
TextInput::make('name')->required()->maxLength(100),
|
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),
|
TextInput::make('phone_code')->maxLength(10),
|
||||||
Toggle::make('is_active')->default(true),
|
Toggle::make('is_active')->default(true),
|
||||||
]);
|
]);
|
||||||
@ -41,17 +45,17 @@ class LocationResource extends Resource
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table->columns([
|
return $table->columns([
|
||||||
TextColumn::make('id')->sortable(),
|
ResourceTableColumns::id(),
|
||||||
TextColumn::make('name')->searchable()->sortable(),
|
TextColumn::make('name')->searchable()->sortable(),
|
||||||
TextColumn::make('code')->searchable()->sortable(),
|
TextColumn::make('code')->searchable()->sortable(),
|
||||||
TextColumn::make('phone_code'),
|
TextColumn::make('phone_code'),
|
||||||
TextColumn::make('cities_count')->counts('cities')->label('Cities')->sortable(),
|
TextColumn::make('cities_count')->counts('cities')->label('Cities')->sortable(),
|
||||||
IconColumn::make('is_active')->boolean(),
|
ResourceTableColumns::activeIcon(),
|
||||||
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
ResourceTableColumns::createdAtHidden(),
|
||||||
])->defaultSort('id', 'desc')->filters([
|
])->defaultSort('id', 'desc')->filters([
|
||||||
SelectFilter::make('code')
|
SelectFilter::make('code')
|
||||||
->label('Code')
|
->label('Code')
|
||||||
->options(fn (): array => Country::query()->orderBy('code')->pluck('code', 'code')->all()),
|
->options(fn (): array => Country::codeOptions()),
|
||||||
TernaryFilter::make('has_cities')
|
TernaryFilter::make('has_cities')
|
||||||
->label('Has cities')
|
->label('Has cities')
|
||||||
->queries(
|
->queries(
|
||||||
@ -60,22 +64,16 @@ class LocationResource extends Resource
|
|||||||
blank: fn (Builder $query): Builder => $query,
|
blank: fn (Builder $query): Builder => $query,
|
||||||
),
|
),
|
||||||
TernaryFilter::make('is_active')->label('Active'),
|
TernaryFilter::make('is_active')->label('Active'),
|
||||||
])->actions([
|
])->actions(ResourceTableActions::editActivityDelete(static::class));
|
||||||
EditAction::make(),
|
|
||||||
Action::make('activities')
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->url(fn (Country $record): string => static::getUrl('activities', ['record' => $record])),
|
|
||||||
DeleteAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => Pages\ListLocations::route('/'),
|
'index' => Pages\ListCountries::route('/'),
|
||||||
'create' => Pages\CreateLocation::route('/create'),
|
'create' => Pages\CreateCountry::route('/create'),
|
||||||
'activities' => Pages\ListLocationActivities::route('/{record}/activities'),
|
'activities' => Pages\ListCountryActivities::route('/{record}/activities'),
|
||||||
'edit' => Pages\EditLocation::route('/{record}/edit'),
|
'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
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources;
|
|
||||||
|
namespace Modules\Location\Filament\Admin\Resources;
|
||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
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\Country;
|
||||||
use Modules\Location\Models\District;
|
use Modules\Location\Models\District;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -24,10 +23,15 @@ use UnitEnum;
|
|||||||
class DistrictResource extends Resource
|
class DistrictResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = District::class;
|
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 $label = 'District';
|
||||||
|
|
||||||
protected static ?string $pluralLabel = 'Districts';
|
protected static ?string $pluralLabel = 'Districts';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 4;
|
protected static ?int $navigationSort = 4;
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -42,16 +46,16 @@ class DistrictResource extends Resource
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table->columns([
|
return $table->columns([
|
||||||
TextColumn::make('id')->sortable(),
|
ResourceTableColumns::id(),
|
||||||
TextColumn::make('name')->searchable()->sortable(),
|
TextColumn::make('name')->searchable()->sortable(),
|
||||||
TextColumn::make('city.name')->label('City')->searchable()->sortable(),
|
TextColumn::make('city.name')->label('City')->searchable()->sortable(),
|
||||||
TextColumn::make('city.country.name')->label('Country'),
|
TextColumn::make('city.country.name')->label('Country'),
|
||||||
IconColumn::make('is_active')->boolean(),
|
ResourceTableColumns::activeIcon(),
|
||||||
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
ResourceTableColumns::createdAtHidden(),
|
||||||
])->defaultSort('id', 'desc')->filters([
|
])->defaultSort('id', 'desc')->filters([
|
||||||
SelectFilter::make('country_id')
|
SelectFilter::make('country_id')
|
||||||
->label('Country')
|
->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)))),
|
->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')
|
SelectFilter::make('city_id')
|
||||||
->label('City')
|
->label('City')
|
||||||
@ -59,13 +63,7 @@ class DistrictResource extends Resource
|
|||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
TernaryFilter::make('is_active')->label('Active'),
|
TernaryFilter::make('is_active')->label('Active'),
|
||||||
])->actions([
|
])->actions(ResourceTableActions::editActivityDelete(static::class));
|
||||||
EditAction::make(),
|
|
||||||
Action::make('activities')
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->url(fn (District $record): string => static::getUrl('activities', ['record' => $record])),
|
|
||||||
DeleteAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
|
|
||||||
|
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
|
||||||
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Modules\Admin\Filament\Resources\DistrictResource;
|
use Modules\Location\Filament\Admin\Resources\DistrictResource;
|
||||||
|
|
||||||
class CreateDistrict extends CreateRecord
|
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
|
<?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;
|
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||||
|
|
||||||
class ListDistrictActivities extends 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
|
<?php
|
||||||
|
|
||||||
namespace Modules\Location\Models;
|
namespace Modules\Location\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
@ -10,8 +14,14 @@ class City extends Model
|
|||||||
use LogsActivity;
|
use LogsActivity;
|
||||||
|
|
||||||
protected $fillable = ['name', 'country_id', 'is_active'];
|
protected $fillable = ['name', 'country_id', 'is_active'];
|
||||||
|
|
||||||
protected $casts = ['is_active' => 'boolean'];
|
protected $casts = ['is_active' => 'boolean'];
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
public function getActivitylogOptions(): LogOptions
|
public function getActivitylogOptions(): LogOptions
|
||||||
{
|
{
|
||||||
return LogOptions::defaults()
|
return LogOptions::defaults()
|
||||||
@ -20,6 +30,56 @@ class City extends Model
|
|||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function country() { return $this->belongsTo(Country::class); }
|
public function country(): BelongsTo
|
||||||
public function districts() { return $this->hasMany(District::class); }
|
{
|
||||||
|
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
|
<?php
|
||||||
|
|
||||||
namespace Modules\Location\Models;
|
namespace Modules\Location\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
@ -10,8 +13,14 @@ class Country extends Model
|
|||||||
use LogsActivity;
|
use LogsActivity;
|
||||||
|
|
||||||
protected $fillable = ['name', 'code', 'phone_code', 'flag', 'is_active'];
|
protected $fillable = ['name', 'code', 'phone_code', 'flag', 'is_active'];
|
||||||
|
|
||||||
protected $casts = ['is_active' => 'boolean'];
|
protected $casts = ['is_active' => 'boolean'];
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
public function getActivitylogOptions(): LogOptions
|
public function getActivitylogOptions(): LogOptions
|
||||||
{
|
{
|
||||||
return LogOptions::defaults()
|
return LogOptions::defaults()
|
||||||
@ -20,8 +29,165 @@ class Country extends Model
|
|||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cities()
|
public function cities(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(City::class);
|
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
|
<?php
|
||||||
|
|
||||||
namespace Modules\Location\Providers;
|
namespace Modules\Location\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@ -9,9 +10,10 @@ class LocationServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
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->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(): void {}
|
public function register(): void
|
||||||
|
{}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Support;
|
namespace Modules\Location\Support;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Tapp\FilamentCountryCodeField\Enums\CountriesEnum;
|
use Tapp\FilamentCountryCodeField\Enums\CountriesEnum;
|
||||||
@ -17,7 +17,7 @@ class CountryCodeManager
|
|||||||
return self::iso2FromCountryCode(self::defaultCountryCode()) ?? 'TR';
|
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);
|
$value = trim((string) $value);
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ class CountryCodeManager
|
|||||||
return self::countryCodeFromIso2($value) ?? '+90';
|
return self::countryCodeFromIso2($value) ?? '+90';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function isValidCountryCode(null | string $value): bool
|
public static function isValidCountryCode(?string $value): bool
|
||||||
{
|
{
|
||||||
if (! filled($value)) {
|
if (! filled($value)) {
|
||||||
return false;
|
return false;
|
||||||
@ -41,7 +41,7 @@ class CountryCodeManager
|
|||||||
return self::countries()->contains(fn (array $country): bool => $country['country_code'] === trim((string) $value));
|
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));
|
$iso2 = strtoupper(trim((string) $iso2));
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ class CountryCodeManager
|
|||||||
->first(fn (array $country): bool => $country['iso2'] === $iso2)['country_code'] ?? null;
|
->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);
|
$countryCode = trim((string) $countryCode);
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ class CountryCodeManager
|
|||||||
->first(fn (array $country): bool => $country['country_code'] === $countryCode)['iso2'] ?? null;
|
->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);
|
$countryCode = trim((string) $countryCode);
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ class CountryCodeManager
|
|||||||
->first(fn (array $country): bool => $country['country_code'] === $countryCode)['english_label'] ?? null;
|
->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);
|
$value = trim((string) $value);
|
||||||
|
|
||||||
@ -111,7 +111,7 @@ class CountryCodeManager
|
|||||||
})['country_code'] ?? null;
|
})['country_code'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function normalizeStoredCountry(null | string $value): ?string
|
public static function normalizeStoredCountry(?string $value): ?string
|
||||||
{
|
{
|
||||||
$value = trim((string) $value);
|
$value = trim((string) $value);
|
||||||
|
|
||||||
@ -128,9 +128,6 @@ class CountryCodeManager
|
|||||||
return self::labelFromCountryCode($countryCode) ?? $value;
|
return self::labelFromCountryCode($countryCode) ?? $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, array{country_code: string, iso2: string, label: string, english_label: string}>
|
|
||||||
*/
|
|
||||||
private static function countries(): Collection
|
private static function countries(): Collection
|
||||||
{
|
{
|
||||||
static $countries;
|
static $countries;
|
||||||
@ -1,50 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Modules\Location\Models\Country;
|
use Modules\Location\Http\Controllers\LocationLookupController;
|
||||||
|
|
||||||
Route::get('/locations/cities/{country}', function (string $country) {
|
Route::middleware('web')->group(function () {
|
||||||
$lookupValue = trim($country);
|
Route::get('/locations/cities/{country}', [LocationLookupController::class, 'cities'])
|
||||||
|
->name('locations.cities');
|
||||||
if ($lookupValue === '') {
|
Route::get('/locations/districts/{city}', [LocationLookupController::class, 'districts'])
|
||||||
return response()->json([]);
|
->name('locations.districts');
|
||||||
}
|
});
|
||||||
|
|
||||||
$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');
|
|
||||||
|
|||||||
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
|
<?php
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace Modules\Panel\App\Livewire;
|
||||||
|
|
||||||
use App\Support\QuickListingCategorySuggester;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -16,9 +15,10 @@ use Modules\Listing\Models\Listing;
|
|||||||
use Modules\Listing\Models\ListingCustomField;
|
use Modules\Listing\Models\ListingCustomField;
|
||||||
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
|
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
|
||||||
use Modules\Listing\Support\ListingPanelHelper;
|
use Modules\Listing\Support\ListingPanelHelper;
|
||||||
|
use Modules\Listing\Support\QuickListingCategorySuggester;
|
||||||
use Modules\Location\Models\City;
|
use Modules\Location\Models\City;
|
||||||
use Modules\Location\Models\Country;
|
use Modules\Location\Models\Country;
|
||||||
use Modules\S3\Support\MediaStorage;
|
use Modules\Site\App\Support\LocalMedia;
|
||||||
use Modules\User\App\Models\Profile;
|
use Modules\User\App\Models\Profile;
|
||||||
use Modules\Video\Models\Video;
|
use Modules\Video\Models\Video;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@ -28,36 +28,59 @@ class PanelQuickListingForm extends Component
|
|||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
private const TOTAL_STEPS = 5;
|
private const TOTAL_STEPS = 5;
|
||||||
|
|
||||||
private const DRAFT_SESSION_KEY = 'panel_quick_listing_draft';
|
private const DRAFT_SESSION_KEY = 'panel_quick_listing_draft';
|
||||||
|
|
||||||
private const OTHER_CITY_ID = -1;
|
private const OTHER_CITY_ID = -1;
|
||||||
|
|
||||||
public array $photos = [];
|
public array $photos = [];
|
||||||
|
|
||||||
public array $videos = [];
|
public array $videos = [];
|
||||||
|
|
||||||
public array $categories = [];
|
public array $categories = [];
|
||||||
|
|
||||||
public array $countries = [];
|
public array $countries = [];
|
||||||
|
|
||||||
public array $cities = [];
|
public array $cities = [];
|
||||||
|
|
||||||
public array $listingCustomFields = [];
|
public array $listingCustomFields = [];
|
||||||
|
|
||||||
public array $customFieldValues = [];
|
public array $customFieldValues = [];
|
||||||
|
|
||||||
public int $currentStep = 1;
|
public int $currentStep = 1;
|
||||||
|
|
||||||
public string $categorySearch = '';
|
public string $categorySearch = '';
|
||||||
|
|
||||||
public ?int $selectedCategoryId = null;
|
public ?int $selectedCategoryId = null;
|
||||||
|
|
||||||
public ?int $activeParentCategoryId = null;
|
public ?int $activeParentCategoryId = null;
|
||||||
|
|
||||||
public ?int $detectedCategoryId = null;
|
public ?int $detectedCategoryId = null;
|
||||||
|
|
||||||
public ?float $detectedConfidence = null;
|
public ?float $detectedConfidence = null;
|
||||||
|
|
||||||
public ?string $detectedReason = null;
|
public ?string $detectedReason = null;
|
||||||
|
|
||||||
public ?string $detectedError = null;
|
public ?string $detectedError = null;
|
||||||
|
|
||||||
public array $detectedAlternatives = [];
|
public array $detectedAlternatives = [];
|
||||||
|
|
||||||
public bool $isDetecting = false;
|
public bool $isDetecting = false;
|
||||||
|
|
||||||
public string $listingTitle = '';
|
public string $listingTitle = '';
|
||||||
|
|
||||||
public string $price = '';
|
public string $price = '';
|
||||||
|
|
||||||
public string $description = '';
|
public string $description = '';
|
||||||
|
|
||||||
public ?int $selectedCountryId = null;
|
public ?int $selectedCountryId = null;
|
||||||
|
|
||||||
public ?int $selectedCityId = null;
|
public ?int $selectedCityId = null;
|
||||||
|
|
||||||
public bool $isPublishing = false;
|
public bool $isPublishing = false;
|
||||||
|
|
||||||
public bool $shouldPersistDraft = true;
|
public bool $shouldPersistDraft = true;
|
||||||
|
|
||||||
public ?string $publishError = null;
|
public ?string $publishError = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -70,7 +93,7 @@ class PanelQuickListingForm extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('panel.quick-create');
|
return view('panel::quick-create');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dehydrate(): void
|
public function dehydrate(): void
|
||||||
@ -596,10 +619,6 @@ class PanelQuickListingForm extends Component
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profilePhone = Profile::query()
|
|
||||||
->where('user_id', $user->getKey())
|
|
||||||
->value('phone');
|
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'title' => trim($this->listingTitle),
|
'title' => trim($this->listingTitle),
|
||||||
'description' => trim($this->description),
|
'description' => trim($this->description),
|
||||||
@ -609,7 +628,7 @@ class PanelQuickListingForm extends Component
|
|||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'custom_fields' => $this->sanitizedCustomFieldValues(),
|
'custom_fields' => $this->sanitizedCustomFieldValues(),
|
||||||
'contact_email' => (string) $user->email,
|
'contact_email' => (string) $user->email,
|
||||||
'contact_phone' => $profilePhone,
|
'contact_phone' => Profile::phoneForUser($user),
|
||||||
'country' => $this->selectedCountryName,
|
'country' => $this->selectedCountryName,
|
||||||
'city' => $this->selectedCityName,
|
'city' => $this->selectedCityName,
|
||||||
];
|
];
|
||||||
@ -622,10 +641,11 @@ class PanelQuickListingForm extends Component
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$listing
|
$listing->attachListingImage(
|
||||||
->addMedia($photo->getRealPath())
|
$photo->getRealPath(),
|
||||||
->usingFileName($photo->getClientOriginalName())
|
$photo->getClientOriginalName(),
|
||||||
->toMediaCollection('listing-images', $mediaDisk);
|
$mediaDisk
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->videos as $index => $video) {
|
foreach ($this->videos as $index => $video) {
|
||||||
@ -674,75 +694,18 @@ class PanelQuickListingForm extends Component
|
|||||||
|
|
||||||
private function loadCategories(): void
|
private function loadCategories(): void
|
||||||
{
|
{
|
||||||
$all = Category::query()
|
$this->categories = Category::panelQuickCatalog();
|
||||||
->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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadLocations(): void
|
private function loadLocations(): void
|
||||||
{
|
{
|
||||||
$this->countries = Country::query()
|
$this->countries = Country::quickCreateOptions();
|
||||||
->where('is_active', true)
|
$this->cities = City::quickCreateOptions();
|
||||||
->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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadListingCustomFields(): void
|
private function loadListingCustomFields(): void
|
||||||
{
|
{
|
||||||
$this->listingCustomFields = ListingCustomField::query()
|
$this->listingCustomFields = ListingCustomField::panelFieldDefinitions($this->selectedCategoryId);
|
||||||
->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();
|
|
||||||
|
|
||||||
$allowed = collect($this->listingCustomFields)->pluck('name')->all();
|
$allowed = collect($this->listingCustomFields)->pluck('name')->all();
|
||||||
$this->customFieldValues = collect($this->customFieldValues)->only($allowed)->all();
|
$this->customFieldValues = collect($this->customFieldValues)->only($allowed)->all();
|
||||||
@ -762,7 +725,7 @@ class PanelQuickListingForm extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = Profile::query()->where('user_id', $user->getKey())->first();
|
$profile = Profile::detailsForUser($user);
|
||||||
|
|
||||||
if (! $profile) {
|
if (! $profile) {
|
||||||
return;
|
return;
|
||||||
@ -795,7 +758,7 @@ class PanelQuickListingForm extends Component
|
|||||||
|
|
||||||
private function frontendMediaDisk(): string
|
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
|
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