Compare commits

..

No commits in common. "master" and "3.0" have entirely different histories.
master ... 3.0

216 changed files with 7796 additions and 4228 deletions

View File

@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::connection(config('activitylog.database_connection'))
->create(config('activitylog.table_name'), function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('log_name')->nullable();
$table->text('description');
$table->nullableMorphs('subject', 'subject');
$table->string('event')->nullable();
$table->nullableMorphs('causer', 'causer');
$table->json('properties')->nullable();
$table->uuid('batch_uuid')->nullable();
$table->timestamps();
$table->index('log_name');
});
}
public function down(): void
{
Schema::connection(config('activitylog.database_connection'))
->dropIfExists(config('activitylog.table_name'));
}
};

View File

@ -1,21 +1,24 @@
<?php
namespace Modules\Site\Filament\Admin\Pages;
namespace Modules\Admin\Filament\Pages;
use App\Settings\GeneralSettings;
use App\Support\CountryCodeManager;
use App\Support\HomeSlideDefaults;
use BackedEnum;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Modules\Location\Support\CountryCodeManager;
use Modules\Site\App\Settings\GeneralSettings;
use Modules\Site\App\Support\HomeSlideDefaults;
use Modules\Site\App\Support\LocalMedia;
use Modules\Site\Support\Filament\HomeSlideFormSchema;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\S3\Support\MediaStorage;
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
@ -24,13 +27,13 @@ class ManageGeneralSettings extends SettingsPage
{
protected static string $settings = GeneralSettings::class;
protected static ?string $title = 'General Settings';
protected static ?string $title = 'Genel Ayarlar';
protected static ?string $navigationLabel = 'General Settings';
protected static ?string $navigationLabel = 'Genel Ayarlar';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static string | UnitEnum | null $navigationGroup = 'Ayarlar';
protected static ?int $navigationSort = 1;
@ -41,8 +44,15 @@ class ManageGeneralSettings extends SettingsPage
return [
'site_name' => filled($data['site_name'] ?? null) ? $data['site_name'] : $defaults['site_name'],
'site_description' => filled($data['site_description'] ?? null) ? $data['site_description'] : $defaults['site_description'],
'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $defaults['home_slides']),
'media_disk' => MediaStorage::normalizeDriver($data['media_disk'] ?? $defaults['media_disk']),
'home_slides' => $this->normalizeHomeSlides(
$data['home_slides'] ?? $defaults['home_slides'],
MediaStorage::storedDisk('public'),
),
'site_logo' => $data['site_logo'] ?? null,
'site_logo_disk' => filled($data['site_logo'] ?? null)
? MediaStorage::storedDisk($data['site_logo_disk'] ?? 'public')
: null,
'sender_name' => filled($data['sender_name'] ?? null) ? $data['sender_name'] : $defaults['sender_name'],
'sender_email' => filled($data['sender_email'] ?? null) ? $data['sender_email'] : $defaults['sender_email'],
'default_language' => filled($data['default_language'] ?? null) ? $data['default_language'] : $defaults['default_language'],
@ -67,7 +77,14 @@ class ManageGeneralSettings extends SettingsPage
protected function mutateFormDataBeforeSave(array $data): array
{
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? []);
$mediaDriver = MediaStorage::normalizeDriver($data['media_disk'] ?? null);
$mediaDisk = MediaStorage::diskFromDriver($mediaDriver);
$data['media_disk'] = $mediaDriver;
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], $mediaDisk);
$data['site_logo_disk'] = MediaStorage::managesPath($data['site_logo'] ?? null)
? MediaStorage::storedDisk($data['site_logo_disk'] ?? null, $mediaDriver)
: null;
$data['currencies'] = $this->normalizeCurrencies($data['currencies'] ?? []);
return $data;
@ -89,16 +106,32 @@ class ManageGeneralSettings extends SettingsPage
->default($defaults['site_description'])
->rows(3)
->maxLength(500),
Select::make('media_disk')
->label('Media Storage')
->options(MediaStorage::options())
->default($defaults['media_disk'])
->required()
->native(false)
->helperText('Storage driver used for listing images, videos, the site logo, and home slide visuals.'),
HomeSlideFormSchema::make(
$defaults['home_slides'],
fn ($state): array => $this->normalizeHomeSlides($state),
fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()),
),
Hidden::make('site_logo_disk'),
FileUpload::make('site_logo')
->label('Site Logo')
->image()
->disk(LocalMedia::disk())
->disk(fn (Get $get): string => MediaStorage::storedDisk($get('site_logo_disk'), $get('media_disk')))
->directory('settings')
->visibility('public'),
->visibility('public')
->afterStateUpdated(function (Get $get, Set $set, mixed $state): void {
$set(
'site_logo_disk',
MediaStorage::managesPath($state)
? MediaStorage::diskFromDriver($get('media_disk'))
: null,
);
}),
TextInput::make('sender_name')
->label('Sender Name')
->default($defaults['sender_name'])
@ -209,9 +242,11 @@ class ManageGeneralSettings extends SettingsPage
return [
'site_name' => $siteName,
'site_description' => 'A fast and secure marketplace for buying and selling.',
'media_disk' => MediaStorage::defaultDriver(),
'home_slides' => $this->defaultHomeSlides(),
'site_logo_disk' => null,
'sender_name' => $siteName,
'sender_email' => (string) config('mail.from.address', 'info@'.$siteHost),
'sender_email' => (string) config('mail.from.address', 'info@' . $siteHost),
'default_language' => in_array(config('app.locale'), array_keys($this->localeOptions()), true) ? (string) config('app.locale') : 'en',
'default_country_code' => CountryCodeManager::normalizeCountryCode(config('app.default_country_code', '+90')),
'currencies' => $this->normalizeCurrencies(config('app.currencies', ['TRY'])),
@ -237,7 +272,7 @@ class ManageGeneralSettings extends SettingsPage
->all();
}
private function normalizeCurrencies(null|array|string $state): array
private function normalizeCurrencies(null | array | string $state): array
{
$source = is_array($state) ? $state : (filled($state) ? [$state] : []);
@ -257,8 +292,8 @@ class ManageGeneralSettings extends SettingsPage
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state): array
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
{
return HomeSlideDefaults::normalize($state);
return HomeSlideDefaults::normalize($state, $defaultDisk);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Modules\Admin\Filament\Pages;
use App\Settings\GeneralSettings;
use App\Support\HomeSlideDefaults;
use BackedEnum;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\S3\Support\MediaStorage;
use UnitEnum;
class ManageHomeSlides extends SettingsPage
{
protected static string $settings = GeneralSettings::class;
protected static ?string $title = 'Home Slides';
protected static ?string $navigationLabel = 'Home Slides';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-photo';
protected static string | UnitEnum | null $navigationGroup = 'Content';
protected static ?int $navigationSort = 2;
protected Width | string | null $maxContentWidth = Width::Full;
protected function mutateFormDataBeforeFill(array $data): array
{
return [
'home_slides' => $this->normalizeHomeSlides(
$data['home_slides'] ?? $this->defaultHomeSlides(),
MediaStorage::storedDisk('public'),
),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], MediaStorage::activeDisk());
return $data;
}
public function form(Schema $schema): Schema
{
return $schema
->components([
HomeSlideFormSchema::make(
$this->defaultHomeSlides(),
fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()),
),
]);
}
private function defaultHomeSlides(): array
{
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
{
return HomeSlideDefaults::normalize($state, $defaultDisk);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,218 @@
<?php
namespace Modules\Admin\Filament\Resources;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use App\Support\CountryCodeManager;
use BackedEnum;
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\ListingResource\Pages;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Video\Support\Filament\VideoFormSchema;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class ListingResource extends Resource
{
protected static ?string $model = Listing::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state) . '-' . \Illuminate\Support\Str::random(4))),
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
Textarea::make('description')->rows(4),
TextInput::make('price')
->numeric()
->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2),
Select::make('currency')
->options(fn () => ListingPanelHelper::currencyOptions())
->default(fn () => ListingPanelHelper::defaultCurrency())
->required(),
Select::make('category_id')
->label('Category')
->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')->email()->maxLength(255),
Toggle::make('is_featured')->default(false),
Select::make('country')
->label('Country')
->options(fn (): array => Country::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable()
->preload()
->live()
->afterStateUpdated(fn ($state, $set) => $set('city', null))
->nullable(),
Select::make('city')
->label('City')
->options(function (Get $get): array {
$country = $get('country');
return City::query()
->where('is_active', true)
->when($country, fn (Builder $query, string $country): Builder => $query->whereHas('country', fn (Builder $countryQuery): Builder => $countryQuery->where('name', $country)))
->orderBy('name')
->pluck('name', 'name')
->all();
})
->searchable()
->preload()
->nullable(),
Map::make('location')
->label('Location')
->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled())
->draggable()
->clickable()
->autocomplete('city')
->autocompleteReverse(true)
->reverseGeocode([
'city' => '%L',
])
->defaultLocation([41.0082, 28.9784])
->defaultZoom(10)
->height('320px')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('images')
->collection('listing-images')
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
SpatieMediaLibraryImageColumn::make('images')
->collection('listing-images')
->circular(),
TextColumn::make('id')->sortable(),
TextColumn::make('title')->searchable()->sortable()->limit(40),
TextColumn::make('category.name')->label('Category')->sortable(),
TextColumn::make('user.email')->label('Owner')->searchable()->toggleable()->sortable(),
TextColumn::make('price')
->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency())
->sortable(),
StateFusionSelectColumn::make('status')->sortable(),
IconColumn::make('is_featured')->boolean()->label('Featured')->sortable(),
TextColumn::make('city')->sortable(),
TextColumn::make('country')->sortable(),
TextColumn::make('created_at')->dateTime()->sortable(),
])->filters([
StateFusionSelectFilter::make('status'),
SelectFilter::make('category_id')
->label('Category')
->relationship('category', 'name')
->searchable()
->preload(),
SelectFilter::make('user_id')
->label('Owner')
->relationship('user', 'email')
->searchable()
->preload(),
SelectFilter::make('country')
->options(fn (): array => Country::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable(),
SelectFilter::make('city')
->options(fn (): array => City::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable(),
TernaryFilter::make('is_featured')->label('Featured'),
Filter::make('created_at')
->label('Created Date')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['from'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date))
->when($data['until'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date))),
Filter::make('price')
->label('Price Range')
->schema([
TextInput::make('min')->numeric()->label('Min'),
TextInput::make('max')->numeric()->label('Max'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['min'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '>=', (float) $amount))
->when($data['max'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '<=', (float) $amount))),
])
->filtersLayout(FiltersLayout::AboveContent)
->filtersFormColumns(3)
->filtersFormWidth('7xl')
->persistFiltersInSession()
->defaultSort('id', 'desc')
->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (Listing $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'),
'activities' => Pages\ListListingActivities::route('/{record}/activities'),
'edit' => Pages\EditListing::route('/{record}/edit'),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,18 @@
<?php
namespace Modules\User\Filament\Admin\Resources;
namespace Modules\Admin\Filament\Resources;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use Modules\User\App\Models\User;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\User\Filament\Admin\Resources\UserResource\Pages;
use Modules\User\App\Models\User;
use Modules\Admin\Filament\Resources\UserResource\Pages;
use Modules\User\App\Support\Filament\UserFormFields;
use STS\FilamentImpersonate\Actions\Impersonate;
use UnitEnum;
@ -20,10 +20,8 @@ use UnitEnum;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-users';
protected static string|UnitEnum|null $navigationGroup = 'User Management';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-users';
protected static string | UnitEnum | null $navigationGroup = 'User Management';
public static function form(Schema $schema): Schema
{
@ -39,7 +37,7 @@ class UserResource extends Resource
public static function table(Table $table): Table
{
return $table->columns([
ResourceTableColumns::id(),
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('email')->searchable()->sortable(),
TextColumn::make('roles.name')->badge()->label('Roles'),
@ -47,9 +45,14 @@ class UserResource extends Resource
TextColumn::make('created_at')->dateTime()->sortable(),
])->defaultSort('id', 'desc')->filters([
StateFusionSelectFilter::make('status'),
])->actions(ResourceTableActions::editActivityDelete(static::class, [
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (User $record): string => static::getUrl('activities', ['record' => $record])),
Impersonate::make(),
]));
DeleteAction::make(),
]);
}
public static function getPages(): array

View File

@ -1,9 +1,8 @@
<?php
namespace Modules\User\Filament\Admin\Resources\UserResource\Pages;
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\User\Filament\Admin\Resources\UserResource;
use Modules\Admin\Filament\Resources\UserResource;
class CreateUser extends CreateRecord
{

View File

@ -1,10 +1,9 @@
<?php
namespace Modules\User\Filament\Admin\Resources\UserResource\Pages;
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\User\Filament\Admin\Resources\UserResource;
use Modules\Admin\Filament\Resources\UserResource;
use STS\FilamentImpersonate\Actions\Impersonate;
class EditUser extends EditRecord

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,15 @@
<?php
namespace Modules\Site\Support\Filament;
namespace Modules\Admin\Support;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Modules\Site\App\Support\LocalMedia;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Modules\S3\Support\MediaStorage;
final class HomeSlideFormSchema
{
@ -16,15 +19,24 @@ final class HomeSlideFormSchema
->label('Homepage Slides')
->helperText('Use 1 to 5 slides. Upload a wide image for each slide to improve the hero area.')
->schema([
Hidden::make('disk'),
FileUpload::make('image_path')
->label('Slide Image')
->image()
->disk(LocalMedia::disk())
->disk(fn (Get $get): string => MediaStorage::storedDisk($get('disk'), self::mediaDriver($get)))
->directory('home-slides')
->visibility('public')
->imageEditor()
->imagePreviewHeight('200')
->helperText('Recommended: 1600x1000 or wider.')
->afterStateUpdated(function (Get $get, Set $set, mixed $state): void {
$set(
'disk',
MediaStorage::managesPath($state)
? MediaStorage::diskFromDriver(self::mediaDriver($get))
: null,
);
})
->columnSpanFull(),
TextInput::make('badge')
->label('Badge')
@ -60,4 +72,13 @@ final class HomeSlideFormSchema
->itemLabel(fn (array $state): string => filled($state['title'] ?? null) ? (string) $state['title'] : 'New Slide')
->dehydrateStateUsing(fn ($state) => $normalizeSlides($state));
}
private static function mediaDriver(Get $get): string
{
$driver = $get('../../media_disk');
return is_string($driver) && trim($driver) !== ''
? MediaStorage::normalizeDriver($driver)
: MediaStorage::activeDriver();
}
}

View File

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

View File

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

View File

@ -1,5 +1,4 @@
<?php
namespace Modules\Category\Models;
use Illuminate\Database\Eloquent\Builder;
@ -33,7 +32,6 @@ class Category extends Model
];
protected $fillable = ['name', 'slug', 'description', 'icon', 'parent_id', 'level', 'sort_order', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function getActivitylogOptions(): LogOptions
@ -105,91 +103,6 @@ class Category extends Model
->get(['id', 'name']);
}
public static function activeIdNameOptions(): array
{
return static::query()
->active()
->ordered()
->pluck('name', 'id')
->all();
}
public static function activeCount(): int
{
return (int) static::query()
->active()
->count();
}
public static function homeParentCategories(int $limit = 8): Collection
{
return static::query()
->active()
->whereNull('parent_id')
->ordered()
->limit($limit)
->get();
}
public static function headerNavigationItems(int $limit = 8): array
{
return static::query()
->active()
->whereNull('parent_id')
->ordered()
->limit($limit)
->get(['id', 'name', 'icon'])
->map(fn (self $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'icon_url' => $category->iconUrl(),
])
->all();
}
public static function activeAiCatalog(): Collection
{
return static::query()
->active()
->ordered()
->get(['id', 'name', 'parent_id']);
}
public static function panelQuickCatalog(): array
{
$all = static::query()
->active()
->ordered()
->get(['id', 'name', 'parent_id', 'icon']);
$childrenCount = static::query()
->active()
->selectRaw('parent_id, count(*) as aggregate')
->whereNotNull('parent_id')
->groupBy('parent_id')
->pluck('aggregate', 'parent_id');
return $all
->map(fn (self $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'parent_id' => $category->parent_id ? (int) $category->parent_id : null,
'icon' => $category->icon,
'has_children' => ((int) ($childrenCount[$category->id] ?? 0)) > 0,
])
->all();
}
public static function rootIdNameOptions(): array
{
return static::query()
->active()
->whereNull('parent_id')
->ordered()
->pluck('name', 'id')
->all();
}
public static function themePills(int $limit = 8): Collection
{
return static::query()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('conversations')) {
Schema::create('conversations', function (Blueprint $table): void {
$table->id();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('last_message_at')->nullable();
$table->timestamps();
$table->unique(['listing_id', 'buyer_id']);
$table->index(['seller_id', 'last_message_at']);
$table->index(['buyer_id', 'last_message_at']);
});
}
if (! Schema::hasTable('conversation_messages')) {
Schema::create('conversation_messages', function (Blueprint $table): void {
$table->id();
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
$table->text('body');
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
$table->index(['conversation_id', 'read_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('conversation_messages');
Schema::dropIfExists('conversations');
}
};

View File

@ -3,6 +3,7 @@
namespace Modules\Conversation\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing;
@ -13,6 +14,10 @@ class ConversationDemoSeeder extends Seeder
{
public function run(): void
{
if (! $this->conversationTablesExist()) {
return;
}
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
@ -68,6 +73,11 @@ class ConversationDemoSeeder extends Seeder
}
}
private function conversationTablesExist(): bool
{
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
}
private function seedConversationThread(
User $seller,
User $buyer,
@ -97,7 +107,7 @@ class ConversationDemoSeeder extends Seeder
$readAfterMinutes = $payload['read_after_minutes'];
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
$message = new ConversationMessage;
$message = new ConversationMessage();
$message->forceFill([
'conversation_id' => $conversation->getKey(),
'sender_id' => $sender->getKey(),

View File

@ -5,10 +5,10 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel::partials.sidebar', ['activeMenu' => 'inbox'])
@include('panel.partials.sidebar', ['activeMenu' => 'inbox'])
<section class="space-y-4">
@include('panel::partials.page-header', [
@include('panel.partials.page-header', [
'title' => 'Inbox',
'description' => 'Read and reply to buyer messages from the same panel shell used across the site.',
'actions' => $requiresLogin ?? false

View File

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

View File

@ -2,11 +2,11 @@
namespace Modules\Demo\App\Support;
use App\Settings\GeneralSettings;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Modules\Demo\App\Models\DemoInstance;
use Modules\Site\App\Settings\GeneralSettings;
use Modules\User\App\Models\User;
use Spatie\Permission\PermissionRegistrar;
use Throwable;

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('favorite_listings')) {
Schema::create('favorite_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'listing_id']);
});
}
if (! Schema::hasTable('favorite_sellers')) {
Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'seller_id']);
});
}
if (! Schema::hasTable('favorite_searches')) {
Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('search_term')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->json('filters')->nullable();
$table->string('signature', 64);
$table->timestamps();
$table->unique(['user_id', 'signature']);
});
}
}
public function down(): void
{
Schema::dropIfExists('favorite_searches');
Schema::dropIfExists('favorite_sellers');
Schema::dropIfExists('favorite_listings');
}
};

View File

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

View File

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

View File

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

View File

@ -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::create('listing_custom_fields', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->string('type', 32);
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->text('placeholder')->nullable();
$table->text('help_text')->nullable();
$table->json('options')->nullable();
$table->boolean('is_required')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('listing_custom_fields');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,22 @@
<?php
namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Conversation\App\Models\Conversation;
use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingImageViewData;
use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Site\App\Support\LocalMedia;
use Modules\User\App\Models\User;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
use Spatie\Image\Enums\Fit;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -65,23 +60,23 @@ class Listing extends Model implements HasMedia
public function category()
{
return $this->belongsTo(Category::class);
return $this->belongsTo(\Modules\Category\Models\Category::class);
}
public function user()
{
return $this->belongsTo(User::class);
return $this->belongsTo(\Modules\User\App\Models\User::class);
}
public function favoritedByUsers()
{
return $this->belongsToMany(User::class, 'favorite_listings')
return $this->belongsToMany(\Modules\User\App\Models\User::class, 'favorite_listings')
->withTimestamps();
}
public function conversations()
{
return $this->hasMany(Conversation::class);
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
}
public function videos()
@ -102,7 +97,7 @@ class Listing extends Model implements HasMedia
return $query->where('status', 'active');
}
public function scopeOwnedByUser(Builder $query, int|string|null $userId): Builder
public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder
{
return $query->where('user_id', $userId);
}
@ -132,24 +127,6 @@ class Listing extends Model implements HasMedia
});
}
public function scopeWithPanelIndexState(Builder $query): Builder
{
return $query
->with('category:id,name')
->withCount('favoritedByUsers')
->withCount('videos')
->withCount([
'videos as ready_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery
->whereNotNull('path')
->where('is_active', true),
'videos as pending_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery
->whereIn('status', [
VideoStatus::Pending->value,
VideoStatus::Processing->value,
]),
]);
}
public function scopeForCategory(Builder $query, ?int $categoryId): Builder
{
return $query->forCategoryIds(Category::listingFilterIds($categoryId));
@ -295,7 +272,7 @@ class Listing extends Model implements HasMedia
];
}
public static function panelStatusCountsForUser(int|string $userId): array
public static function panelStatusCountsForUser(int | string $userId): array
{
$counts = static::query()
->ownedByUser($userId)
@ -312,97 +289,6 @@ class Listing extends Model implements HasMedia
];
}
public static function activeCount(): int
{
return (int) static::query()
->active()
->count();
}
public static function overviewStats(): array
{
$counts = static::query()
->selectRaw('COUNT(*) as total')
->selectRaw("SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active")
->selectRaw("SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending")
->selectRaw('SUM(CASE WHEN is_featured = true THEN 1 ELSE 0 END) as featured')
->first();
return [
'total' => (int) ($counts?->total ?? 0),
'active' => (int) ($counts?->active ?? 0),
'pending' => (int) ($counts?->pending ?? 0),
'featured' => (int) ($counts?->featured ?? 0),
'created_today' => (int) static::query()
->where('created_at', '>=', now()->startOfDay())
->count(),
];
}
public static function creationTrend(int $days): array
{
$safeDays = max(1, $days);
$startDate = now()->startOfDay()->subDays($safeDays - 1);
$countsByDate = static::query()
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $startDate)
->groupBy('day')
->orderBy('day')
->pluck('total', 'day')
->all();
$labels = [];
$data = [];
for ($index = 0; $index < $safeDays; $index++) {
$date = $startDate->copy()->addDays($index);
$dateKey = $date->toDateString();
$labels[] = $date->format('M j');
$data[] = (int) ($countsByDate[$dateKey] ?? 0);
}
return [
'labels' => $labels,
'data' => $data,
];
}
public static function homeFeatured(int $limit = 4): Collection
{
return static::query()
->active()
->where('is_featured', true)
->latest()
->take($limit)
->get();
}
public static function homeRecent(int $limit = 8): Collection
{
return static::query()
->active()
->latest()
->take($limit)
->get();
}
public static function panelIndexDataForUser(User $user, string $search, string $status): array
{
$listings = static::query()
->ownedByUser($user->getKey())
->withPanelIndexState()
->searchTerm($search)
->forPanelStatus($status)
->latest('id')
->paginate(10)
->withQueryString();
return [
'listings' => $listings,
'counts' => static::panelStatusCountsForUser($user->getKey()),
];
}
public function panelPrimaryImageUrl(): ?string
{
return $this->primaryImageUrl('card', 'desktop');
@ -503,7 +389,6 @@ class Listing extends Model implements HasMedia
return;
}
$disk = $this->mediaDisk();
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
$existingMediaItems = $this->getMedia('listing-images');
@ -513,7 +398,7 @@ class Listing extends Model implements HasMedia
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === $disk
&& (string) $existingMedia->disk === 'public'
) {
try {
if (is_file($existingMedia->getPath())) {
@ -525,25 +410,12 @@ class Listing extends Model implements HasMedia
}
$this->clearMediaCollection('listing-images');
$this->attachListingImage($absolutePath, $targetFileName, $disk);
}
public function attachListingImage(string $absolutePath, string $fileName, ?string $disk = null): void
{
if (! is_file($absolutePath)) {
return;
}
$targetDisk = is_string($disk) && trim($disk) !== ''
? trim($disk)
: $this->mediaDisk();
$this
->addMedia($absolutePath)
->usingFileName(trim($fileName))
->withCustomProperties(self::mediaCustomProperties())
->usingFileName($targetFileName)
->preservingOriginal()
->toMediaCollection('listing-images', $targetDisk);
->toMediaCollection('listing-images', 'public');
}
public function statusValue(): string
@ -563,44 +435,6 @@ class Listing extends Model implements HasMedia
};
}
public function loadPanelEditor(): self
{
return $this->load([
'category:id,name',
'videos:id,listing_id,title,status,is_active,path,upload_path,duration_seconds,size',
]);
}
public function assertOwnedBy(User $user): void
{
abort_unless((int) $this->user_id === (int) $user->getKey(), 403);
}
public function trackViewBy(null|int|string $viewerId): void
{
if ((int) $this->user_id === (int) $viewerId) {
return;
}
$this->increment('view_count');
$this->refresh();
}
public function markAsSold(): void
{
$this->forceFill([
'status' => 'sold',
])->save();
}
public function republish(): void
{
$this->forceFill([
'status' => 'active',
'expires_at' => now()->addDays(self::DEFAULT_PANEL_EXPIRY_WINDOW_DAYS),
])->save();
}
public function updateFromPanel(array $attributes): void
{
$payload = Arr::only($attributes, [
@ -626,7 +460,7 @@ class Listing extends Model implements HasMedia
$this->forceFill($payload)->save();
}
public static function createFromFrontend(array $data, null|int|string $userId): self
public static function createFromFrontend(array $data, null | int | string $userId): self
{
$baseSlug = Str::slug((string) ($data['title'] ?? 'listing'));
$baseSlug = $baseSlug !== '' ? $baseSlug : 'listing';
@ -645,7 +479,7 @@ class Listing extends Model implements HasMedia
public function registerMediaCollections(): void
{
$this->addMediaCollection('listing-images')->useDisk($this->mediaDisk());
$this->addMediaCollection('listing-images')->useDisk('public');
}
public function registerMediaConversions(?Media $media = null): void
@ -718,37 +552,6 @@ class Listing extends Model implements HasMedia
return str_contains($argv, 'db:seed') || str_contains($argv, '--seed');
}
private function mediaDisk(): string
{
return LocalMedia::disk();
}
public static function mediaCustomProperties(): array
{
$scope = static::mediaPathScope();
return $scope !== null
? ['path_scope' => $scope]
: [];
}
public static function mediaPathScope(): ?string
{
$connection = (string) config('database.default', 'pgsql');
$searchPath = config("database.connections.{$connection}.search_path");
$value = is_array($searchPath)
? implode('_', $searchPath)
: (string) $searchPath;
$scope = (string) Str::of($value)
->before(',')
->trim()
->lower()
->replaceMatches('/[^a-z0-9_]+/', '_')
->trim('_');
return $scope !== '' ? $scope : null;
}
protected function location(): Attribute
{
return Attribute::make(

View File

@ -4,21 +4,15 @@ namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
class ListingCustomField extends Model
{
public const TYPE_TEXT = 'text';
public const TYPE_TEXTAREA = 'textarea';
public const TYPE_NUMBER = 'number';
public const TYPE_SELECT = 'select';
public const TYPE_BOOLEAN = 'boolean';
public const TYPE_DATE = 'date';
protected $fillable = [
@ -89,24 +83,6 @@ class ListingCustomField extends Model
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
}
public static function uniqueNameFromLabel(string $label, ?self $record = null): string
{
$baseName = Str::slug($label, '_');
$baseName = $baseName !== '' ? $baseName : 'custom_field';
$name = $baseName;
$counter = 1;
while (static::query()
->where('name', $name)
->when($record, fn (Builder $query): Builder => $query->whereKeyNot($record->getKey()))
->exists()) {
$name = "{$baseName}_{$counter}";
$counter++;
}
return $name;
}
public static function upsertSeeded(Category $category, array $attributes): self
{
return static::query()->updateOrCreate(
@ -124,26 +100,4 @@ class ListingCustomField extends Model
],
);
}
public static function panelFieldDefinitions(?int $categoryId): array
{
return static::query()
->active()
->forCategory($categoryId)
->ordered()
->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options'])
->map(fn (self $field): array => [
'name' => (string) $field->name,
'label' => (string) $field->label,
'type' => (string) $field->type,
'is_required' => (bool) $field->is_required,
'placeholder' => $field->placeholder,
'help_text' => $field->help_text,
'options' => collect($field->options ?? [])
->map(fn ($option): string => (string) $option)
->values()
->all(),
])
->all();
}
}

View File

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

View File

@ -1,180 +0,0 @@
<?php
namespace Modules\Listing\Support\Filament;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Location\Support\CountryCodeManager;
use Modules\Site\App\Support\LocalMedia;
use Modules\Video\Support\Filament\VideoFormSchema;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class AdminListingResourceSchema
{
public static function form(): array
{
return [
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', Str::slug($state).'-'.Str::random(4))),
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
Textarea::make('description')->rows(4),
TextInput::make('price')
->numeric()
->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2),
Select::make('currency')
->options(fn (): array => ListingPanelHelper::currencyOptions())
->default(fn (): string => ListingPanelHelper::defaultCurrency())
->required(),
Select::make('category_id')
->label('Category')
->options(fn (): array => Category::activeIdNameOptions())
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')->email()->maxLength(255),
Toggle::make('is_featured')->default(false),
Select::make('country')
->label('Country')
->options(fn (): array => Country::nameOptions())
->searchable()
->preload()
->live()
->afterStateUpdated(fn ($state, $set) => $set('city', null))
->nullable(),
Select::make('city')
->label('City')
->options(fn (Get $get): array => City::nameOptions($get('country')))
->searchable()
->preload()
->nullable(),
Map::make('location')
->label('Location')
->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled())
->draggable()
->clickable()
->autocomplete('city')
->autocompleteReverse(true)
->reverseGeocode([
'city' => '%L',
])
->defaultLocation([41.0082, 28.9784])
->defaultZoom(10)
->height('320px')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('images')
->collection('listing-images')
->disk(fn (): string => LocalMedia::disk())
->customProperties(fn (): array => Listing::mediaCustomProperties())
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
];
}
public static function configureTable(Table $table, string $resourceClass): Table
{
return $table
->columns([
SpatieMediaLibraryImageColumn::make('images')
->collection('listing-images')
->circular(),
TextColumn::make('id')->sortable(),
TextColumn::make('title')->searchable()->sortable()->limit(40),
TextColumn::make('category.name')->label('Category')->sortable(),
TextColumn::make('user.email')->label('Owner')->searchable()->toggleable()->sortable(),
TextColumn::make('price')
->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency())
->sortable(),
StateFusionSelectColumn::make('status')->sortable(),
IconColumn::make('is_featured')->boolean()->label('Featured')->sortable(),
TextColumn::make('city')->sortable(),
TextColumn::make('country')->sortable(),
TextColumn::make('created_at')->dateTime()->sortable(),
])
->filters([
StateFusionSelectFilter::make('status'),
SelectFilter::make('category_id')
->label('Category')
->relationship('category', 'name')
->searchable()
->preload(),
SelectFilter::make('user_id')
->label('Owner')
->relationship('user', 'email')
->searchable()
->preload(),
SelectFilter::make('country')
->options(fn (): array => Country::nameOptions())
->searchable(),
SelectFilter::make('city')
->options(fn (): array => City::nameOptions(null, false))
->searchable(),
TernaryFilter::make('is_featured')->label('Featured'),
Filter::make('created_at')
->label('Created Date')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['from'] ?? null, fn (Builder $builder, string $date): Builder => $builder->whereDate('created_at', '>=', $date))
->when($data['until'] ?? null, fn (Builder $builder, string $date): Builder => $builder->whereDate('created_at', '<=', $date))),
Filter::make('price')
->label('Price Range')
->schema([
TextInput::make('min')->numeric()->label('Min'),
TextInput::make('max')->numeric()->label('Max'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['min'] ?? null, fn (Builder $builder, string $amount): Builder => $builder->where('price', '>=', (float) $amount))
->when($data['max'] ?? null, fn (Builder $builder, string $amount): Builder => $builder->where('price', '<=', (float) $amount))),
])
->filtersLayout(FiltersLayout::AboveContentCollapsible)
->filtersFormColumns(3)
->filtersFormWidth('7xl')
->persistFiltersInSession()
->defaultSort('id', 'desc')
->actions(ResourceTableActions::editActivityDelete($resourceClass));
}
}

View File

@ -4,8 +4,8 @@ namespace Modules\Listing\Support;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Illuminate\Support\Carbon;
@ -22,6 +22,9 @@ class ListingCustomFieldSchemaBuilder
->exists();
}
/**
* @return array<int, Component>
*/
public static function formComponents(?int $categoryId): array
{
return ListingCustomField::query()
@ -35,6 +38,10 @@ class ListingCustomFieldSchemaBuilder
->all();
}
/**
* @param array<string, mixed> $values
* @return array<int, array{label: string, value: string}>
*/
public static function presentableValues(?int $categoryId, array $values): array
{
if ($values === []) {

View File

@ -2,7 +2,7 @@
namespace Modules\Listing\Support;
use Modules\Site\App\Settings\GeneralSettings;
use App\Settings\GeneralSettings;
use Throwable;
class ListingPanelHelper
@ -32,7 +32,7 @@ class ListingPanelHelper
return self::currencyCodes()[0] ?? 'USD';
}
public static function normalizeCurrency(?string $currency): string
public static function normalizeCurrency(null | string $currency): string
{
$normalized = strtoupper(substr(trim((string) $currency), 0, 3));
$codes = self::currencyCodes();

View File

@ -1,5 +1,4 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -31,26 +30,10 @@ return new class extends Migration
$table->decimal('longitude', 10, 7)->nullable();
$table->timestamps();
});
Schema::create('listing_custom_fields', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->string('type', 32);
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->text('placeholder')->nullable();
$table->text('help_text')->nullable();
$table->json('options')->nullable();
$table->boolean('is_required')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('listing_custom_fields');
Schema::dropIfExists('listings');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,33 +0,0 @@
<?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.',
];
}
}

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