From f06943ce9d15dcb2a117bcb8a391fa1547d9ae48 Mon Sep 17 00:00:00 2001 From: fatihalp Date: Mon, 23 Mar 2026 01:39:30 +0300 Subject: [PATCH] feat: add User management resource with CRUD operations and activity logging - Created UserResource for managing users with form and table configurations. - Implemented pages for creating, editing, listing users, and viewing user activities. - Added UserPlugin for resource registration in Filament admin panel. - Introduced CSS styles for panel quick creation and listing filters. - Developed JavaScript modules for handling listing filters and home slider functionality. --- ...03_03_093635_create_activity_log_table.php | 0 .../CategoryResource/Pages/EditCategory.php | 12 - .../Resources/CityResource/Pages/EditCity.php | 12 - .../CityResource/Pages/ListCities.php | 12 - .../DistrictResource/Pages/EditDistrict.php | 12 - .../DistrictResource/Pages/ListDistricts.php | 12 - .../ListingResource/Pages/EditListing.php | 12 - .../ListingResource/Pages/ListListings.php | 12 - .../LocationResource/Pages/CreateLocation.php | 10 - .../LocationResource/Pages/EditLocation.php | 12 - .../Pages/ListLocationActivities.php | 10 - .../LocationResource/Pages/ListLocations.php | 12 - .../UserResource/Pages/ListUsers.php | 12 - .../Admin/Providers/AdminPanelProvider.php | 18 +- .../Admin/Providers/AdminServiceProvider.php | 4 +- Modules/Category/CategoryPlugin.php | 29 + .../Admin}/Resources/CategoryResource.php | 4 +- .../CategoryResource/Pages/CreateCategory.php | 5 +- .../CategoryResource/Pages/EditCategory.php | 17 + .../CategoryResource/Pages/ListCategories.php | 5 +- .../Pages/ListCategoryActivities.php | 5 +- .../Providers/CategoryServiceProvider.php | 3 +- .../Controllers/ConversationController.php | 70 +- .../Conversation/App/Models/Conversation.php | 36 + .../Seeders/ConversationDemoSeeder.php | 10 - ...3_04_000000_create_conversation_tables.php | 46 +- .../Http/Controllers/FavoriteController.php | 96 +- .../Favorite/App/Models/FavoriteSearch.php | 32 + .../Database/Seeders/FavoriteDemoSeeder.php | 50 +- ...6_03_04_000000_create_favorites_tables.php | 56 +- .../Database/Seeders/ListingSeeder.php | 9 - .../2026_03_03_085614_create_media_table.php | 5 + .../Resources/ListingCustomFieldResource.php | 20 +- .../Pages/CreateListingCustomField.php | 4 +- .../Pages/EditListingCustomField.php | 4 +- .../Pages/ListListingCustomFields.php | 4 +- .../Admin}/Resources/ListingResource.php | 4 +- .../ListingResource/Pages/CreateListing.php | 5 +- .../ListingResource/Pages/EditListing.php | 17 + .../Pages/ListListingActivities.php | 5 +- .../ListingResource/Pages/ListListings.php | 17 + .../Admin}/Widgets/ListingOverview.php | 23 +- .../Admin}/Widgets/ListingsTrendChart.php | 27 +- .../Http/Controllers/ListingController.php | 156 +--- Modules/Listing/ListingPlugin.php | 34 + Modules/Listing/Models/Listing.php | 58 ++ Modules/Listing/Models/ListingCustomField.php | 19 + .../Providers/ListingServiceProvider.php | 3 +- .../views/partials/index-content.blade.php | 193 ---- .../Admin}/Resources/CityResource.php | 4 +- .../CityResource/Pages/CreateCity.php | 5 +- .../Resources/CityResource/Pages/EditCity.php | 17 + .../CityResource/Pages/ListCities.php | 17 + .../CityResource/Pages/ListCityActivities.php | 5 +- .../Admin/Resources/CountryResource.php} | 16 +- .../CountryResource/Pages/CreateCountry.php | 11 + .../CountryResource/Pages/EditCountry.php | 17 + .../CountryResource/Pages/ListCountries.php | 17 + .../Pages/ListCountryActivities.php | 11 + .../Admin}/Resources/DistrictResource.php | 4 +- .../DistrictResource/Pages/CreateDistrict.php | 5 +- .../DistrictResource/Pages/EditDistrict.php | 17 + .../Pages/ListDistrictActivities.php | 5 +- .../DistrictResource/Pages/ListDistricts.php | 17 + Modules/Location/LocationPlugin.php | 29 + Modules/Location/Models/Country.php | 59 ++ .../Providers/LocationServiceProvider.php | 3 +- .../partials/quick-create/form.blade.php | 825 ------------------ .../App/Http/Controllers/HomeController.php | 2 +- .../App/Providers/SiteServiceProvider.php | 1 + Modules/Site/App/Support/RequestAppData.php | 23 +- ...022_12_14_083707_create_settings_table.php | 7 +- .../Admin}/Pages/ManageGeneralSettings.php | 10 +- Modules/Site/SitePlugin.php | 29 + .../Support/Filament}/HomeSlideFormSchema.php | 2 +- Modules/Site/resources/views/home.blade.php | 212 ----- .../Controllers/Auth/SocialAuthController.php | 45 +- Modules/User/App/Models/SocialiteUser.php | 58 ++ Modules/User/App/Models/User.php | 45 +- .../App/Providers/UserServiceProvider.php | 3 +- .../User/Database/Seeders/AuthUserSeeder.php | 5 - ...01_01_000000_create_user_domain_tables.php | 80 +- ..._03_03_080610_create_permission_tables.php | 14 +- ...03_092653_create_breezy_sessions_table.php | 0 ...53_create_personal_access_tokens_table.php | 0 ...026_03_03_092655_create_passkeys_table.php | 3 +- ...03_094518_create_socialite_users_table.php | 11 +- .../Admin}/Resources/UserResource.php | 4 +- .../UserResource/Pages/CreateUser.php | 5 +- .../Resources/UserResource/Pages/EditUser.php | 5 +- .../UserResource/Pages/ListUserActivities.php | 5 +- .../UserResource/Pages/ListUsers.php | 17 + Modules/User/UserPlugin.php | 29 + .../Database/Seeders/VideoDemoSeeder.php | 5 - Modules/Video/VideoPlugin.php | 35 + resources/css/modules/panel-quick-create.css | 822 +++++++++++++++++ resources/js/app.js | 3 + resources/js/bootstrap.js | 6 - resources/js/modules/listing-filters.js | 203 +++++ resources/js/modules/site-home.js | 205 +++++ 100 files changed, 2192 insertions(+), 2019 deletions(-) rename {database => Modules/Admin/Database}/migrations/2026_03_03_093635_create_activity_log_table.php (100%) delete mode 100644 Modules/Admin/Filament/Resources/CategoryResource/Pages/EditCategory.php delete mode 100644 Modules/Admin/Filament/Resources/CityResource/Pages/EditCity.php delete mode 100644 Modules/Admin/Filament/Resources/CityResource/Pages/ListCities.php delete mode 100644 Modules/Admin/Filament/Resources/DistrictResource/Pages/EditDistrict.php delete mode 100644 Modules/Admin/Filament/Resources/DistrictResource/Pages/ListDistricts.php delete mode 100644 Modules/Admin/Filament/Resources/ListingResource/Pages/EditListing.php delete mode 100644 Modules/Admin/Filament/Resources/ListingResource/Pages/ListListings.php delete mode 100644 Modules/Admin/Filament/Resources/LocationResource/Pages/CreateLocation.php delete mode 100644 Modules/Admin/Filament/Resources/LocationResource/Pages/EditLocation.php delete mode 100644 Modules/Admin/Filament/Resources/LocationResource/Pages/ListLocationActivities.php delete mode 100644 Modules/Admin/Filament/Resources/LocationResource/Pages/ListLocations.php delete mode 100644 Modules/Admin/Filament/Resources/UserResource/Pages/ListUsers.php create mode 100644 Modules/Category/CategoryPlugin.php rename Modules/{Admin/Filament => Category/Filament/Admin}/Resources/CategoryResource.php (96%) rename Modules/{Admin/Filament => Category/Filament/Admin}/Resources/CategoryResource/Pages/CreateCategory.php (53%) create mode 100644 Modules/Category/Filament/Admin/Resources/CategoryResource/Pages/EditCategory.php rename Modules/{Admin/Filament => Category/Filament/Admin}/Resources/CategoryResource/Pages/ListCategories.php (90%) rename Modules/{Admin/Filament => Category/Filament/Admin}/Resources/CategoryResource/Pages/ListCategoryActivities.php (56%) rename {database => Modules/Listing/Database}/migrations/2026_03_03_085614_create_media_table.php (92%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingCustomFieldResource.php (83%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php (53%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingCustomFieldResource/Pages/EditListingCustomField.php (66%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingCustomFieldResource/Pages/ListListingCustomFields.php (66%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingResource.php (90%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingResource/Pages/CreateListing.php (53%) create mode 100644 Modules/Listing/Filament/Admin/Resources/ListingResource/Pages/EditListing.php rename Modules/{Admin/Filament => Listing/Filament/Admin}/Resources/ListingResource/Pages/ListListingActivities.php (56%) create mode 100644 Modules/Listing/Filament/Admin/Resources/ListingResource/Pages/ListListings.php rename Modules/{Admin/Filament => Listing/Filament/Admin}/Widgets/ListingOverview.php (50%) rename Modules/{Admin/Filament => Listing/Filament/Admin}/Widgets/ListingsTrendChart.php (58%) create mode 100644 Modules/Listing/ListingPlugin.php rename Modules/{Admin/Filament => Location/Filament/Admin}/Resources/CityResource.php (96%) rename Modules/{Admin/Filament => Location/Filament/Admin}/Resources/CityResource/Pages/CreateCity.php (53%) create mode 100644 Modules/Location/Filament/Admin/Resources/CityResource/Pages/EditCity.php create mode 100644 Modules/Location/Filament/Admin/Resources/CityResource/Pages/ListCities.php rename Modules/{Admin/Filament => Location/Filament/Admin}/Resources/CityResource/Pages/ListCityActivities.php (56%) rename Modules/{Admin/Filament/Resources/LocationResource.php => Location/Filament/Admin/Resources/CountryResource.php} (83%) create mode 100644 Modules/Location/Filament/Admin/Resources/CountryResource/Pages/CreateCountry.php create mode 100644 Modules/Location/Filament/Admin/Resources/CountryResource/Pages/EditCountry.php create mode 100644 Modules/Location/Filament/Admin/Resources/CountryResource/Pages/ListCountries.php create mode 100644 Modules/Location/Filament/Admin/Resources/CountryResource/Pages/ListCountryActivities.php rename Modules/{Admin/Filament => Location/Filament/Admin}/Resources/DistrictResource.php (96%) rename Modules/{Admin/Filament => Location/Filament/Admin}/Resources/DistrictResource/Pages/CreateDistrict.php (53%) create mode 100644 Modules/Location/Filament/Admin/Resources/DistrictResource/Pages/EditDistrict.php rename Modules/{Admin/Filament => Location/Filament/Admin}/Resources/DistrictResource/Pages/ListDistrictActivities.php (56%) create mode 100644 Modules/Location/Filament/Admin/Resources/DistrictResource/Pages/ListDistricts.php create mode 100644 Modules/Location/LocationPlugin.php rename {database => Modules/Site/Database}/migrations/2022_12_14_083707_create_settings_table.php (82%) rename Modules/{Admin/Filament => Site/Filament/Admin}/Pages/ManageGeneralSettings.php (97%) create mode 100644 Modules/Site/SitePlugin.php rename Modules/{Admin/Support => Site/Support/Filament}/HomeSlideFormSchema.php (98%) create mode 100644 Modules/User/App/Models/SocialiteUser.php rename {database => Modules/User/Database}/migrations/2026_03_03_080610_create_permission_tables.php (93%) rename {database => Modules/User/Database}/migrations/2026_03_03_092653_create_breezy_sessions_table.php (100%) rename {database => Modules/User/Database}/migrations/2026_03_03_092653_create_personal_access_tokens_table.php (100%) rename {database => Modules/User/Database}/migrations/2026_03_03_092655_create_passkeys_table.php (94%) rename {database => Modules/User/Database}/migrations/2026_03_03_094518_create_socialite_users_table.php (86%) rename Modules/{Admin/Filament => User/Filament/Admin}/Resources/UserResource.php (95%) rename Modules/{Admin/Filament => User/Filament/Admin}/Resources/UserResource/Pages/CreateUser.php (55%) rename Modules/{Admin/Filament => User/Filament/Admin}/Resources/UserResource/Pages/EditUser.php (76%) rename Modules/{Admin/Filament => User/Filament/Admin}/Resources/UserResource/Pages/ListUserActivities.php (58%) create mode 100644 Modules/User/Filament/Admin/Resources/UserResource/Pages/ListUsers.php create mode 100644 Modules/User/UserPlugin.php create mode 100644 Modules/Video/VideoPlugin.php create mode 100644 resources/css/modules/panel-quick-create.css create mode 100644 resources/js/modules/listing-filters.js create mode 100644 resources/js/modules/site-home.js 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
- -
Step {{ $currentStep }} of 5 diff --git a/Modules/Site/App/Http/Controllers/HomeController.php b/Modules/Site/App/Http/Controllers/HomeController.php index 55ea66622..a48084bdb 100644 --- a/Modules/Site/App/Http/Controllers/HomeController.php +++ b/Modules/Site/App/Http/Controllers/HomeController.php @@ -18,7 +18,7 @@ class HomeController extends Controller $categoryCount = Category::activeCount(); $userCount = User::totalCount(); $favoriteListingIds = auth()->check() - ? auth()->user()->homeFavoriteListingIds() + ? auth()->user()->favoriteListingIds() : []; return view('site::home', compact( diff --git a/Modules/Site/App/Providers/SiteServiceProvider.php b/Modules/Site/App/Providers/SiteServiceProvider.php index 0d75cd07a..10215b3e9 100644 --- a/Modules/Site/App/Providers/SiteServiceProvider.php +++ b/Modules/Site/App/Providers/SiteServiceProvider.php @@ -26,6 +26,7 @@ class SiteServiceProvider extends ServiceProvider { $viewPath = module_path('Site', 'resources/views'); + $this->loadMigrationsFrom(module_path('Site', 'Database/migrations')); $this->loadRoutesFrom(module_path('Site', 'routes/web.php')); $this->loadViewsFrom($viewPath, 'site'); View::addNamespace('app', $viewPath); diff --git a/Modules/Site/App/Support/RequestAppData.php b/Modules/Site/App/Support/RequestAppData.php index 48fea4597..37f6e9379 100644 --- a/Modules/Site/App/Support/RequestAppData.php +++ b/Modules/Site/App/Support/RequestAppData.php @@ -3,7 +3,6 @@ namespace Modules\Site\App\Support; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; use Modules\Category\Models\Category; use Modules\Location\Models\Country; @@ -68,14 +67,6 @@ final class RequestAppData 'apple_client_secret' => $fallbackAppleClientSecret, ]; - try { - if (! Schema::hasTable('settings')) { - return $generalSettings; - } - } catch (Throwable) { - return $generalSettings; - } - try { $settings = app(GeneralSettings::class); $currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies); @@ -141,15 +132,15 @@ final class RequestAppData 'filament-google-maps.keys.server_key' => $mapsKey, 'services.google.client_id' => $generalSettings['google_client_id'], 'services.google.client_secret' => $generalSettings['google_client_secret'], - 'services.google.redirect' => url('/oauth/callback/google'), + 'services.google.redirect' => route('auth.social.callback', ['provider' => 'google'], absolute: true), 'services.google.enabled' => (bool) $generalSettings['google_login_enabled'], 'services.facebook.client_id' => $generalSettings['facebook_client_id'], 'services.facebook.client_secret' => $generalSettings['facebook_client_secret'], - 'services.facebook.redirect' => url('/oauth/callback/facebook'), + 'services.facebook.redirect' => route('auth.social.callback', ['provider' => 'facebook'], absolute: true), 'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'], 'services.apple.client_id' => $generalSettings['apple_client_id'], 'services.apple.client_secret' => $generalSettings['apple_client_secret'], - 'services.apple.redirect' => url('/oauth/callback/apple'), + 'services.apple.redirect' => route('auth.social.callback', ['provider' => 'apple'], absolute: true), 'services.apple.stateless' => true, 'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'], 'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD', @@ -161,10 +152,6 @@ final class RequestAppData private function resolveHeaderLocationCountries(): array { try { - if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) { - return []; - } - return Country::headerLocationOptions(); } catch (Throwable) { return []; @@ -174,10 +161,6 @@ final class RequestAppData private function resolveHeaderNavCategories(): array { try { - if (! Schema::hasTable('categories')) { - return []; - } - return Category::headerNavigationItems(); } catch (Throwable) { return []; diff --git a/database/migrations/2022_12_14_083707_create_settings_table.php b/Modules/Site/Database/migrations/2022_12_14_083707_create_settings_table.php similarity index 82% rename from database/migrations/2022_12_14_083707_create_settings_table.php rename to Modules/Site/Database/migrations/2022_12_14_083707_create_settings_table.php index 9b14b8613..2154f72c6 100644 --- a/database/migrations/2022_12_14_083707_create_settings_table.php +++ b/Modules/Site/Database/migrations/2022_12_14_083707_create_settings_table.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - public function up() + public function up(): void { Schema::create('settings', function (Blueprint $table): void { $table->id(); @@ -21,4 +21,9 @@ return new class extends Migration $table->unique(['group', 'name']); }); } + + public function down(): void + { + Schema::dropIfExists('settings'); + } }; diff --git a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php b/Modules/Site/Filament/Admin/Pages/ManageGeneralSettings.php similarity index 97% rename from Modules/Admin/Filament/Pages/ManageGeneralSettings.php rename to Modules/Site/Filament/Admin/Pages/ManageGeneralSettings.php index f830517a9..eed0f556b 100644 --- a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php +++ b/Modules/Site/Filament/Admin/Pages/ManageGeneralSettings.php @@ -1,6 +1,6 @@ discoverPages( + in: module_path('Site', 'Filament/Admin/Pages'), + for: 'Modules\\Site\\Filament\\Admin\\Pages', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Admin/Support/HomeSlideFormSchema.php b/Modules/Site/Support/Filament/HomeSlideFormSchema.php similarity index 98% rename from Modules/Admin/Support/HomeSlideFormSchema.php rename to Modules/Site/Support/Filament/HomeSlideFormSchema.php index 7c23750bb..9a7a2c53c 100644 --- a/Modules/Admin/Support/HomeSlideFormSchema.php +++ b/Modules/Site/Support/Filament/HomeSlideFormSchema.php @@ -1,6 +1,6 @@
@endif - @if($prepareDemoTurnstileRenderable) @endif diff --git a/Modules/User/App/Http/Controllers/Auth/SocialAuthController.php b/Modules/User/App/Http/Controllers/Auth/SocialAuthController.php index 34c1449c6..750216311 100644 --- a/Modules/User/App/Http/Controllers/Auth/SocialAuthController.php +++ b/Modules/User/App/Http/Controllers/Auth/SocialAuthController.php @@ -6,10 +6,8 @@ use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Str; use Laravel\Socialite\Facades\Socialite; +use Modules\User\App\Models\SocialiteUser; use Modules\User\App\Models\User; use Modules\User\App\Support\AuthProviderCatalog; use Modules\User\App\Support\AuthRedirector; @@ -60,46 +58,7 @@ class SocialAuthController extends Controller private function resolveUser(string $provider, mixed $oauthUser): User { - $socialiteUser = DB::table('socialite_users') - ->where('provider', $provider) - ->where('provider_id', (string) $oauthUser->getId()) - ->first(); - - $user = null; - - if ($socialiteUser?->user_id) { - $user = User::query()->find($socialiteUser->user_id); - } - - if (! $user) { - $email = filled($oauthUser->getEmail()) - ? strtolower(trim((string) $oauthUser->getEmail())) - : sprintf('%s_%s@social.local', $provider, $oauthUser->getId()); - - $user = User::query()->firstOrCreate( - ['email' => $email], - [ - 'name' => trim((string) ($oauthUser->getName() ?: $oauthUser->getNickname() ?: ucfirst($provider).' User')), - 'password' => Hash::make(Str::random(40)), - 'status' => 'active', - 'email_verified_at' => now(), - ], - ); - } - - DB::table('socialite_users')->updateOrInsert( - [ - 'provider' => $provider, - 'provider_id' => (string) $oauthUser->getId(), - ], - [ - 'user_id' => $user->getKey(), - 'updated_at' => now(), - 'created_at' => $socialiteUser?->created_at ?? now(), - ], - ); - - return $user; + return SocialiteUser::resolveUser($provider, $oauthUser); } private function driver(string $provider): mixed diff --git a/Modules/User/App/Models/SocialiteUser.php b/Modules/User/App/Models/SocialiteUser.php new file mode 100644 index 000000000..24d3fc75b --- /dev/null +++ b/Modules/User/App/Models/SocialiteUser.php @@ -0,0 +1,58 @@ +belongsTo(User::class); + } + + public static function resolveUser(string $provider, mixed $oauthUser): User + { + $socialiteUser = static::query() + ->with('user') + ->where('provider', $provider) + ->where('provider_id', (string) $oauthUser->getId()) + ->first(); + $user = $socialiteUser?->user; + + if (! $user) { + $email = filled($oauthUser->getEmail()) + ? strtolower(trim((string) $oauthUser->getEmail())) + : sprintf('%s_%s@social.local', $provider, $oauthUser->getId()); + + $user = User::query()->firstOrCreate( + ['email' => $email], + [ + 'name' => trim((string) ($oauthUser->getName() ?: $oauthUser->getNickname() ?: ucfirst($provider).' User')), + 'password' => Hash::make(Str::random(40)), + 'status' => 'active', + 'email_verified_at' => now(), + ], + ); + } + + static::query()->updateOrCreate( + [ + 'provider' => $provider, + 'provider_id' => (string) $oauthUser->getId(), + ], + [ + 'user_id' => $user->getKey(), + ], + ); + + return $user; + } +} diff --git a/Modules/User/App/Models/User.php b/Modules/User/App/Models/User.php index 2e0e08cfb..897009a8a 100644 --- a/Modules/User/App/Models/User.php +++ b/Modules/User/App/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Storage; use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable; @@ -186,6 +187,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar return true; } + public function rememberListing(Listing $listing): void + { + $this->favoriteListings()->syncWithoutDetaching([$listing->getKey()]); + } + public function unreadInboxCount(): int { return Conversation::unreadCountForUser((int) $this->getKey()); @@ -223,7 +229,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar return (int) static::query()->count(); } - public function homeFavoriteListingIds(): array + public function favoriteListingIds(): array { return $this->favoriteListings() ->pluck('listings.id') @@ -231,6 +237,43 @@ class User extends Authenticatable implements FilamentUser, HasAvatar ->all(); } + public function homeFavoriteListingIds(): array + { + return $this->favoriteListingIds(); + } + + public function favoriteListingsPage(string $statusFilter = 'all', ?int $categoryId = null, int $perPage = 10): LengthAwarePaginator + { + return $this->favoriteListings() + ->with(['category:id,name', 'user:id,name']) + ->wherePivot('created_at', '>=', now()->subYear()) + ->when($statusFilter === 'active', fn ($query) => $query->where('status', 'active')) + ->when($categoryId, fn ($query) => $query->where('category_id', $categoryId)) + ->orderByPivot('created_at', 'desc') + ->paginate($perPage) + ->withQueryString(); + } + + public function favoriteSearchesPage(int $perPage = 10): LengthAwarePaginator + { + return $this->favoriteSearches() + ->with('category:id,name') + ->latest() + ->paginate($perPage) + ->withQueryString(); + } + + public function favoriteSellersPage(int $perPage = 10): LengthAwarePaginator + { + return $this->favoriteSellers() + ->withCount([ + 'listings as active_listings_count' => fn ($query) => $query->where('status', 'active'), + ]) + ->orderByPivot('created_at', 'desc') + ->paginate($perPage) + ->withQueryString(); + } + public function panelListingOptions(): Collection { return $this->listings() diff --git a/Modules/User/App/Providers/UserServiceProvider.php b/Modules/User/App/Providers/UserServiceProvider.php index be05f8d1c..64e37083e 100644 --- a/Modules/User/App/Providers/UserServiceProvider.php +++ b/Modules/User/App/Providers/UserServiceProvider.php @@ -13,5 +13,6 @@ class UserServiceProvider extends ServiceProvider $this->loadViewsFrom(module_path('User', 'resources/views'), 'user'); } - public function register(): void {} + public function register(): void + {} } diff --git a/Modules/User/Database/Seeders/AuthUserSeeder.php b/Modules/User/Database/Seeders/AuthUserSeeder.php index 686b23875..6877a6c78 100644 --- a/Modules/User/Database/Seeders/AuthUserSeeder.php +++ b/Modules/User/Database/Seeders/AuthUserSeeder.php @@ -3,7 +3,6 @@ namespace Modules\User\Database\Seeders; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Schema; use Modules\User\App\Models\User; use Modules\User\App\Support\DemoUserCatalog; use Spatie\Permission\Models\Role; @@ -22,10 +21,6 @@ class AuthUserSeeder extends Seeder ], )); - if (! class_exists(Role::class) || ! Schema::hasTable((new Role)->getTable())) { - return; - } - $adminRole = Role::query()->firstOrCreate([ 'name' => 'admin', 'guard_name' => 'web', diff --git a/Modules/User/Database/migrations/0001_01_01_000000_create_user_domain_tables.php b/Modules/User/Database/migrations/0001_01_01_000000_create_user_domain_tables.php index 37ad3c0f8..d6f1c6166 100644 --- a/Modules/User/Database/migrations/0001_01_01_000000_create_user_domain_tables.php +++ b/Modules/User/Database/migrations/0001_01_01_000000_create_user_domain_tables.php @@ -8,53 +8,45 @@ return new class extends Migration { public function up(): void { - if (! Schema::hasTable('users')) { - Schema::create('users', function (Blueprint $table): void { - $table->id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('status')->default('active'); - $table->string('password'); - $table->string('avatar_url')->nullable(); - $table->rememberToken(); - $table->timestamps(); - }); - } + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('status')->default('active'); + $table->string('password'); + $table->string('avatar_url')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); - if (! Schema::hasTable('profiles')) { - Schema::create('profiles', function (Blueprint $table): void { - $table->id(); - $table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete(); - $table->string('avatar')->nullable(); - $table->text('bio')->nullable(); - $table->string('phone')->nullable(); - $table->string('city')->nullable(); - $table->string('country')->nullable(); - $table->string('website')->nullable(); - $table->boolean('is_verified')->default(false); - $table->timestamps(); - }); - } + Schema::create('profiles', function (Blueprint $table): void { + $table->id(); + $table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete(); + $table->string('avatar')->nullable(); + $table->text('bio')->nullable(); + $table->string('phone')->nullable(); + $table->string('city')->nullable(); + $table->string('country')->nullable(); + $table->string('website')->nullable(); + $table->boolean('is_verified')->default(false); + $table->timestamps(); + }); - if (! Schema::hasTable('password_reset_tokens')) { - Schema::create('password_reset_tokens', function (Blueprint $table): void { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } + Schema::create('password_reset_tokens', function (Blueprint $table): void { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); - if (! Schema::hasTable('sessions')) { - Schema::create('sessions', function (Blueprint $table): void { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); - } + Schema::create('sessions', function (Blueprint $table): void { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); } public function down(): void diff --git a/database/migrations/2026_03_03_080610_create_permission_tables.php b/Modules/User/Database/migrations/2026_03_03_080610_create_permission_tables.php similarity index 93% rename from database/migrations/2026_03_03_080610_create_permission_tables.php rename to Modules/User/Database/migrations/2026_03_03_080610_create_permission_tables.php index d952d6c8a..eb017d2de 100644 --- a/database/migrations/2026_03_03_080610_create_permission_tables.php +++ b/Modules/User/Database/migrations/2026_03_03_080610_create_permission_tables.php @@ -17,7 +17,7 @@ return new class extends Migration throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); Schema::create($tableNames['permissions'], static function (Blueprint $table) { - $table->id(); // permission id + $table->id(); $table->string('name'); $table->string('guard_name'); $table->timestamps(); @@ -25,8 +25,8 @@ return new class extends Migration $table->unique(['name', 'guard_name']); }); Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { - $table->id(); // role id - if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->id(); + if ($teams || config('permission.testing')) { $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); } @@ -48,7 +48,7 @@ return new class extends Migration $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); $table->foreign($pivotPermission) - ->references('id') // permission id + ->references('id') ->on($tableNames['permissions']) ->cascadeOnDelete(); if ($teams) { @@ -71,7 +71,7 @@ return new class extends Migration $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); $table->foreign($pivotRole) - ->references('id') // role id + ->references('id') ->on($tableNames['roles']) ->cascadeOnDelete(); if ($teams) { @@ -91,12 +91,12 @@ return new class extends Migration $table->unsignedBigInteger($pivotRole); $table->foreign($pivotPermission) - ->references('id') // permission id + ->references('id') ->on($tableNames['permissions']) ->cascadeOnDelete(); $table->foreign($pivotRole) - ->references('id') // role id + ->references('id') ->on($tableNames['roles']) ->cascadeOnDelete(); diff --git a/database/migrations/2026_03_03_092653_create_breezy_sessions_table.php b/Modules/User/Database/migrations/2026_03_03_092653_create_breezy_sessions_table.php similarity index 100% rename from database/migrations/2026_03_03_092653_create_breezy_sessions_table.php rename to Modules/User/Database/migrations/2026_03_03_092653_create_breezy_sessions_table.php diff --git a/database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php b/Modules/User/Database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php similarity index 100% rename from database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php rename to Modules/User/Database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php diff --git a/database/migrations/2026_03_03_092655_create_passkeys_table.php b/Modules/User/Database/migrations/2026_03_03_092655_create_passkeys_table.php similarity index 94% rename from database/migrations/2026_03_03_092655_create_passkeys_table.php rename to Modules/User/Database/migrations/2026_03_03_092655_create_passkeys_table.php index 8dc4560ea..097322f97 100644 --- a/database/migrations/2026_03_03_092655_create_passkeys_table.php +++ b/Modules/User/Database/migrations/2026_03_03_092655_create_passkeys_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('passkeys', function (Blueprint $table) { diff --git a/database/migrations/2026_03_03_094518_create_socialite_users_table.php b/Modules/User/Database/migrations/2026_03_03_094518_create_socialite_users_table.php similarity index 86% rename from database/migrations/2026_03_03_094518_create_socialite_users_table.php rename to Modules/User/Database/migrations/2026_03_03_094518_create_socialite_users_table.php index da3e4125a..c90f6323b 100644 --- a/database/migrations/2026_03_03_094518_create_socialite_users_table.php +++ b/Modules/User/Database/migrations/2026_03_03_094518_create_socialite_users_table.php @@ -1,11 +1,12 @@ id(); @@ -23,7 +24,7 @@ return new class extends Migration { }); } - public function down() + public function down(): void { Schema::dropIfExists('socialite_users'); } diff --git a/Modules/Admin/Filament/Resources/UserResource.php b/Modules/User/Filament/Admin/Resources/UserResource.php similarity index 95% rename from Modules/Admin/Filament/Resources/UserResource.php rename to Modules/User/Filament/Admin/Resources/UserResource.php index bc84e5d5f..9445e2e5e 100644 --- a/Modules/Admin/Filament/Resources/UserResource.php +++ b/Modules/User/Filament/Admin/Resources/UserResource.php @@ -1,6 +1,6 @@ discoverResources( + in: module_path('User', 'Filament/Admin/Resources'), + for: 'Modules\\User\\Filament\\Admin\\Resources', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Video/Database/Seeders/VideoDemoSeeder.php b/Modules/Video/Database/Seeders/VideoDemoSeeder.php index b52bdd45f..fbeed5db0 100644 --- a/Modules/Video/Database/Seeders/VideoDemoSeeder.php +++ b/Modules/Video/Database/Seeders/VideoDemoSeeder.php @@ -3,7 +3,6 @@ namespace Modules\Video\Database\Seeders; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Schema; use Modules\Listing\Models\Listing; use Modules\User\App\Models\User; use Modules\User\App\Support\DemoUserCatalog; @@ -14,10 +13,6 @@ class VideoDemoSeeder extends Seeder { public function run(): void { - if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) { - return; - } - $users = User::query() ->whereIn('email', DemoUserCatalog::emails()) ->orderBy('email') diff --git a/Modules/Video/VideoPlugin.php b/Modules/Video/VideoPlugin.php new file mode 100644 index 000000000..785a6a2cf --- /dev/null +++ b/Modules/Video/VideoPlugin.php @@ -0,0 +1,35 @@ +discoverResources( + in: module_path('Video', 'Filament/Admin/Resources'), + for: 'Modules\\Video\\Filament\\Admin\\Resources', + ) + ->renderHook( + PanelsRenderHook::BODY_END, + fn (): \Illuminate\Contracts\View\View => view('video::partials.video-upload-optimizer'), + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/resources/css/modules/panel-quick-create.css b/resources/css/modules/panel-quick-create.css new file mode 100644 index 000000000..6b162bd33 --- /dev/null +++ b/resources/css/modules/panel-quick-create.css @@ -0,0 +1,822 @@ +.qc-shell { + --qc-surface: rgba(255, 255, 255, 0.9); + --qc-surface-soft: #f5f5f7; + --qc-surface-subtle: #fbfbfd; + --qc-border: rgba(15, 23, 42, 0.08); + --qc-border-strong: rgba(15, 23, 42, 0.12); + --qc-text: #1d1d1f; + --qc-muted: #6e6e73; + --qc-primary: #0071e3; + --qc-primary-strong: #0066cc; + --qc-primary-soft: #e8f3ff; + --qc-danger: #dc2626; + color: var(--qc-text); + font-family: "SF Pro Text", "SF Pro Display", "Helvetica Neue", Arial, sans-serif; +} + +.qc-header { + display: grid; + gap: 0.75rem; + justify-items: center; + text-align: center; + margin-bottom: 1.9rem; +} + +.qc-step-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0 0.9rem; + border-radius: 999px; + border: 1px solid var(--qc-border); + background: rgba(255, 255, 255, 0.85); + color: var(--qc-muted); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.qc-title { + margin: 0; + font-size: clamp(2.2rem, 5vw, 4.5rem); + font-weight: 700; + line-height: 0.98; + letter-spacing: -0.06em; +} + +.qc-progress { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.45rem; + width: min(280px, 72vw); +} + +.qc-progress > span { + height: 0.28rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.1); +} + +.qc-progress > span.is-on { + background: linear-gradient(90deg, var(--qc-primary), #4aa8ff); +} + +.qc-card { + border: 1px solid var(--qc-border); + border-radius: 2.25rem; + background: var(--qc-surface); + box-shadow: 0 30px 80px rgba(15, 23, 42, 0.07); + overflow: hidden; + backdrop-filter: saturate(180%) blur(20px); +} + +.qc-body { + padding: 1.4rem; +} + +.qc-stack { + display: grid; + gap: 0.9rem; +} + +.qc-panel, +.qc-upload-zone, +.qc-summary-card, +.qc-notice, +.qc-empty, +.qc-photo-strip { + border: 1px solid var(--qc-border); + border-radius: 1.5rem; + background: var(--qc-surface-subtle); +} + +.qc-upload-zone { + display: grid; + place-items: center; + text-align: center; + gap: 0.8rem; + min-height: 360px; + padding: 2.5rem 1.5rem; + cursor: pointer; + border-style: dashed; + border-color: rgba(0, 113, 227, 0.16); + background: + radial-gradient(circle at top, rgba(0, 113, 227, 0.08), transparent 34%), + #fbfbfd; +} + +.qc-upload-zone:hover { + border-color: rgba(0, 113, 227, 0.26); + background: + radial-gradient(circle at top, rgba(0, 113, 227, 0.1), transparent 34%), + #ffffff; +} + +.qc-upload-icon { + width: 4.25rem; + height: 4.25rem; + border-radius: 1.35rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: #fff; + color: var(--qc-text); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.06); +} + +.qc-upload-title { + font-size: 2rem; + line-height: 1.04; + letter-spacing: -0.04em; + font-weight: 700; +} + +.qc-copy { + color: var(--qc-muted); + font-size: 0.94rem; + line-height: 1.55; + max-width: 28rem; + margin: 0; +} + +.qc-primary-pill, +.qc-secondary-pill, +.qc-button, +.qc-button-secondary, +.qc-chip, +.qc-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + font-weight: 700; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +} + +.qc-primary-pill, +.qc-button { + min-height: 3.25rem; + padding: 0 1.4rem; + border: 1px solid transparent; + background: linear-gradient(180deg, #2997ff, var(--qc-primary)); + color: #fff; + box-shadow: 0 14px 28px rgba(0, 113, 227, 0.18); +} + +.qc-primary-pill:hover, +.qc-button:hover { + transform: translateY(-1px); + background: linear-gradient(180deg, #1587ff, var(--qc-primary-strong)); +} + +.qc-secondary-pill, +.qc-button-secondary, +.qc-chip, +.qc-icon-button { + min-height: 3rem; + padding: 0 1rem; + border: 1px solid var(--qc-border); + background: #fff; + color: var(--qc-text); +} + +.qc-secondary-pill:hover, +.qc-button-secondary:hover, +.qc-chip:hover, +.qc-icon-button:hover { + transform: translateY(-1px); + border-color: var(--qc-border-strong); + background: #fff; +} + +.qc-panel { + padding: 1rem 1.05rem; +} + +.qc-panel-head, +.qc-panel-row, +.qc-summary-card, +.qc-review-meta, +.qc-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.qc-panel-head h2, +.qc-panel-row h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.qc-panel-head p, +.qc-panel-row p, +.qc-summary-copy, +.qc-meta-copy, +.qc-seller-copy { + margin: 0.2rem 0 0; + color: var(--qc-muted); + font-size: 0.9rem; + line-height: 1.6; +} + +.qc-count { + flex-shrink: 0; + color: var(--qc-muted); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.qc-photo-grid, +.qc-photo-strip { + display: grid; + gap: 0.8rem; +} + +.qc-photo-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 1rem; +} + +.qc-photo-strip { + grid-template-columns: repeat(4, minmax(0, 1fr)); + padding: 0.9rem; + background: #fff; +} + +.qc-photo-slot, +.qc-review-thumb, +.qc-gallery-main { + position: relative; + border-radius: 1.15rem; + overflow: hidden; + border: 1px solid var(--qc-border); + background: #eef2f7; + display: flex; + align-items: center; + justify-content: center; +} + +.qc-photo-slot { + aspect-ratio: 1; + min-height: 120px; +} + +.qc-photo-slot img, +.qc-review-thumb img, +.qc-gallery-main img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.qc-remove { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 1.9rem; + height: 1.9rem; + border-radius: 999px; + border: 0; + background: rgba(15, 23, 42, 0.88); + color: #fff; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; +} + +.qc-cover { + position: absolute; + left: 0.55rem; + bottom: 0.55rem; + min-height: 1.8rem; + padding: 0 0.7rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.96); + color: var(--qc-text); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.qc-empty { + padding: 1.15rem 1.2rem; + text-align: center; + color: var(--qc-muted); + font-size: 0.93rem; + line-height: 1.6; +} + +.qc-video-list { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.qc-video-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + padding: 0.95rem 1rem; + border: 1px solid var(--qc-border); + border-radius: 1.1rem; + background: #fff; +} + +.qc-video-meta { + min-width: 0; +} + +.qc-video-name { + color: var(--qc-text); + font-size: 0.93rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.qc-video-size { + margin-top: 0.2rem; + color: var(--qc-muted); + font-size: 0.84rem; +} + +.qc-notice { + padding: 0.9rem 1rem; + color: var(--qc-text); + font-size: 0.9rem; + line-height: 1.55; +} + +.qc-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.qc-category-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.9rem; +} + +.qc-category-card { + border: 1px solid var(--qc-border); + border-radius: 1.4rem; + background: #fff; + padding: 1.1rem 1rem; + text-align: center; + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.qc-category-card:hover { + transform: translateY(-1px); + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.06); +} + +.qc-category-card.is-selected { + border-color: rgba(0, 113, 227, 0.24); + background: var(--qc-primary-soft); +} + +.qc-category-icon { + width: 4rem; + height: 4rem; + margin: 0 auto 0.8rem; + border-radius: 1.2rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--qc-surface-soft); + color: var(--qc-text); +} + +.qc-category-name { + font-size: 0.95rem; + font-weight: 700; + line-height: 1.35; +} + +.qc-search-wrap { + display: grid; + gap: 0.8rem; +} + +.qc-input, +.qc-select, +.qc-textarea { + width: 100%; + min-height: 3.25rem; + padding: 0 1rem; + border: 1px solid var(--qc-border); + border-radius: 1rem; + background: #fff; + color: var(--qc-text); + font-size: 0.96rem; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.qc-textarea { + min-height: 10rem; + padding-top: 0.9rem; + padding-bottom: 0.9rem; + resize: vertical; +} + +.qc-input:focus, +.qc-select:focus, +.qc-textarea:focus { + outline: none; + border-color: rgba(0, 113, 227, 0.28); + box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.12); +} + +.qc-category-list { + display: grid; + gap: 0.6rem; +} + +.qc-category-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 0.6rem; + align-items: center; + padding: 0.7rem; + border: 1px solid var(--qc-border); + border-radius: 1rem; + background: #fff; +} + +.qc-category-main, +.qc-category-next, +.qc-back-link, +.qc-text-link { + border: 0; + background: transparent; + color: var(--qc-text); + cursor: pointer; +} + +.qc-category-main { + text-align: left; + font-size: 0.96rem; + font-weight: 600; +} + +.qc-category-main.is-selected { + color: var(--qc-primary); +} + +.qc-category-check { + color: var(--qc-primary); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.qc-back-link, +.qc-text-link { + color: var(--qc-primary); + font-size: 0.92rem; + font-weight: 700; +} + +.qc-summary-card { + padding: 0.95rem 1rem; + background: #fff; +} + +.qc-summary-label { + display: block; + color: var(--qc-muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.qc-summary-value { + display: block; + margin-top: 0.3rem; + color: var(--qc-text); + font-size: 1rem; + font-weight: 700; + line-height: 1.45; +} + +.qc-fields { + display: grid; + gap: 1rem; +} + +.qc-fields.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.qc-field { + display: grid; + gap: 0.45rem; +} + +.qc-field label { + color: var(--qc-text); + font-size: 0.9rem; + font-weight: 700; +} + +.qc-counter { + text-align: right; + color: var(--qc-muted); + font-size: 0.8rem; + font-weight: 600; +} + +.qc-input-row { + position: relative; +} + +.qc-input-suffix { + position: absolute; + top: 50%; + right: 1rem; + transform: translateY(-50%); + color: var(--qc-muted); + font-size: 0.92rem; + font-weight: 700; +} + +.qc-toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-height: 3.25rem; + padding: 0 1rem; + border: 1px solid var(--qc-border); + border-radius: 1rem; + background: #fff; + color: var(--qc-text); + font-size: 0.95rem; + font-weight: 600; +} + +.qc-toggle input { + accent-color: var(--qc-primary); +} + +.qc-error { + color: var(--qc-danger); + font-size: 0.84rem; + line-height: 1.5; + font-weight: 600; +} + +.qc-footer { + padding: 1rem 1.1rem; + border-top: 1px solid var(--qc-border); + background: rgba(255, 255, 255, 0.96); +} + +.qc-footer.is-single { + justify-content: flex-end; +} + +.qc-review-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 1rem; +} + +.qc-review-gallery { + display: grid; + gap: 0.8rem; +} + +.qc-gallery-main { + min-height: 420px; + background: #f0f4f8; +} + +.qc-review-thumbs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.7rem; +} + +.qc-review-thumb { + aspect-ratio: 1; + min-height: 86px; +} + +.qc-review-panel { + padding: 1.1rem; +} + +.qc-review-price { + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 700; + line-height: 1; + letter-spacing: -0.06em; +} + +.qc-review-location { + color: var(--qc-muted); + font-size: 0.9rem; + line-height: 1.6; + text-align: right; +} + +.qc-review-title { + margin: 1rem 0 0; + font-size: 1.35rem; + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.03em; +} + +.qc-review-description { + margin: 0.8rem 0 0; + color: var(--qc-text); + font-size: 0.96rem; + line-height: 1.7; +} + +.qc-feature-list { + display: grid; + gap: 0.8rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--qc-border); +} + +.qc-feature-row { + display: grid; + grid-template-columns: 150px 1fr; + gap: 0.9rem; + align-items: start; +} + +.qc-feature-label { + color: var(--qc-muted); + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.qc-feature-value { + color: var(--qc-text); + font-size: 0.95rem; + font-weight: 600; + line-height: 1.6; +} + +.qc-side-stack { + display: grid; + gap: 1rem; + align-self: start; +} + +.qc-seller-card { + padding: 1rem 1.1rem; +} + +.qc-seller-head { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.qc-avatar { + width: 3.3rem; + height: 3.3rem; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--qc-surface-soft); + color: var(--qc-text); + font-size: 1.1rem; + font-weight: 700; +} + +.qc-seller-name { + font-size: 1rem; + font-weight: 700; + line-height: 1.3; +} + +.qc-seller-email { + margin-top: 0.2rem; + color: var(--qc-muted); + font-size: 0.88rem; +} + +.qc-publish-stack { + display: grid; + gap: 0.7rem; + position: relative; + z-index: 2; +} + +.qc-button, +.qc-button-secondary { + min-height: 3.25rem; + padding: 0 1.2rem; + font-size: 0.95rem; +} + +.qc-button:disabled { + background: #d8dbe1; + color: #f3f4f6; + box-shadow: none; + cursor: not-allowed; + transform: none; +} + +.qc-button-secondary { + box-shadow: none; +} + +@media (max-width: 1023px) { + .qc-review-grid { + grid-template-columns: 1fr; + } + + .qc-side-stack { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 767px) { + .qc-body, + .qc-footer { + padding: 1rem; + } + + .qc-panel-head, + .qc-panel-row, + .qc-summary-card, + .qc-review-meta, + .qc-footer, + .qc-side-stack { + flex-direction: column; + align-items: stretch; + } + + .qc-footer { + justify-content: stretch; + } + + .qc-upload-zone { + min-height: 260px; + } + + .qc-category-grid, + .qc-photo-grid, + .qc-photo-strip, + .qc-review-thumbs, + .qc-fields.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .qc-feature-row { + grid-template-columns: 1fr; + gap: 0.3rem; + } + + .qc-review-location { + text-align: left; + } +} + +@media (max-width: 540px) { + .qc-category-grid, + .qc-photo-grid, + .qc-photo-strip, + .qc-review-thumbs, + .qc-fields.two-col { + grid-template-columns: 1fr; + } + + .qc-category-row { + grid-template-columns: 1fr auto; + } + + .qc-category-check { + display: none; + } +} diff --git a/resources/js/app.js b/resources/js/app.js index dc202daf5..31a38a945 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,8 @@ import './bootstrap'; import '../../Modules/Conversation/resources/assets/js/conversation'; +import '../css/modules/panel-quick-create.css'; +import './modules/listing-filters'; +import './modules/site-home'; import { animate, createTimeline, stagger } from 'animejs'; const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index deb2e10ca..ded34c639 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -3,10 +3,4 @@ window.axios = axios; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; -/** - * Echo exposes an expressive API for subscribing to channels and listening - * for events that are broadcast by Laravel. Echo and event broadcasting - * allow your team to quickly build robust real-time web applications. - */ - import './echo'; diff --git a/resources/js/modules/listing-filters.js b/resources/js/modules/listing-filters.js new file mode 100644 index 000000000..7753d9ab1 --- /dev/null +++ b/resources/js/modules/listing-filters.js @@ -0,0 +1,203 @@ +const onReady = (callback) => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback, { once: true }); + + return; + } + + callback(); +}; + +onReady(() => { + 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 = ''; + 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 = ''; + citySelect.disabled = true; + + return; + } + + citySelect.disabled = true; + citySelect.innerHTML = ''; + + 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 = ''; + 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) { + } + }); +}); diff --git a/resources/js/modules/site-home.js b/resources/js/modules/site-home.js new file mode 100644 index 000000000..6561a6bbf --- /dev/null +++ b/resources/js/modules/site-home.js @@ -0,0 +1,205 @@ +const onReady = (callback) => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback, { once: true }); + + return; + } + + callback(); +}; + +onReady(() => { + const form = document.querySelector('[data-demo-prepare-form]'); + + if (form) { + const button = form.querySelector('[data-demo-prepare-button]'); + const idleLabel = form.querySelector('[data-demo-prepare-idle]'); + const loadingLabel = form.querySelector('[data-demo-prepare-loading]'); + const status = form.querySelector('[data-demo-prepare-status]'); + const turnstileRequired = form.dataset.turnstileRequired === '1'; + + const resolveTurnstileToken = () => { + const tokenField = form.querySelector('input[name="cf-turnstile-response"]'); + + if (!tokenField) { + return ''; + } + + return tokenField.value.trim(); + }; + + const applyReadyState = () => { + if (!button) { + return; + } + + if (!turnstileRequired) { + button.removeAttribute('disabled'); + + return; + } + + const token = resolveTurnstileToken(); + + if (token === '') { + button.setAttribute('disabled', 'disabled'); + + return; + } + + button.removeAttribute('disabled'); + }; + + if (turnstileRequired) { + const tokenObserver = window.setInterval(() => { + applyReadyState(); + }, 250); + + form.addEventListener('submit', () => { + window.clearInterval(tokenObserver); + }); + } else { + applyReadyState(); + } + + form.addEventListener('submit', (event) => { + if (form.dataset.submitting === '1') { + event.preventDefault(); + + return; + } + + if (turnstileRequired && resolveTurnstileToken() === '') { + event.preventDefault(); + + if (status) { + status.textContent = status.dataset.turnstileMessage ?? 'Please complete the security verification first.'; + status.classList.remove('hidden'); + } + + applyReadyState(); + + return; + } + + form.dataset.submitting = '1'; + + if (button) { + button.setAttribute('disabled', 'disabled'); + } + + if (idleLabel) { + idleLabel.classList.add('hidden'); + } + + if (loadingLabel) { + loadingLabel.classList.remove('hidden'); + loadingLabel.classList.add('inline-flex'); + } + + if (status) { + status.textContent = status.dataset.loadingMessage ?? status.textContent; + status.classList.remove('hidden'); + } + }); + } + + const track = document.querySelector('[data-trend-track]'); + const previousTrendButton = document.querySelector('[data-trend-prev]'); + const nextTrendButton = document.querySelector('[data-trend-next]'); + + if (track && previousTrendButton && nextTrendButton) { + const scrollAmount = () => Math.max(240, Math.floor(track.clientWidth * 0.7)); + + previousTrendButton.addEventListener('click', () => { + track.scrollBy({ left: -scrollAmount(), behavior: 'smooth' }); + }); + + nextTrendButton.addEventListener('click', () => { + track.scrollBy({ left: scrollAmount(), behavior: 'smooth' }); + }); + } + + const slider = document.querySelector('[data-home-slider]'); + + if (!slider) { + return; + } + + const slides = Array.from(slider.querySelectorAll('[data-home-slide]')); + const visuals = Array.from(document.querySelectorAll('[data-home-slide-visual]')); + const dots = Array.from(slider.querySelectorAll('[data-home-slide-dot]')); + const previousButton = slider.querySelector('[data-home-slide-prev]'); + const nextButton = slider.querySelector('[data-home-slide-next]'); + + if (slides.length <= 1) { + return; + } + + let activeIndex = 0; + let intervalId = null; + + const activateSlide = (index) => { + activeIndex = (index + slides.length) % slides.length; + + slides.forEach((slide, slideIndex) => { + const isActive = slideIndex === activeIndex; + + slide.classList.toggle('hidden', !isActive); + slide.setAttribute('aria-hidden', isActive ? 'false' : 'true'); + }); + + visuals.forEach((visual, visualIndex) => { + const isActive = visualIndex === activeIndex; + + visual.classList.toggle('hidden', !isActive); + visual.setAttribute('aria-hidden', isActive ? 'false' : 'true'); + }); + + dots.forEach((dot, dotIndex) => { + const isActive = dotIndex === activeIndex; + + dot.classList.toggle('w-7', isActive); + dot.classList.toggle('bg-white', isActive); + dot.classList.toggle('w-2.5', !isActive); + dot.classList.toggle('bg-white/40', !isActive); + }); + }; + + const stopAutoPlay = () => { + if (intervalId !== null) { + window.clearInterval(intervalId); + intervalId = null; + } + }; + + const startAutoPlay = () => { + stopAutoPlay(); + intervalId = window.setInterval(() => activateSlide(activeIndex + 1), 6000); + }; + + previousButton?.addEventListener('click', () => { + activateSlide(activeIndex - 1); + startAutoPlay(); + }); + + nextButton?.addEventListener('click', () => { + activateSlide(activeIndex + 1); + startAutoPlay(); + }); + + dots.forEach((dot, index) => { + dot.addEventListener('click', () => { + activateSlide(index); + startAutoPlay(); + }); + }); + + slider.addEventListener('mouseenter', stopAutoPlay); + slider.addEventListener('mouseleave', startAutoPlay); + slider.addEventListener('focusin', stopAutoPlay); + slider.addEventListener('focusout', startAutoPlay); + + activateSlide(0); + startAutoPlay(); +});