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.
This commit is contained in:
fatihalp 2026-03-23 01:39:30 +03:00
parent 057620b715
commit f06943ce9d
100 changed files with 2192 additions and 2019 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,5 @@ class AdminServiceProvider extends ServiceProvider
} }
public function register(): void public function register(): void
{ {}
$this->app->register(AdminPanelProvider::class);
}
} }

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\Category\Filament\Admin\Resources;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -11,10 +11,10 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions; use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns; use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
use UnitEnum; use UnitEnum;
class CategoryResource extends Resource class CategoryResource extends Resource

View File

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

View File

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

View File

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

View File

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

View File

@ -15,5 +15,6 @@ class CategoryServiceProvider extends ServiceProvider
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category'); $this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category');
} }
public function register(): void {} public function register(): void
{}
} }

View File

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

View File

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

View File

@ -3,7 +3,6 @@
namespace Modules\Conversation\Database\Seeders; namespace Modules\Conversation\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation; use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage; use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
@ -14,10 +13,6 @@ class ConversationDemoSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
if (! $this->conversationTablesExist()) {
return;
}
$users = User::query() $users = User::query()
->whereIn('email', DemoUserCatalog::emails()) ->whereIn('email', DemoUserCatalog::emails())
->orderBy('email') ->orderBy('email')
@ -73,11 +68,6 @@ class ConversationDemoSeeder extends Seeder
} }
} }
private function conversationTablesExist(): bool
{
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
}
private function seedConversationThread( private function seedConversationThread(
User $seller, User $seller,
User $buyer, User $buyer,

View File

@ -8,7 +8,6 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (! Schema::hasTable('conversations')) {
Schema::create('conversations', function (Blueprint $table): void { Schema::create('conversations', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete(); $table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
@ -21,9 +20,7 @@ return new class extends Migration
$table->index(['seller_id', 'last_message_at']); $table->index(['seller_id', 'last_message_at']);
$table->index(['buyer_id', 'last_message_at']); $table->index(['buyer_id', 'last_message_at']);
}); });
}
if (! Schema::hasTable('conversation_messages')) {
Schema::create('conversation_messages', function (Blueprint $table): void { Schema::create('conversation_messages', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete(); $table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
@ -36,7 +33,6 @@ return new class extends Migration
$table->index(['conversation_id', 'read_at']); $table->index(['conversation_id', 'read_at']);
}); });
} }
}
public function down(): void public function down(): void
{ {

View File

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

View File

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

View File

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

View File

@ -8,7 +8,6 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (! Schema::hasTable('favorite_listings')) {
Schema::create('favorite_listings', function (Blueprint $table): void { Schema::create('favorite_listings', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
@ -17,9 +16,7 @@ return new class extends Migration
$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 { Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
@ -28,9 +25,7 @@ return new class extends Migration
$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 { Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
@ -44,7 +39,6 @@ return new class extends Migration
$table->unique(['user_id', 'signature']); $table->unique(['user_id', 'signature']);
}); });
} }
}
public function down(): void public function down(): void
{ {

View File

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

View File

@ -29,4 +29,9 @@ return new class extends Migration
$table->nullableTimestamps(); $table->nullableTimestamps();
}); });
} }
public function down(): void
{
Schema::dropIfExists('media');
}
}; };

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\Listing\Filament\Admin\Resources;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@ -13,9 +13,9 @@ use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions; use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
use Modules\Listing\Models\ListingCustomField; use Modules\Listing\Models\ListingCustomField;
use UnitEnum; use UnitEnum;
@ -37,21 +37,7 @@ class ListingCustomFieldResource extends Resource
->maxLength(255) ->maxLength(255)
->live(onBlur: true) ->live(onBlur: true)
->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void { ->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void {
$baseName = \Illuminate\Support\Str::slug((string) $state, '_'); $set('name', ListingCustomField::uniqueNameFromLabel((string) $state, $record));
$baseName = $baseName !== '' ? $baseName : 'custom_field';
$name = $baseName;
$counter = 1;
while (ListingCustomField::query()
->where('name', $name)
->when($record, fn ($query) => $query->whereKeyNot($record->getKey()))
->exists()) {
$name = "{$baseName}_{$counter}";
$counter++;
}
$set('name', $name);
}), }),
TextInput::make('name') TextInput::make('name')
->required() ->required()

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\Listing\Filament\Admin\Resources;
use BackedEnum; use BackedEnum;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Table; use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\ListingResource\Pages; use Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\Listing\Support\Filament\AdminListingResourceSchema; use Modules\Listing\Support\Filament\AdminListingResourceSchema;
use UnitEnum; use UnitEnum;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -319,6 +319,54 @@ class Listing extends Model implements HasMedia
->count(); ->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 public static function homeFeatured(int $limit = 4): Collection
{ {
return static::query() return static::query()
@ -528,6 +576,16 @@ class Listing extends Model implements HasMedia
abort_unless((int) $this->user_id === (int) $user->getKey(), 403); 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 public function markAsSold(): void
{ {
$this->forceFill([ $this->forceFill([

View File

@ -4,6 +4,7 @@ namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
class ListingCustomField extends Model class ListingCustomField extends Model
@ -88,6 +89,24 @@ class ListingCustomField extends Model
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all(); return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
} }
public static function uniqueNameFromLabel(string $label, ?self $record = null): string
{
$baseName = Str::slug($label, '_');
$baseName = $baseName !== '' ? $baseName : 'custom_field';
$name = $baseName;
$counter = 1;
while (static::query()
->where('name', $name)
->when($record, fn (Builder $query): Builder => $query->whereKeyNot($record->getKey()))
->exists()) {
$name = "{$baseName}_{$counter}";
$counter++;
}
return $name;
}
public static function upsertSeeded(Category $category, array $attributes): self public static function upsertSeeded(Category $category, array $attributes): self
{ {
return static::query()->updateOrCreate( return static::query()->updateOrCreate(

View File

@ -17,5 +17,6 @@ class ListingServiceProvider extends ServiceProvider
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php')); $this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
} }
public function register(): void {} public function register(): void
{}
} }

View File

@ -381,196 +381,3 @@
</section> </section>
</div> </div>
</div> </div>
<script>
(() => {
const countrySelect = document.querySelector('[data-listing-country]');
const citySelect = document.querySelector('[data-listing-city]');
const currentLocationButton = document.querySelector('[data-use-current-location]');
const filterDrawer = document.querySelector('[data-listing-filter-drawer]');
const filterOpenButtons = Array.from(document.querySelectorAll('[data-listing-filter-open]'));
const filterCloseButtons = Array.from(document.querySelectorAll('[data-listing-filter-close]'));
const citiesTemplate = countrySelect?.dataset.citiesUrlTemplate ?? '';
const locationStorageKey = 'oc2.header.location';
const drawerMediaQuery = window.matchMedia('(max-width: 1023px)');
const setDrawerExpanded = (expanded) => {
filterOpenButtons.forEach((button) => button.setAttribute('aria-expanded', expanded ? 'true' : 'false'));
};
const closeFilterDrawer = () => {
if (!filterDrawer) {
return;
}
filterDrawer.classList.remove('is-open');
filterDrawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('listing-filters-open');
setDrawerExpanded(false);
};
const openFilterDrawer = () => {
if (!filterDrawer || !drawerMediaQuery.matches) {
return;
}
filterDrawer.classList.add('is-open');
filterDrawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('listing-filters-open');
setDrawerExpanded(true);
};
filterOpenButtons.forEach((button) => button.addEventListener('click', openFilterDrawer));
filterCloseButtons.forEach((button) => button.addEventListener('click', closeFilterDrawer));
window.addEventListener('resize', () => {
if (!drawerMediaQuery.matches) {
closeFilterDrawer();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeFilterDrawer();
}
});
if (drawerMediaQuery.matches) {
closeFilterDrawer();
} else if (filterDrawer) {
filterDrawer.setAttribute('aria-hidden', 'false');
setDrawerExpanded(false);
}
if (!countrySelect || !citySelect || citiesTemplate === '') {
return;
}
const normalize = (value) => (value ?? '')
.toString()
.toLocaleLowerCase('tr-TR')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
const setCityOptions = (cities, selectedCityName = '') => {
citySelect.innerHTML = '<option value="">Select city</option>';
cities.forEach((city) => {
const option = document.createElement('option');
option.value = String(city.id ?? '');
option.textContent = city.name ?? '';
option.dataset.name = city.name ?? '';
citySelect.appendChild(option);
});
citySelect.disabled = false;
if (selectedCityName) {
const matched = Array.from(citySelect.options).find((option) => normalize(option.dataset.name) === normalize(selectedCityName));
if (matched) {
citySelect.value = matched.value;
}
}
};
const fetchCityOptions = async (url) => {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('city_fetch_failed');
}
const payload = await response.json();
if (Array.isArray(payload)) {
return payload;
}
return Array.isArray(payload?.data) ? payload.data : [];
};
const loadCities = async (countryId, selectedCityName = '') => {
if (!countryId) {
citySelect.innerHTML = '<option value="">Select country first</option>';
citySelect.disabled = true;
return;
}
citySelect.disabled = true;
citySelect.innerHTML = '<option value="">Loading cities...</option>';
const primaryUrl = citiesTemplate.replace('__COUNTRY__', encodeURIComponent(String(countryId)));
try {
let cities = [];
try {
cities = await fetchCityOptions(primaryUrl);
} catch (primaryError) {
if (!/^https?:\/\//i.test(primaryUrl)) {
throw primaryError;
}
let fallbackUrl = null;
try {
const parsed = new URL(primaryUrl);
fallbackUrl = `${parsed.pathname}${parsed.search}`;
} catch (urlError) {
fallbackUrl = null;
}
if (!fallbackUrl) {
throw primaryError;
}
cities = await fetchCityOptions(fallbackUrl);
}
setCityOptions(cities, selectedCityName);
} catch (error) {
citySelect.innerHTML = '<option value="">Cities could not be loaded</option>';
citySelect.disabled = true;
}
};
countrySelect.addEventListener('change', () => {
citySelect.value = '';
void loadCities(countrySelect.value);
});
currentLocationButton?.addEventListener('click', async () => {
try {
const rawLocation = localStorage.getItem(locationStorageKey);
if (!rawLocation) {
return;
}
const parsedLocation = JSON.parse(rawLocation);
const countryName = parsedLocation?.countryName ?? '';
const cityName = parsedLocation?.cityName ?? '';
const countryId = parsedLocation?.countryId ? String(parsedLocation.countryId) : null;
const matchedCountryOption = Array.from(countrySelect.options).find((option) => {
if (countryId && option.value === countryId) {
return true;
}
return normalize(option.textContent) === normalize(countryName);
});
if (!matchedCountryOption) {
return;
}
countrySelect.value = matchedCountryOption.value;
await loadCities(matchedCountryOption.value, cityName);
} catch (error) {
}
});
})();
</script>

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\Location\Filament\Admin\Resources;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@ -13,9 +13,9 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\CityResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions; use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns; use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Location\Filament\Admin\Resources\CityResource\Pages;
use Modules\Location\Models\City; use Modules\Location\Models\City;
use UnitEnum; use UnitEnum;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\Location\Filament\Admin\Resources;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -12,13 +12,13 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\LocationResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions; use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns; use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
use Modules\Location\Models\Country; use Modules\Location\Models\Country;
use UnitEnum; use UnitEnum;
class LocationResource extends Resource class CountryResource extends Resource
{ {
protected static ?string $model = Country::class; protected static ?string $model = Country::class;
@ -36,7 +36,7 @@ class LocationResource extends Resource
{ {
return $schema->schema([ return $schema->schema([
TextInput::make('name')->required()->maxLength(100), TextInput::make('name')->required()->maxLength(100),
TextInput::make('code')->required()->maxLength(2)->unique(ignoreRecord: true), TextInput::make('code')->required()->maxLength(3)->unique(ignoreRecord: true),
TextInput::make('phone_code')->maxLength(10), TextInput::make('phone_code')->maxLength(10),
Toggle::make('is_active')->default(true), Toggle::make('is_active')->default(true),
]); ]);
@ -70,10 +70,10 @@ class LocationResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListLocations::route('/'), 'index' => Pages\ListCountries::route('/'),
'create' => Pages\CreateLocation::route('/create'), 'create' => Pages\CreateCountry::route('/create'),
'activities' => Pages\ListLocationActivities::route('/{record}/activities'), 'activities' => Pages\ListCountryActivities::route('/{record}/activities'),
'edit' => Pages\EditLocation::route('/{record}/edit'), 'edit' => Pages\EditCountry::route('/{record}/edit'),
]; ];
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\Location\Filament\Admin\Resources;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@ -13,9 +13,9 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions; use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns; use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
use Modules\Location\Models\Country; use Modules\Location\Models\Country;
use Modules\Location\Models\District; use Modules\Location\Models\District;
use UnitEnum; use UnitEnum;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -131,4 +131,63 @@ class Country extends Model
]) ])
->all(); ->all();
} }
public static function browseSelection(?int $countryId, ?int $cityId): array
{
$countries = static::query()
->active()
->orderBy('name')
->get(['id', 'name']);
$selectedCountry = $countryId
? ($countries->firstWhere('id', $countryId) ?? static::query()->whereKey($countryId)->first(['id', 'name']))
: null;
$selectedCity = $cityId
? City::query()->whereKey($cityId)->first(['id', 'name', 'country_id'])
: null;
if ($selectedCity && ! $selectedCountry) {
$countryId = (int) $selectedCity->country_id;
$selectedCountry = static::query()->whereKey($countryId)->first(['id', 'name']);
}
$cities = collect();
if ($selectedCountry) {
$countryId = (int) $selectedCountry->getKey();
$cities = City::query()
->where('country_id', $countryId)
->active()
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($cities->isEmpty()) {
$cities = City::query()
->where('country_id', $countryId)
->orderBy('name')
->get(['id', 'name', 'country_id']);
}
} else {
$countryId = null;
$cityId = null;
}
if ($selectedCity && $countryId && (int) $selectedCity->country_id !== $countryId) {
$selectedCity = null;
$cityId = null;
}
if ($selectedCity) {
$cityId = (int) $selectedCity->getKey();
}
return [
'country_id' => $countryId,
'city_id' => $cityId,
'countries' => $countries,
'cities' => $cities,
'selected_country_name' => $selectedCountry?->name ? (string) $selectedCountry->name : null,
'selected_city_name' => $selectedCity?->name ? (string) $selectedCity->name : null,
];
}
} }

View File

@ -14,5 +14,6 @@ class LocationServiceProvider extends ServiceProvider
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php')); $this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
} }
public function register(): void {} public function register(): void
{}
} }

View File

@ -7,831 +7,6 @@
@endphp @endphp
<div class="mx-auto w-full max-w-[920px] px-4 py-6 sm:py-10"> <div class="mx-auto w-full max-w-[920px] px-4 py-6 sm:py-10">
<style>
.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;
}
}
</style>
<div class="qc-shell"> <div class="qc-shell">
<div class="qc-header"> <div class="qc-header">
<span class="qc-step-chip">Step {{ $currentStep }} of 5</span> <span class="qc-step-chip">Step {{ $currentStep }} of 5</span>

View File

@ -18,7 +18,7 @@ class HomeController extends Controller
$categoryCount = Category::activeCount(); $categoryCount = Category::activeCount();
$userCount = User::totalCount(); $userCount = User::totalCount();
$favoriteListingIds = auth()->check() $favoriteListingIds = auth()->check()
? auth()->user()->homeFavoriteListingIds() ? auth()->user()->favoriteListingIds()
: []; : [];
return view('site::home', compact( return view('site::home', compact(

View File

@ -26,6 +26,7 @@ class SiteServiceProvider extends ServiceProvider
{ {
$viewPath = module_path('Site', 'resources/views'); $viewPath = module_path('Site', 'resources/views');
$this->loadMigrationsFrom(module_path('Site', 'Database/migrations'));
$this->loadRoutesFrom(module_path('Site', 'routes/web.php')); $this->loadRoutesFrom(module_path('Site', 'routes/web.php'));
$this->loadViewsFrom($viewPath, 'site'); $this->loadViewsFrom($viewPath, 'site');
View::addNamespace('app', $viewPath); View::addNamespace('app', $viewPath);

View File

@ -3,7 +3,6 @@
namespace Modules\Site\App\Support; namespace Modules\Site\App\Support;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Location\Models\Country; use Modules\Location\Models\Country;
@ -68,14 +67,6 @@ final class RequestAppData
'apple_client_secret' => $fallbackAppleClientSecret, 'apple_client_secret' => $fallbackAppleClientSecret,
]; ];
try {
if (! Schema::hasTable('settings')) {
return $generalSettings;
}
} catch (Throwable) {
return $generalSettings;
}
try { try {
$settings = app(GeneralSettings::class); $settings = app(GeneralSettings::class);
$currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies); $currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies);
@ -141,15 +132,15 @@ final class RequestAppData
'filament-google-maps.keys.server_key' => $mapsKey, 'filament-google-maps.keys.server_key' => $mapsKey,
'services.google.client_id' => $generalSettings['google_client_id'], 'services.google.client_id' => $generalSettings['google_client_id'],
'services.google.client_secret' => $generalSettings['google_client_secret'], '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.google.enabled' => (bool) $generalSettings['google_login_enabled'],
'services.facebook.client_id' => $generalSettings['facebook_client_id'], 'services.facebook.client_id' => $generalSettings['facebook_client_id'],
'services.facebook.client_secret' => $generalSettings['facebook_client_secret'], '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.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'],
'services.apple.client_id' => $generalSettings['apple_client_id'], 'services.apple.client_id' => $generalSettings['apple_client_id'],
'services.apple.client_secret' => $generalSettings['apple_client_secret'], '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.stateless' => true,
'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'], 'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'],
'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD', 'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD',
@ -161,10 +152,6 @@ final class RequestAppData
private function resolveHeaderLocationCountries(): array private function resolveHeaderLocationCountries(): array
{ {
try { try {
if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) {
return [];
}
return Country::headerLocationOptions(); return Country::headerLocationOptions();
} catch (Throwable) { } catch (Throwable) {
return []; return [];
@ -174,10 +161,6 @@ final class RequestAppData
private function resolveHeaderNavCategories(): array private function resolveHeaderNavCategories(): array
{ {
try { try {
if (! Schema::hasTable('categories')) {
return [];
}
return Category::headerNavigationItems(); return Category::headerNavigationItems();
} catch (Throwable) { } catch (Throwable) {
return []; return [];

View File

@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {
public function up() public function up(): void
{ {
Schema::create('settings', function (Blueprint $table): void { Schema::create('settings', function (Blueprint $table): void {
$table->id(); $table->id();
@ -21,4 +21,9 @@ return new class extends Migration
$table->unique(['group', 'name']); $table->unique(['group', 'name']);
}); });
} }
public function down(): void
{
Schema::dropIfExists('settings');
}
}; };

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Pages; namespace Modules\Site\Filament\Admin\Pages;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
@ -11,11 +11,11 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Pages\SettingsPage; use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\Location\Support\CountryCodeManager; use Modules\Location\Support\CountryCodeManager;
use Modules\Site\App\Settings\GeneralSettings; use Modules\Site\App\Settings\GeneralSettings;
use Modules\Site\App\Support\HomeSlideDefaults; use Modules\Site\App\Support\HomeSlideDefaults;
use Modules\Site\App\Support\LocalMedia; use Modules\Site\App\Support\LocalMedia;
use Modules\Site\Support\Filament\HomeSlideFormSchema;
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect; use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
use UnitEnum; use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput; use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
@ -24,13 +24,13 @@ class ManageGeneralSettings extends SettingsPage
{ {
protected static string $settings = GeneralSettings::class; protected static string $settings = GeneralSettings::class;
protected static ?string $title = 'Genel Ayarlar'; protected static ?string $title = 'General Settings';
protected static ?string $navigationLabel = 'Genel Ayarlar'; protected static ?string $navigationLabel = 'General Settings';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|UnitEnum|null $navigationGroup = 'Ayarlar'; protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?int $navigationSort = 1; protected static ?int $navigationSort = 1;

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Support; namespace Modules\Site\Support\Filament;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;

View File

@ -384,218 +384,6 @@
</section> </section>
</div> </div>
@endif @endif
<script>
(() => {
const setupPrepareDemoForm = () => {
const form = document.querySelector('[data-demo-prepare-form]');
if (!form) {
return;
}
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');
}
});
};
setupPrepareDemoForm();
const setupTrendCategories = () => {
const track = document.querySelector('[data-trend-track]');
const previousButton = document.querySelector('[data-trend-prev]');
const nextButton = document.querySelector('[data-trend-next]');
if (!track || !previousButton || !nextButton) {
return;
}
const scrollAmount = () => Math.max(240, Math.floor(track.clientWidth * 0.7));
previousButton.addEventListener('click', () => {
track.scrollBy({ left: -scrollAmount(), behavior: 'smooth' });
});
nextButton.addEventListener('click', () => {
track.scrollBy({ left: scrollAmount(), behavior: 'smooth' });
});
};
const setupHomeSlider = () => {
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();
};
setupHomeSlider();
setupTrendCategories();
})();
</script>
@if($prepareDemoTurnstileRenderable) @if($prepareDemoTurnstileRenderable)
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
@endif @endif

View File

@ -6,10 +6,8 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; 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 Laravel\Socialite\Facades\Socialite;
use Modules\User\App\Models\SocialiteUser;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\AuthProviderCatalog; use Modules\User\App\Support\AuthProviderCatalog;
use Modules\User\App\Support\AuthRedirector; use Modules\User\App\Support\AuthRedirector;
@ -60,46 +58,7 @@ class SocialAuthController extends Controller
private function resolveUser(string $provider, mixed $oauthUser): User private function resolveUser(string $provider, mixed $oauthUser): User
{ {
$socialiteUser = DB::table('socialite_users') return SocialiteUser::resolveUser($provider, $oauthUser);
->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;
} }
private function driver(string $provider): mixed private function driver(string $provider): mixed

View File

@ -0,0 +1,58 @@
<?php
namespace Modules\User\App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class SocialiteUser extends Model
{
protected $table = 'socialite_users';
protected $fillable = ['user_id', 'provider', 'provider_id'];
public function user(): BelongsTo
{
return $this->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;
}
}

View File

@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable; use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable;
@ -186,6 +187,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
return true; return true;
} }
public function rememberListing(Listing $listing): void
{
$this->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
}
public function unreadInboxCount(): int public function unreadInboxCount(): int
{ {
return Conversation::unreadCountForUser((int) $this->getKey()); return Conversation::unreadCountForUser((int) $this->getKey());
@ -223,7 +229,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
return (int) static::query()->count(); return (int) static::query()->count();
} }
public function homeFavoriteListingIds(): array public function favoriteListingIds(): array
{ {
return $this->favoriteListings() return $this->favoriteListings()
->pluck('listings.id') ->pluck('listings.id')
@ -231,6 +237,43 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
->all(); ->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 public function panelListingOptions(): Collection
{ {
return $this->listings() return $this->listings()

View File

@ -13,5 +13,6 @@ class UserServiceProvider extends ServiceProvider
$this->loadViewsFrom(module_path('User', 'resources/views'), 'user'); $this->loadViewsFrom(module_path('User', 'resources/views'), 'user');
} }
public function register(): void {} public function register(): void
{}
} }

View File

@ -3,7 +3,6 @@
namespace Modules\User\Database\Seeders; namespace Modules\User\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog; use Modules\User\App\Support\DemoUserCatalog;
use Spatie\Permission\Models\Role; 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([ $adminRole = Role::query()->firstOrCreate([
'name' => 'admin', 'name' => 'admin',
'guard_name' => 'web', 'guard_name' => 'web',

View File

@ -8,7 +8,6 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (! Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table): void { Schema::create('users', function (Blueprint $table): void {
$table->id(); $table->id();
$table->string('name'); $table->string('name');
@ -20,9 +19,7 @@ return new class extends Migration
$table->rememberToken(); $table->rememberToken();
$table->timestamps(); $table->timestamps();
}); });
}
if (! Schema::hasTable('profiles')) {
Schema::create('profiles', function (Blueprint $table): void { Schema::create('profiles', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete(); $table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
@ -35,17 +32,13 @@ return new class extends Migration
$table->boolean('is_verified')->default(false); $table->boolean('is_verified')->default(false);
$table->timestamps(); $table->timestamps();
}); });
}
if (! Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table): void { Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary(); $table->string('email')->primary();
$table->string('token'); $table->string('token');
$table->timestamp('created_at')->nullable(); $table->timestamp('created_at')->nullable();
}); });
}
if (! Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table): void { Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary(); $table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index(); $table->foreignId('user_id')->nullable()->index();
@ -55,7 +48,6 @@ return new class extends Migration
$table->integer('last_activity')->index(); $table->integer('last_activity')->index();
}); });
} }
}
public function down(): void public function down(): void
{ {

View File

@ -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(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.'); 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) { Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id $table->id();
$table->string('name'); $table->string('name');
$table->string('guard_name'); $table->string('guard_name');
$table->timestamps(); $table->timestamps();
@ -25,8 +25,8 @@ return new class extends Migration
$table->unique(['name', 'guard_name']); $table->unique(['name', 'guard_name']);
}); });
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id $table->id();
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing if ($teams || config('permission.testing')) {
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); $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->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission) $table->foreign($pivotPermission)
->references('id') // permission id ->references('id')
->on($tableNames['permissions']) ->on($tableNames['permissions'])
->cascadeOnDelete(); ->cascadeOnDelete();
if ($teams) { 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->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole) $table->foreign($pivotRole)
->references('id') // role id ->references('id')
->on($tableNames['roles']) ->on($tableNames['roles'])
->cascadeOnDelete(); ->cascadeOnDelete();
if ($teams) { if ($teams) {
@ -91,12 +91,12 @@ return new class extends Migration
$table->unsignedBigInteger($pivotRole); $table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission) $table->foreign($pivotPermission)
->references('id') // permission id ->references('id')
->on($tableNames['permissions']) ->on($tableNames['permissions'])
->cascadeOnDelete(); ->cascadeOnDelete();
$table->foreign($pivotRole) $table->foreign($pivotRole)
->references('id') // role id ->references('id')
->on($tableNames['roles']) ->on($tableNames['roles'])
->cascadeOnDelete(); ->cascadeOnDelete();

View File

@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration { return new class extends Migration
{
public function up(): void public function up(): void
{ {
Schema::create('passkeys', function (Blueprint $table) { Schema::create('passkeys', function (Blueprint $table) {

View File

@ -1,11 +1,12 @@
<?php <?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration; 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() {
public function up(): void
{ {
Schema::create('socialite_users', function (Blueprint $table) { Schema::create('socialite_users', function (Blueprint $table) {
$table->id(); $table->id();
@ -23,7 +24,7 @@ return new class extends Migration {
}); });
} }
public function down() public function down(): void
{ {
Schema::dropIfExists('socialite_users'); Schema::dropIfExists('socialite_users');
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Admin\Filament\Resources; namespace Modules\User\Filament\Admin\Resources;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn; use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter; use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
@ -9,9 +9,9 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\UserResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions; use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Admin\Support\Filament\ResourceTableColumns; use Modules\Admin\Support\Filament\ResourceTableColumns;
use Modules\User\Filament\Admin\Resources\UserResource\Pages;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\Filament\UserFormFields; use Modules\User\App\Support\Filament\UserFormFields;
use STS\FilamentImpersonate\Actions\Impersonate; use STS\FilamentImpersonate\Actions\Impersonate;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@
namespace Modules\Video\Database\Seeders; namespace Modules\Video\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog; use Modules\User\App\Support\DemoUserCatalog;
@ -14,10 +13,6 @@ class VideoDemoSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) {
return;
}
$users = User::query() $users = User::query()
->whereIn('email', DemoUserCatalog::emails()) ->whereIn('email', DemoUserCatalog::emails())
->orderBy('email') ->orderBy('email')

View File

@ -0,0 +1,35 @@
<?php
namespace Modules\Video;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Filament\View\PanelsRenderHook;
final class VideoPlugin implements Plugin
{
public function getId(): string
{
return 'video';
}
public static function make(): static
{
return app(static::class);
}
public function register(Panel $panel): void
{
$panel
->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 {}
}

View File

@ -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;
}
}

View File

@ -1,5 +1,8 @@
import './bootstrap'; import './bootstrap';
import '../../Modules/Conversation/resources/assets/js/conversation'; 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'; import { animate, createTimeline, stagger } from 'animejs';
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

View File

@ -3,10 +3,4 @@ window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 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'; import './echo';

View File

@ -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 = '<option value="">Select city</option>';
cities.forEach((city) => {
const option = document.createElement('option');
option.value = String(city.id ?? '');
option.textContent = city.name ?? '';
option.dataset.name = city.name ?? '';
citySelect.appendChild(option);
});
citySelect.disabled = false;
if (selectedCityName) {
const matched = Array.from(citySelect.options).find((option) => normalize(option.dataset.name) === normalize(selectedCityName));
if (matched) {
citySelect.value = matched.value;
}
}
};
const fetchCityOptions = async (url) => {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error('city_fetch_failed');
}
const payload = await response.json();
if (Array.isArray(payload)) {
return payload;
}
return Array.isArray(payload?.data) ? payload.data : [];
};
const loadCities = async (countryId, selectedCityName = '') => {
if (!countryId) {
citySelect.innerHTML = '<option value="">Select country first</option>';
citySelect.disabled = true;
return;
}
citySelect.disabled = true;
citySelect.innerHTML = '<option value="">Loading cities...</option>';
const primaryUrl = citiesTemplate.replace('__COUNTRY__', encodeURIComponent(String(countryId)));
try {
let cities = [];
try {
cities = await fetchCityOptions(primaryUrl);
} catch (primaryError) {
if (!/^https?:\/\//i.test(primaryUrl)) {
throw primaryError;
}
let fallbackUrl = null;
try {
const parsed = new URL(primaryUrl);
fallbackUrl = `${parsed.pathname}${parsed.search}`;
} catch (urlError) {
fallbackUrl = null;
}
if (!fallbackUrl) {
throw primaryError;
}
cities = await fetchCityOptions(fallbackUrl);
}
setCityOptions(cities, selectedCityName);
} catch (error) {
citySelect.innerHTML = '<option value="">Cities could not be loaded</option>';
citySelect.disabled = true;
}
};
countrySelect.addEventListener('change', () => {
citySelect.value = '';
void loadCities(countrySelect.value);
});
currentLocationButton?.addEventListener('click', async () => {
try {
const rawLocation = localStorage.getItem(locationStorageKey);
if (!rawLocation) {
return;
}
const parsedLocation = JSON.parse(rawLocation);
const countryName = parsedLocation?.countryName ?? '';
const cityName = parsedLocation?.cityName ?? '';
const countryId = parsedLocation?.countryId ? String(parsedLocation.countryId) : null;
const matchedCountryOption = Array.from(countrySelect.options).find((option) => {
if (countryId && option.value === countryId) {
return true;
}
return normalize(option.textContent) === normalize(countryName);
});
if (!matchedCountryOption) {
return;
}
countrySelect.value = matchedCountryOption.value;
await loadCities(matchedCountryOption.value, cityName);
} catch (error) {
}
});
});

View File

@ -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();
});