diff --git a/database/migrations/2026_03_03_093635_create_activity_log_table.php b/Modules/Admin/Database/migrations/2026_03_03_093635_create_activity_log_table.php similarity index 100% rename from database/migrations/2026_03_03_093635_create_activity_log_table.php rename to Modules/Admin/Database/migrations/2026_03_03_093635_create_activity_log_table.php diff --git a/Modules/Admin/Filament/Resources/CategoryResource/Pages/EditCategory.php b/Modules/Admin/Filament/Resources/CategoryResource/Pages/EditCategory.php deleted file mode 100644 index 8dcd616d9..000000000 --- a/Modules/Admin/Filament/Resources/CategoryResource/Pages/EditCategory.php +++ /dev/null @@ -1,12 +0,0 @@ -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,6 +67,12 @@ class AdminPanelProvider extends PanelProvider ->users([ 'Admin' => 'a@a.com', ]), + CategoryPlugin::make(), + ListingPlugin::make(), + LocationPlugin::make(), + SitePlugin::make(), + UserPlugin::make(), + VideoPlugin::make(), ]) ->pages([Dashboard::class]) ->middleware([ diff --git a/Modules/Admin/Providers/AdminServiceProvider.php b/Modules/Admin/Providers/AdminServiceProvider.php index 9a290e883..fe8e6a286 100644 --- a/Modules/Admin/Providers/AdminServiceProvider.php +++ b/Modules/Admin/Providers/AdminServiceProvider.php @@ -12,7 +12,5 @@ class AdminServiceProvider extends ServiceProvider } public function register(): void - { - $this->app->register(AdminPanelProvider::class); - } + {} } diff --git a/Modules/Category/CategoryPlugin.php b/Modules/Category/CategoryPlugin.php new file mode 100644 index 000000000..4e7bfc202 --- /dev/null +++ b/Modules/Category/CategoryPlugin.php @@ -0,0 +1,29 @@ +discoverResources( + in: module_path('Category', 'Filament/Admin/Resources'), + for: 'Modules\\Category\\Filament\\Admin\\Resources', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Admin/Filament/Resources/CategoryResource.php b/Modules/Category/Filament/Admin/Resources/CategoryResource.php similarity index 96% rename from Modules/Admin/Filament/Resources/CategoryResource.php rename to Modules/Category/Filament/Admin/Resources/CategoryResource.php index 6688db7d1..774daec2f 100644 --- a/Modules/Admin/Filament/Resources/CategoryResource.php +++ b/Modules/Category/Filament/Admin/Resources/CategoryResource.php @@ -1,6 +1,6 @@ loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category'); } - public function register(): void {} + public function register(): void + {} } diff --git a/Modules/Conversation/App/Http/Controllers/ConversationController.php b/Modules/Conversation/App/Http/Controllers/ConversationController.php index 6abbd033d..238a03cf4 100644 --- a/Modules/Conversation/App/Http/Controllers/ConversationController.php +++ b/Modules/Conversation/App/Http/Controllers/ConversationController.php @@ -6,7 +6,6 @@ use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Schema; use Illuminate\View\View; use Modules\Conversation\App\Events\ConversationReadUpdated; use Modules\Conversation\App\Events\InboxMessageCreated; @@ -14,7 +13,6 @@ use Modules\Conversation\App\Models\Conversation; use Modules\Conversation\App\Models\ConversationMessage; use Modules\Conversation\App\Support\QuickMessageCatalog; use Modules\Listing\Models\Listing; -use Throwable; class ConversationController extends Controller { @@ -28,28 +26,23 @@ class ConversationController extends Controller $conversations = collect(); $selectedConversation = null; - if ($userId && $this->messagingTablesReady()) { - try { - [ - 'conversations' => $conversations, - 'selectedConversation' => $selectedConversation, - 'markedRead' => $markedRead, - ] = $this->resolveInboxState( - $userId, - $messageFilter, - $request->integer('conversation'), - true, - ); + if ($userId) { + [ + 'conversations' => $conversations, + 'selectedConversation' => $selectedConversation, + 'markedRead' => $markedRead, + ] = $this->resolveInboxState( + $userId, + $messageFilter, + $request->integer('conversation'), + true, + ); - if ($selectedConversation && $markedRead) { - broadcast(new ConversationReadUpdated( - $userId, - $selectedConversation->readPayloadFor($userId), - )); - } - } catch (Throwable) { - $conversations = collect(); - $selectedConversation = null; + if ($selectedConversation && $markedRead) { + broadcast(new ConversationReadUpdated( + $userId, + $selectedConversation->readPayloadFor($userId), + )); } } @@ -64,8 +57,6 @@ class ConversationController extends Controller public function state(Request $request): JsonResponse { - abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.'); - $userId = (int) $request->user()->getKey(); $messageFilter = $this->resolveMessageFilter($request); @@ -91,14 +82,6 @@ class ConversationController extends Controller public function start(Request $request, Listing $listing): RedirectResponse | JsonResponse { - if (! $this->messagingTablesReady()) { - if ($request->expectsJson()) { - return response()->json(['message' => 'Messaging is not available yet.'], 503); - } - - return back()->with('error', 'Messaging is not available yet.'); - } - $user = $request->user(); if (! $listing->user_id) { @@ -124,8 +107,7 @@ class ConversationController extends Controller } $conversation = Conversation::openForListingBuyer($listing, (int) $user->getKey()); - - $user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]); + $user->rememberListing($listing); $message = null; if ($messageBody !== '') { @@ -144,14 +126,6 @@ class ConversationController extends Controller public function send(Request $request, Conversation $conversation): RedirectResponse | JsonResponse { - if (! $this->messagingTablesReady()) { - if ($request->expectsJson()) { - return response()->json(['message' => 'Messaging is not available yet.'], 503); - } - - return back()->with('error', 'Messaging is not available yet.'); - } - $user = $request->user(); $userId = (int) $user->getKey(); @@ -187,8 +161,6 @@ class ConversationController extends Controller public function read(Request $request, Conversation $conversation): JsonResponse { - abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.'); - $userId = (int) $request->user()->getKey(); abort_unless($conversation->hasParticipant($userId), 403); @@ -310,12 +282,4 @@ class ConversationController extends Controller } } - private function messagingTablesReady(): bool - { - try { - return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages'); - } catch (Throwable) { - return false; - } - } } diff --git a/Modules/Conversation/App/Models/Conversation.php b/Modules/Conversation/App/Models/Conversation.php index 2c5252420..144faf6f9 100644 --- a/Modules/Conversation/App/Models/Conversation.php +++ b/Modules/Conversation/App/Models/Conversation.php @@ -284,6 +284,42 @@ class Conversation extends Model return is_null($value) ? null : (int) $value; } + public static function detailForBuyerListing(int $listingId, int $buyerId): ?self + { + $conversationId = static::buyerListingConversationId($listingId, $buyerId); + + if (! $conversationId) { + return null; + } + + $conversation = static::query() + ->forUser($buyerId) + ->find($conversationId); + + if (! $conversation) { + return null; + } + + $conversation->loadThread(); + $conversation->loadCount([ + 'messages as unread_count' => fn (Builder $query) => $query + ->where('sender_id', '!=', $buyerId) + ->whereNull('read_at'), + ]); + + return $conversation; + } + + public static function listingMapForBuyer(int $buyerId, array $listingIds = []): array + { + return static::query() + ->where('buyer_id', $buyerId) + ->when($listingIds !== [], fn (Builder $query): Builder => $query->whereIn('listing_id', $listingIds)) + ->pluck('id', 'listing_id') + ->map(fn ($conversationId): int => (int) $conversationId) + ->all(); + } + public static function unreadCountForUser(int $userId): int { return (int) ConversationMessage::query() diff --git a/Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php b/Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php index e866e7d9e..6d8d1a24b 100644 --- a/Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php +++ b/Modules/Conversation/Database/Seeders/ConversationDemoSeeder.php @@ -3,7 +3,6 @@ namespace Modules\Conversation\Database\Seeders; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Schema; use Modules\Conversation\App\Models\Conversation; use Modules\Conversation\App\Models\ConversationMessage; use Modules\Listing\Models\Listing; @@ -14,10 +13,6 @@ class ConversationDemoSeeder extends Seeder { public function run(): void { - if (! $this->conversationTablesExist()) { - return; - } - $users = User::query() ->whereIn('email', DemoUserCatalog::emails()) ->orderBy('email') @@ -73,11 +68,6 @@ class ConversationDemoSeeder extends Seeder } } - private function conversationTablesExist(): bool - { - return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages'); - } - private function seedConversationThread( User $seller, User $buyer, diff --git a/Modules/Conversation/Database/migrations/2026_03_04_000000_create_conversation_tables.php b/Modules/Conversation/Database/migrations/2026_03_04_000000_create_conversation_tables.php index 49b718c8e..49a13af3f 100644 --- a/Modules/Conversation/Database/migrations/2026_03_04_000000_create_conversation_tables.php +++ b/Modules/Conversation/Database/migrations/2026_03_04_000000_create_conversation_tables.php @@ -8,34 +8,30 @@ 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(); + 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']); - }); - } + $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(); + 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']); - }); - } + $table->index(['conversation_id', 'created_at']); + $table->index(['conversation_id', 'read_at']); + }); } public function down(): void diff --git a/Modules/Favorite/App/Http/Controllers/FavoriteController.php b/Modules/Favorite/App/Http/Controllers/FavoriteController.php index 1bae61310..a20763eac 100644 --- a/Modules/Favorite/App/Http/Controllers/FavoriteController.php +++ b/Modules/Favorite/App/Http/Controllers/FavoriteController.php @@ -5,14 +5,12 @@ namespace Modules\Favorite\App\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Facades\Schema; use Modules\Category\Models\Category; use Modules\Conversation\App\Models\Conversation; use Modules\Favorite\App\Models\FavoriteSearch; use Modules\Listing\Models\Listing; use Modules\User\App\Models\User; use Modules\User\App\Support\AuthRedirector; -use Throwable; class FavoriteController extends Controller { @@ -40,13 +38,7 @@ class FavoriteController extends Controller $user = $request->user(); $requiresLogin = ! $user; - $categories = collect(); - if ($this->tableExists('categories')) { - $categories = Category::query() - ->where('is_active', true) - ->orderBy('name') - ->get(['id', 'name']); - } + $categories = Category::filterOptions(); $favoriteListings = $this->emptyPaginator(); $favoriteSearches = $this->emptyPaginator(); @@ -54,64 +46,22 @@ class FavoriteController extends Controller $buyerConversationListingMap = []; if ($user && $activeTab === 'listings') { - try { - if ($this->tableExists('favorite_listings')) { - $favoriteListings = $user->favoriteListings() - ->with(['category:id,name', 'user:id,name']) - ->wherePivot('created_at', '>=', now()->subYear()) - ->when($statusFilter === 'active', fn ($query) => $query->where('status', 'active')) - ->when($selectedCategoryId, fn ($query) => $query->where('category_id', $selectedCategoryId)) - ->orderByPivot('created_at', 'desc') - ->paginate(10) - ->withQueryString(); - } + $favoriteListings = $user->favoriteListingsPage($statusFilter, $selectedCategoryId); - if ( - $favoriteListings->isNotEmpty() - && $this->tableExists('conversations') - ) { - $userId = (int) $user->getKey(); - $buyerConversationListingMap = Conversation::query() - ->where('buyer_id', $userId) - ->whereIn('listing_id', $favoriteListings->pluck('id')->all()) - ->pluck('id', 'listing_id') - ->map(fn ($conversationId) => (int) $conversationId) - ->all(); - } - } catch (Throwable) { - $favoriteListings = $this->emptyPaginator(); - $buyerConversationListingMap = []; + if ($favoriteListings->isNotEmpty()) { + $buyerConversationListingMap = Conversation::listingMapForBuyer( + (int) $user->getKey(), + $favoriteListings->pluck('id')->all(), + ); } } if ($user && $activeTab === 'searches') { - try { - if ($this->tableExists('favorite_searches')) { - $favoriteSearches = $user->favoriteSearches() - ->with('category:id,name') - ->latest() - ->paginate(10) - ->withQueryString(); - } - } catch (Throwable) { - $favoriteSearches = $this->emptyPaginator(); - } + $favoriteSearches = $user->favoriteSearchesPage(); } if ($user && $activeTab === 'sellers') { - try { - if ($this->tableExists('favorite_sellers')) { - $favoriteSellers = $user->favoriteSellers() - ->withCount([ - 'listings as active_listings_count' => fn ($query) => $query->where('status', 'active'), - ]) - ->orderByPivot('created_at', 'desc') - ->paginate(10) - ->withQueryString(); - } - } catch (Throwable) { - $favoriteSellers = $this->emptyPaginator(); - } + $favoriteSellers = $user->favoriteSellersPage(); } return view('favorite::index', [ @@ -163,24 +113,7 @@ class FavoriteController extends Controller return back()->with('error', 'Select at least one filter before saving a search.'); } - $signature = FavoriteSearch::signatureFor($filters); - - $categoryName = null; - if (isset($filters['category'])) { - $categoryName = Category::query()->whereKey($filters['category'])->value('name'); - } - - $label = FavoriteSearch::labelFor($filters, is_string($categoryName) ? $categoryName : null); - - $favoriteSearch = $request->user()->favoriteSearches()->firstOrCreate( - ['signature' => $signature], - [ - 'label' => $label, - 'search_term' => $filters['search'] ?? null, - 'category_id' => $filters['category'] ?? null, - 'filters' => $filters, - ] - ); + $favoriteSearch = FavoriteSearch::storeForUser($request->user(), $filters); if (! $favoriteSearch->wasRecentlyCreated) { return back()->with('success', 'This search is already in your favorites.'); @@ -200,15 +133,6 @@ class FavoriteController extends Controller return back()->with('success', 'Saved search deleted.'); } - private function tableExists(string $table): bool - { - try { - return Schema::hasTable($table); - } catch (Throwable) { - return false; - } - } - private function emptyPaginator(): LengthAwarePaginator { return new LengthAwarePaginator([], 0, 10, 1, [ diff --git a/Modules/Favorite/App/Models/FavoriteSearch.php b/Modules/Favorite/App/Models/FavoriteSearch.php index c424eb1a2..ea4d0e4ea 100644 --- a/Modules/Favorite/App/Models/FavoriteSearch.php +++ b/Modules/Favorite/App/Models/FavoriteSearch.php @@ -53,4 +53,36 @@ class FavoriteSearch extends Model return $labelParts !== [] ? implode(' ยท ', $labelParts) : 'Filtered search'; } + + public static function isSavedForUser(User $user, array $filters): bool + { + $normalized = static::normalizeFilters($filters); + + if ($normalized === []) { + return false; + } + + return $user->favoriteSearches() + ->where('signature', static::signatureFor($normalized)) + ->exists(); + } + + public static function storeForUser(User $user, array $filters): self + { + $normalized = static::normalizeFilters($filters); + $signature = static::signatureFor($normalized); + $categoryName = isset($normalized['category']) + ? Category::query()->whereKey($normalized['category'])->value('name') + : null; + + return $user->favoriteSearches()->firstOrCreate( + ['signature' => $signature], + [ + 'label' => static::labelFor($normalized, is_string($categoryName) ? $categoryName : null), + 'search_term' => $normalized['search'] ?? null, + 'category_id' => $normalized['category'] ?? null, + 'filters' => $normalized, + ] + ); + } } diff --git a/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php b/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php index d55ad15dd..fec3fd517 100644 --- a/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php +++ b/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php @@ -4,8 +4,6 @@ namespace Modules\Favorite\Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use Modules\Category\Models\Category; use Modules\Favorite\App\Models\FavoriteSearch; use Modules\Listing\Models\Listing; @@ -16,10 +14,6 @@ class FavoriteDemoSeeder extends Seeder { public function run(): void { - if (! $this->favoriteTablesExist()) { - return; - } - $users = User::query() ->whereIn('email', DemoUserCatalog::emails()) ->orderBy('email') @@ -30,8 +24,11 @@ class FavoriteDemoSeeder extends Seeder return; } - DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete(); - DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete(); + $users->each(function (User $user): void { + $user->favoriteListings()->detach(); + $user->favoriteSellers()->detach(); + }); + FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete(); foreach ($users as $index => $user) { @@ -56,38 +53,25 @@ class FavoriteDemoSeeder extends Seeder } } - private function favoriteTablesExist(): bool - { - return Schema::hasTable('favorite_listings') - && Schema::hasTable('favorite_sellers') - && Schema::hasTable('favorite_searches'); - } - private function seedFavoriteListings(User $user, Collection $listings): void { - $rows = $listings + $payload = $listings ->values() - ->map(function (Listing $listing, int $index) use ($user): array { + ->mapWithKeys(function (Listing $listing, int $index): array { $timestamp = now()->subHours(8 + ($index * 3)); - return [ - 'user_id' => $user->getKey(), - 'listing_id' => $listing->getKey(), + return [$listing->getKey() => [ 'created_at' => $timestamp, 'updated_at' => $timestamp, - ]; + ]]; }) ->all(); - if ($rows === []) { + if ($payload === []) { return; } - DB::table('favorite_listings')->upsert( - $rows, - ['user_id', 'listing_id'], - ['updated_at'] - ); + $user->favoriteListings()->syncWithoutDetaching($payload); } private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void @@ -96,16 +80,12 @@ class FavoriteDemoSeeder extends Seeder return; } - DB::table('favorite_sellers')->upsert( - [[ - 'user_id' => $user->getKey(), - 'seller_id' => $seller->getKey(), + $user->favoriteSellers()->syncWithoutDetaching([ + $seller->getKey() => [ 'created_at' => $timestamp, 'updated_at' => $timestamp, - ]], - ['user_id', 'seller_id'], - ['updated_at'] - ); + ], + ]); } private function seedFavoriteSearches(User $user, array $payloads): void diff --git a/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php b/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php index 23995ff2a..350194c75 100644 --- a/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php +++ b/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php @@ -8,42 +8,36 @@ 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(); + 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']); - }); - } + $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(); + 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']); - }); - } + $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(); + 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']); - }); - } + $table->unique(['user_id', 'signature']); + }); } public function down(): void diff --git a/Modules/Listing/Database/Seeders/ListingSeeder.php b/Modules/Listing/Database/Seeders/ListingSeeder.php index fbdaadba9..2df0e6d29 100644 --- a/Modules/Listing/Database/Seeders/ListingSeeder.php +++ b/Modules/Listing/Database/Seeders/ListingSeeder.php @@ -4,7 +4,6 @@ namespace Modules\Listing\Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Modules\Category\Models\Category; use Modules\Listing\Models\Listing; @@ -107,10 +106,6 @@ class ListingSeeder extends Seeder private function resolveCountries(): Collection { - if (! class_exists(Country::class) || ! Schema::hasTable('countries')) { - return collect(); - } - return Country::query() ->where('is_active', true) ->orderBy('name') @@ -120,10 +115,6 @@ class ListingSeeder extends Seeder private function resolveTurkeyCities(): Collection { - if (! class_exists(City::class) || ! Schema::hasTable('cities') || ! Schema::hasTable('countries')) { - return collect(['Istanbul', 'Ankara', 'Izmir', 'Bursa', 'Antalya']); - } - $turkey = Country::query() ->where('code', 'TR') ->first(['id']); diff --git a/database/migrations/2026_03_03_085614_create_media_table.php b/Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php similarity index 92% rename from database/migrations/2026_03_03_085614_create_media_table.php rename to Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php index 47a4be987..e1fa63b1e 100644 --- a/database/migrations/2026_03_03_085614_create_media_table.php +++ b/Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php @@ -29,4 +29,9 @@ return new class extends Migration $table->nullableTimestamps(); }); } + + public function down(): void + { + Schema::dropIfExists('media'); + } }; diff --git a/Modules/Admin/Filament/Resources/ListingCustomFieldResource.php b/Modules/Listing/Filament/Admin/Resources/ListingCustomFieldResource.php similarity index 83% rename from Modules/Admin/Filament/Resources/ListingCustomFieldResource.php rename to Modules/Listing/Filament/Admin/Resources/ListingCustomFieldResource.php index 00e59cb69..2a5190c43 100644 --- a/Modules/Admin/Filament/Resources/ListingCustomFieldResource.php +++ b/Modules/Listing/Filament/Admin/Resources/ListingCustomFieldResource.php @@ -1,6 +1,6 @@ maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void { - $baseName = \Illuminate\Support\Str::slug((string) $state, '_'); - $baseName = $baseName !== '' ? $baseName : 'custom_field'; - - $name = $baseName; - $counter = 1; - - while (ListingCustomField::query() - ->where('name', $name) - ->when($record, fn ($query) => $query->whereKeyNot($record->getKey())) - ->exists()) { - $name = "{$baseName}_{$counter}"; - $counter++; - } - - $set('name', $name); + $set('name', ListingCustomField::uniqueNameFromLabel((string) $state, $record)); }), TextInput::make('name') ->required() diff --git a/Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php b/Modules/Listing/Filament/Admin/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php similarity index 53% rename from Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php rename to Modules/Listing/Filament/Admin/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php index 6bcaf8216..ade9f2a6c 100644 --- a/Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php +++ b/Modules/Listing/Filament/Admin/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php @@ -1,9 +1,9 @@ count(); - $activeListings = Listing::query()->where('status', 'active')->count(); - $pendingListings = Listing::query()->where('status', 'pending')->count(); - $featuredListings = Listing::query()->where('is_featured', true)->count(); - $createdToday = Listing::query()->where('created_at', '>=', now()->startOfDay())->count(); + $stats = Listing::overviewStats(); - $featuredRatio = $totalListings > 0 - ? number_format(($featuredListings / $totalListings) * 100, 1).'% of all listings' + $featuredRatio = $stats['total'] > 0 + ? number_format(($stats['featured'] / $stats['total']) * 100, 1).'% of all listings' : '0.0% of all listings'; return [ - Stat::make('Total Listings', number_format($totalListings)) + Stat::make('Total Listings', number_format($stats['total'])) ->description('All listings in the system') ->icon('heroicon-o-clipboard-document-list') ->color('primary'), - Stat::make('Active Listings', number_format($activeListings)) - ->description(number_format($pendingListings).' pending review') + Stat::make('Active Listings', number_format($stats['active'])) + ->description(number_format($stats['pending']).' pending review') ->descriptionIcon('heroicon-o-clock') ->icon('heroicon-o-check-circle') ->color('success'), - Stat::make('Created Today', number_format($createdToday)) + Stat::make('Created Today', number_format($stats['created_today'])) ->description('New listings added today') ->icon('heroicon-o-calendar-days') ->color('info'), - Stat::make('Featured Listings', number_format($featuredListings)) + Stat::make('Featured Listings', number_format($stats['featured'])) ->description($featuredRatio) ->icon('heroicon-o-star') ->color('warning'), diff --git a/Modules/Admin/Filament/Widgets/ListingsTrendChart.php b/Modules/Listing/Filament/Admin/Widgets/ListingsTrendChart.php similarity index 58% rename from Modules/Admin/Filament/Widgets/ListingsTrendChart.php rename to Modules/Listing/Filament/Admin/Widgets/ListingsTrendChart.php index f1011b330..8f3149483 100644 --- a/Modules/Admin/Filament/Widgets/ListingsTrendChart.php +++ b/Modules/Listing/Filament/Admin/Widgets/ListingsTrendChart.php @@ -1,6 +1,6 @@ filter ?? '30'); - $startDate = now()->startOfDay()->subDays($days - 1); - - $countsByDate = Listing::query() - ->selectRaw('DATE(created_at) as day, COUNT(*) as total') - ->where('created_at', '>=', $startDate) - ->groupBy('day') - ->orderBy('day') - ->pluck('total', 'day') - ->all(); - - $labels = []; - $data = []; - - for ($index = 0; $index < $days; $index++) { - $date = $startDate->copy()->addDays($index); - $dateKey = $date->toDateString(); - - $labels[] = $date->format('M j'); - $data[] = (int) ($countsByDate[$dateKey] ?? 0); - } + $trend = Listing::creationTrend($days); return [ 'datasets' => [ [ 'label' => 'Listings', - 'data' => $data, + 'data' => $trend['data'], 'fill' => true, 'borderColor' => '#2563eb', 'backgroundColor' => 'rgba(37, 99, 235, 0.12)', 'tension' => 0.35, ], ], - 'labels' => $labels, + 'labels' => $trend['labels'], ]; } diff --git a/Modules/Listing/Http/Controllers/ListingController.php b/Modules/Listing/Http/Controllers/ListingController.php index d50c507fb..abd2e9a6d 100644 --- a/Modules/Listing/Http/Controllers/ListingController.php +++ b/Modules/Listing/Http/Controllers/ListingController.php @@ -1,18 +1,15 @@ resolveLocationFilters( - $countryId, - $cityId, - $countries, - $cities, - $selectedCountryName, - $selectedCityName - ); + $locationSelection = Country::browseSelection($countryId, $cityId); + $countryId = $locationSelection['country_id']; + $cityId = $locationSelection['city_id']; + $countries = $locationSelection['countries']; + $cities = $locationSelection['cities']; + $selectedCountryName = $locationSelection['selected_country_name']; + $selectedCityName = $locationSelection['selected_city_name']; $listingDirectory = Category::listingDirectory($categoryId); @@ -109,29 +100,13 @@ class ListingController extends Controller if (auth()->check()) { $userId = (int) auth()->id(); - $favoriteListingIds = auth()->user() - ->favoriteListings() - ->pluck('listings.id') - ->all(); + $favoriteListingIds = auth()->user()->favoriteListingIds(); + $conversationListingMap = Conversation::listingMapForBuyer($userId); - $conversationListingMap = Conversation::query() - ->where('buyer_id', $userId) - ->pluck('id', 'listing_id') - ->map(fn ($conversationId) => (int) $conversationId) - ->all(); - - $filters = FavoriteSearch::normalizeFilters([ + $isCurrentSearchSaved = FavoriteSearch::isSavedForUser(auth()->user(), [ 'search' => $search, 'category' => $categoryId, ]); - - if ($filters !== []) { - $signature = FavoriteSearch::signatureFor($filters); - $isCurrentSearchSaved = auth()->user() - ->favoriteSearches() - ->where('signature', $signature) - ->exists(); - } } return view($this->themes->view('listing', 'index'), compact( @@ -159,13 +134,7 @@ class ListingController extends Controller public function show(Listing $listing) { - if ( - Schema::hasColumn('listings', 'view_count') - && (! auth()->check() || (int) auth()->id() !== (int) $listing->user_id) - ) { - $listing->increment('view_count'); - $listing->refresh(); - } + $listing->trackViewBy(auth()->id()); $listing->loadMissing([ 'user:id,name,email', @@ -193,10 +162,7 @@ class ListingController extends Controller if (auth()->check()) { $userId = (int) auth()->id(); - $isListingFavorited = auth()->user() - ->favoriteListings() - ->whereKey($listing->getKey()) - ->exists(); + $isListingFavorited = in_array((int) $listing->getKey(), auth()->user()->favoriteListingIds(), true); if ($listing->user_id) { $isSellerFavorited = auth()->user() @@ -206,25 +172,10 @@ class ListingController extends Controller } if ($listing->user_id && (int) $listing->user_id !== $userId) { - $existingConversationId = Conversation::buyerListingConversationId( + $detailConversation = Conversation::detailForBuyerListing( (int) $listing->getKey(), $userId, ); - - if ($existingConversationId) { - $detailConversation = Conversation::query() - ->forUser($userId) - ->find($existingConversationId); - - if ($detailConversation) { - $detailConversation->loadThread(); - $detailConversation->loadCount([ - 'messages as unread_count' => fn ($query) => $query - ->where('sender_id', '!=', $userId) - ->whereNull('read_at'), - ]); - } - } } } @@ -261,81 +212,4 @@ class ListingController extends Controller ->route('panel.listings.create') ->with('success', 'You were redirected to the listing creation screen.'); } - - private function resolveLocationFilters( - ?int &$countryId, - ?int &$cityId, - Collection &$countries, - Collection &$cities, - ?string &$selectedCountryName, - ?string &$selectedCityName - ): void { - try { - if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) { - return; - } - - $countries = Country::query() - ->where('is_active', true) - ->orderBy('name') - ->get(['id', 'name']); - - $selectedCountry = $countryId - ? $countries->firstWhere('id', $countryId) - : null; - - if (! $selectedCountry && $countryId) { - $selectedCountry = Country::query()->whereKey($countryId)->first(['id', 'name']); - } - - $selectedCity = null; - if ($cityId) { - $selectedCity = City::query()->whereKey($cityId)->first(['id', 'name', 'country_id']); - if (! $selectedCity) { - $cityId = null; - } - } - - if ($selectedCity && ! $selectedCountry) { - $countryId = (int) $selectedCity->country_id; - $selectedCountry = Country::query()->whereKey($countryId)->first(['id', 'name']); - } - - if ($selectedCountry) { - $selectedCountryName = (string) $selectedCountry->name; - $cities = City::query() - ->where('country_id', $selectedCountry->id) - ->where('is_active', true) - ->orderBy('name') - ->get(['id', 'name', 'country_id']); - - if ($cities->isEmpty()) { - $cities = City::query() - ->where('country_id', $selectedCountry->id) - ->orderBy('name') - ->get(['id', 'name', 'country_id']); - } - } else { - $countryId = null; - $cities = collect(); - } - - if ($selectedCity) { - if ($selectedCountry && (int) $selectedCity->country_id !== (int) $selectedCountry->id) { - $selectedCity = null; - $cityId = null; - } else { - $selectedCityName = (string) $selectedCity->name; - } - } - } catch (Throwable) { - $countryId = null; - $cityId = null; - $selectedCountryName = null; - $selectedCityName = null; - $countries = collect(); - $cities = collect(); - } - } - } diff --git a/Modules/Listing/ListingPlugin.php b/Modules/Listing/ListingPlugin.php new file mode 100644 index 000000000..da986fcb3 --- /dev/null +++ b/Modules/Listing/ListingPlugin.php @@ -0,0 +1,34 @@ +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 {} +} diff --git a/Modules/Listing/Models/Listing.php b/Modules/Listing/Models/Listing.php index 0a512ee5d..3bbae9ef3 100644 --- a/Modules/Listing/Models/Listing.php +++ b/Modules/Listing/Models/Listing.php @@ -319,6 +319,54 @@ class Listing extends Model implements HasMedia ->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() @@ -528,6 +576,16 @@ class Listing extends Model implements HasMedia 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([ diff --git a/Modules/Listing/Models/ListingCustomField.php b/Modules/Listing/Models/ListingCustomField.php index 6fbd4e07e..13c4fce06 100644 --- a/Modules/Listing/Models/ListingCustomField.php +++ b/Modules/Listing/Models/ListingCustomField.php @@ -4,6 +4,7 @@ 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 @@ -88,6 +89,24 @@ class ListingCustomField extends Model return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all(); } + public static function uniqueNameFromLabel(string $label, ?self $record = null): string + { + $baseName = Str::slug($label, '_'); + $baseName = $baseName !== '' ? $baseName : 'custom_field'; + $name = $baseName; + $counter = 1; + + while (static::query() + ->where('name', $name) + ->when($record, fn (Builder $query): Builder => $query->whereKeyNot($record->getKey())) + ->exists()) { + $name = "{$baseName}_{$counter}"; + $counter++; + } + + return $name; + } + public static function upsertSeeded(Category $category, array $attributes): self { return static::query()->updateOrCreate( diff --git a/Modules/Listing/Providers/ListingServiceProvider.php b/Modules/Listing/Providers/ListingServiceProvider.php index 1adbb53ca..04e4bfd27 100644 --- a/Modules/Listing/Providers/ListingServiceProvider.php +++ b/Modules/Listing/Providers/ListingServiceProvider.php @@ -17,5 +17,6 @@ class ListingServiceProvider extends ServiceProvider $this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php')); } - public function register(): void {} + public function register(): void + {} } diff --git a/Modules/Listing/resources/views/partials/index-content.blade.php b/Modules/Listing/resources/views/partials/index-content.blade.php index 9fba863bc..c26caae62 100644 --- a/Modules/Listing/resources/views/partials/index-content.blade.php +++ b/Modules/Listing/resources/views/partials/index-content.blade.php @@ -381,196 +381,3 @@ - - diff --git a/Modules/Admin/Filament/Resources/CityResource.php b/Modules/Location/Filament/Admin/Resources/CityResource.php similarity index 96% rename from Modules/Admin/Filament/Resources/CityResource.php rename to Modules/Location/Filament/Admin/Resources/CityResource.php index 1c007bc56..9e2dc971a 100644 --- a/Modules/Admin/Filament/Resources/CityResource.php +++ b/Modules/Location/Filament/Admin/Resources/CityResource.php @@ -1,6 +1,6 @@ schema([ TextInput::make('name')->required()->maxLength(100), - TextInput::make('code')->required()->maxLength(2)->unique(ignoreRecord: true), + TextInput::make('code')->required()->maxLength(3)->unique(ignoreRecord: true), TextInput::make('phone_code')->maxLength(10), Toggle::make('is_active')->default(true), ]); @@ -70,10 +70,10 @@ class LocationResource extends Resource public static function getPages(): array { return [ - 'index' => Pages\ListLocations::route('/'), - 'create' => Pages\CreateLocation::route('/create'), - 'activities' => Pages\ListLocationActivities::route('/{record}/activities'), - 'edit' => Pages\EditLocation::route('/{record}/edit'), + 'index' => Pages\ListCountries::route('/'), + 'create' => Pages\CreateCountry::route('/create'), + 'activities' => Pages\ListCountryActivities::route('/{record}/activities'), + 'edit' => Pages\EditCountry::route('/{record}/edit'), ]; } } diff --git a/Modules/Location/Filament/Admin/Resources/CountryResource/Pages/CreateCountry.php b/Modules/Location/Filament/Admin/Resources/CountryResource/Pages/CreateCountry.php new file mode 100644 index 000000000..fe56f4665 --- /dev/null +++ b/Modules/Location/Filament/Admin/Resources/CountryResource/Pages/CreateCountry.php @@ -0,0 +1,11 @@ +discoverResources( + in: module_path('Location', 'Filament/Admin/Resources'), + for: 'Modules\\Location\\Filament\\Admin\\Resources', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Location/Models/Country.php b/Modules/Location/Models/Country.php index e25380d56..d93ba22d7 100644 --- a/Modules/Location/Models/Country.php +++ b/Modules/Location/Models/Country.php @@ -131,4 +131,63 @@ class Country extends Model ]) ->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, + ]; + } } diff --git a/Modules/Location/Providers/LocationServiceProvider.php b/Modules/Location/Providers/LocationServiceProvider.php index ccba4507c..3f2383f99 100644 --- a/Modules/Location/Providers/LocationServiceProvider.php +++ b/Modules/Location/Providers/LocationServiceProvider.php @@ -14,5 +14,6 @@ class LocationServiceProvider extends ServiceProvider $this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php')); } - public function register(): void {} + public function register(): void + {} } diff --git a/Modules/Panel/resources/views/partials/quick-create/form.blade.php b/Modules/Panel/resources/views/partials/quick-create/form.blade.php index a78492447..d1fe0af72 100644 --- a/Modules/Panel/resources/views/partials/quick-create/form.blade.php +++ b/Modules/Panel/resources/views/partials/quick-create/form.blade.php @@ -7,831 +7,6 @@ @endphp