mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 03:02:08 -05:00
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:
parent
057620b715
commit
f06943ce9d
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
||||
|
||||
class EditCategory extends EditRecord
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\CityResource;
|
||||
|
||||
class EditCity extends EditRecord
|
||||
{
|
||||
protected static string $resource = CityResource::class;
|
||||
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Admin\Filament\Resources\CityResource;
|
||||
|
||||
class ListCities extends ListRecords
|
||||
{
|
||||
protected static string $resource = CityResource::class;
|
||||
protected function getHeaderActions(): array { return [CreateAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\DistrictResource;
|
||||
|
||||
class EditDistrict extends EditRecord
|
||||
{
|
||||
protected static string $resource = DistrictResource::class;
|
||||
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Admin\Filament\Resources\DistrictResource;
|
||||
|
||||
class ListDistricts extends ListRecords
|
||||
{
|
||||
protected static string $resource = DistrictResource::class;
|
||||
protected function getHeaderActions(): array { return [CreateAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\ListingResource;
|
||||
|
||||
class EditListing extends EditRecord
|
||||
{
|
||||
protected static string $resource = ListingResource::class;
|
||||
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Admin\Filament\Resources\ListingResource;
|
||||
|
||||
class ListListings extends ListRecords
|
||||
{
|
||||
protected static string $resource = ListingResource::class;
|
||||
protected function getHeaderActions(): array { return [CreateAction::make()]; }
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\LocationResource;
|
||||
|
||||
class CreateLocation extends CreateRecord
|
||||
{
|
||||
protected static string $resource = LocationResource::class;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\LocationResource;
|
||||
|
||||
class EditLocation extends EditRecord
|
||||
{
|
||||
protected static string $resource = LocationResource::class;
|
||||
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
|
||||
use Modules\Admin\Filament\Resources\LocationResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListLocationActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = LocationResource::class;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Admin\Filament\Resources\LocationResource;
|
||||
|
||||
class ListLocations extends ListRecords
|
||||
{
|
||||
protected static string $resource = LocationResource::class;
|
||||
protected function getHeaderActions(): array { return [CreateAction::make()]; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Admin\Filament\Resources\UserResource;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
protected function getHeaderActions(): array { return [CreateAction::make()]; }
|
||||
}
|
||||
@ -13,7 +13,6 @@ use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
@ -21,8 +20,14 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Jeffgreco13\FilamentBreezy\BreezyCore;
|
||||
use Modules\Category\CategoryPlugin;
|
||||
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
|
||||
use Modules\Listing\ListingPlugin;
|
||||
use Modules\Location\LocationPlugin;
|
||||
use Modules\Site\App\Http\Middleware\BootstrapAppData;
|
||||
use Modules\Site\SitePlugin;
|
||||
use Modules\User\UserPlugin;
|
||||
use Modules\Video\VideoPlugin;
|
||||
use MWGuerra\FileManager\Filament\Pages\FileManager;
|
||||
use MWGuerra\FileManager\FileManagerPlugin;
|
||||
|
||||
@ -36,11 +41,6 @@ class AdminPanelProvider extends PanelProvider
|
||||
->path('admin')
|
||||
->login()
|
||||
->colors(['primary' => Color::Blue])
|
||||
->discoverResources(in: module_path('Admin', 'Filament/Resources'), for: 'Modules\\Admin\\Filament\\Resources')
|
||||
->discoverResources(in: module_path('Video', 'Filament/Admin/Resources'), for: 'Modules\\Video\\Filament\\Admin\\Resources')
|
||||
->discoverPages(in: module_path('Admin', 'Filament/Pages'), for: 'Modules\\Admin\\Filament\\Pages')
|
||||
->discoverWidgets(in: module_path('Admin', 'Filament/Widgets'), for: 'Modules\\Admin\\Filament\\Widgets')
|
||||
->renderHook(PanelsRenderHook::BODY_END, fn () => view('video::partials.video-upload-optimizer'))
|
||||
->userMenuItems([
|
||||
'view-site' => MenuItem::make()
|
||||
->label('View Site')
|
||||
@ -67,6 +67,12 @@ class AdminPanelProvider extends PanelProvider
|
||||
->users([
|
||||
'Admin' => 'a@a.com',
|
||||
]),
|
||||
CategoryPlugin::make(),
|
||||
ListingPlugin::make(),
|
||||
LocationPlugin::make(),
|
||||
SitePlugin::make(),
|
||||
UserPlugin::make(),
|
||||
VideoPlugin::make(),
|
||||
])
|
||||
->pages([Dashboard::class])
|
||||
->middleware([
|
||||
|
||||
@ -12,7 +12,5 @@ class AdminServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(AdminPanelProvider::class);
|
||||
}
|
||||
{}
|
||||
}
|
||||
|
||||
29
Modules/Category/CategoryPlugin.php
Normal file
29
Modules/Category/CategoryPlugin.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Category;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
final class CategoryPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'category';
|
||||
}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->discoverResources(
|
||||
in: module_path('Category', 'Filament/Admin/Resources'),
|
||||
for: 'Modules\\Category\\Filament\\Admin\\Resources',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\Category\Filament\Admin\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -11,10 +11,10 @@ use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Admin\Support\Filament\ResourceTableColumns;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||
use UnitEnum;
|
||||
|
||||
class CategoryResource extends Resource
|
||||
@ -1,8 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
||||
|
||||
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
||||
use Modules\Category\Filament\Admin\Resources\CategoryResource;
|
||||
|
||||
class CreateCategory extends CreateRecord
|
||||
{
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Category\Filament\Admin\Resources\CategoryResource;
|
||||
|
||||
class EditCategory extends EditRecord
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
||||
|
||||
namespace Modules\Category\Filament\Admin\Resources\CategoryResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Livewire\Attributes\Url;
|
||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
||||
use Modules\Category\Filament\Admin\Resources\CategoryResource;
|
||||
use Modules\Category\Models\Category;
|
||||
|
||||
class ListCategories extends ListRecords
|
||||
@ -1,7 +1,8 @@
|
||||
<?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;
|
||||
|
||||
class ListCategoryActivities extends ListActivities
|
||||
@ -15,5 +15,6 @@ class CategoryServiceProvider extends ServiceProvider
|
||||
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category');
|
||||
}
|
||||
|
||||
public function register(): void {}
|
||||
public function register(): void
|
||||
{}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Conversation\App\Events\ConversationReadUpdated;
|
||||
use Modules\Conversation\App\Events\InboxMessageCreated;
|
||||
@ -14,7 +13,6 @@ use Modules\Conversation\App\Models\Conversation;
|
||||
use Modules\Conversation\App\Models\ConversationMessage;
|
||||
use Modules\Conversation\App\Support\QuickMessageCatalog;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Throwable;
|
||||
|
||||
class ConversationController extends Controller
|
||||
{
|
||||
@ -28,28 +26,23 @@ class ConversationController extends Controller
|
||||
$conversations = collect();
|
||||
$selectedConversation = null;
|
||||
|
||||
if ($userId && $this->messagingTablesReady()) {
|
||||
try {
|
||||
[
|
||||
'conversations' => $conversations,
|
||||
'selectedConversation' => $selectedConversation,
|
||||
'markedRead' => $markedRead,
|
||||
] = $this->resolveInboxState(
|
||||
$userId,
|
||||
$messageFilter,
|
||||
$request->integer('conversation'),
|
||||
true,
|
||||
);
|
||||
if ($userId) {
|
||||
[
|
||||
'conversations' => $conversations,
|
||||
'selectedConversation' => $selectedConversation,
|
||||
'markedRead' => $markedRead,
|
||||
] = $this->resolveInboxState(
|
||||
$userId,
|
||||
$messageFilter,
|
||||
$request->integer('conversation'),
|
||||
true,
|
||||
);
|
||||
|
||||
if ($selectedConversation && $markedRead) {
|
||||
broadcast(new ConversationReadUpdated(
|
||||
$userId,
|
||||
$selectedConversation->readPayloadFor($userId),
|
||||
));
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$conversations = collect();
|
||||
$selectedConversation = null;
|
||||
if ($selectedConversation && $markedRead) {
|
||||
broadcast(new ConversationReadUpdated(
|
||||
$userId,
|
||||
$selectedConversation->readPayloadFor($userId),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,8 +57,6 @@ class ConversationController extends Controller
|
||||
|
||||
public function state(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
|
||||
|
||||
$userId = (int) $request->user()->getKey();
|
||||
$messageFilter = $this->resolveMessageFilter($request);
|
||||
|
||||
@ -91,14 +82,6 @@ class ConversationController extends Controller
|
||||
|
||||
public function start(Request $request, Listing $listing): RedirectResponse | JsonResponse
|
||||
{
|
||||
if (! $this->messagingTablesReady()) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Messaging is not available yet.'], 503);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Messaging is not available yet.');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $listing->user_id) {
|
||||
@ -124,8 +107,7 @@ class ConversationController extends Controller
|
||||
}
|
||||
|
||||
$conversation = Conversation::openForListingBuyer($listing, (int) $user->getKey());
|
||||
|
||||
$user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
|
||||
$user->rememberListing($listing);
|
||||
|
||||
$message = null;
|
||||
if ($messageBody !== '') {
|
||||
@ -144,14 +126,6 @@ class ConversationController extends Controller
|
||||
|
||||
public function send(Request $request, Conversation $conversation): RedirectResponse | JsonResponse
|
||||
{
|
||||
if (! $this->messagingTablesReady()) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Messaging is not available yet.'], 503);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Messaging is not available yet.');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$userId = (int) $user->getKey();
|
||||
|
||||
@ -187,8 +161,6 @@ class ConversationController extends Controller
|
||||
|
||||
public function read(Request $request, Conversation $conversation): JsonResponse
|
||||
{
|
||||
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
|
||||
|
||||
$userId = (int) $request->user()->getKey();
|
||||
abort_unless($conversation->hasParticipant($userId), 403);
|
||||
|
||||
@ -310,12 +282,4 @@ class ConversationController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
private function messagingTablesReady(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,6 +284,42 @@ class Conversation extends Model
|
||||
return is_null($value) ? null : (int) $value;
|
||||
}
|
||||
|
||||
public static function detailForBuyerListing(int $listingId, int $buyerId): ?self
|
||||
{
|
||||
$conversationId = static::buyerListingConversationId($listingId, $buyerId);
|
||||
|
||||
if (! $conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$conversation = static::query()
|
||||
->forUser($buyerId)
|
||||
->find($conversationId);
|
||||
|
||||
if (! $conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$conversation->loadThread();
|
||||
$conversation->loadCount([
|
||||
'messages as unread_count' => fn (Builder $query) => $query
|
||||
->where('sender_id', '!=', $buyerId)
|
||||
->whereNull('read_at'),
|
||||
]);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
public static function listingMapForBuyer(int $buyerId, array $listingIds = []): array
|
||||
{
|
||||
return static::query()
|
||||
->where('buyer_id', $buyerId)
|
||||
->when($listingIds !== [], fn (Builder $query): Builder => $query->whereIn('listing_id', $listingIds))
|
||||
->pluck('id', 'listing_id')
|
||||
->map(fn ($conversationId): int => (int) $conversationId)
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function unreadCountForUser(int $userId): int
|
||||
{
|
||||
return (int) ConversationMessage::query()
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Modules\Conversation\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Conversation\App\Models\Conversation;
|
||||
use Modules\Conversation\App\Models\ConversationMessage;
|
||||
use Modules\Listing\Models\Listing;
|
||||
@ -14,10 +13,6 @@ class ConversationDemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->conversationTablesExist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->whereIn('email', DemoUserCatalog::emails())
|
||||
->orderBy('email')
|
||||
@ -73,11 +68,6 @@ class ConversationDemoSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
private function conversationTablesExist(): bool
|
||||
{
|
||||
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
|
||||
}
|
||||
|
||||
private function seedConversationThread(
|
||||
User $seller,
|
||||
User $buyer,
|
||||
|
||||
@ -8,34 +8,30 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('conversations')) {
|
||||
Schema::create('conversations', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
|
||||
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamp('last_message_at')->nullable();
|
||||
$table->timestamps();
|
||||
Schema::create('conversations', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
|
||||
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamp('last_message_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['listing_id', 'buyer_id']);
|
||||
$table->index(['seller_id', 'last_message_at']);
|
||||
$table->index(['buyer_id', 'last_message_at']);
|
||||
});
|
||||
}
|
||||
$table->unique(['listing_id', 'buyer_id']);
|
||||
$table->index(['seller_id', 'last_message_at']);
|
||||
$table->index(['buyer_id', 'last_message_at']);
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('conversation_messages')) {
|
||||
Schema::create('conversation_messages', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
|
||||
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->text('body');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
Schema::create('conversation_messages', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
|
||||
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->text('body');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['conversation_id', 'created_at']);
|
||||
$table->index(['conversation_id', 'read_at']);
|
||||
});
|
||||
}
|
||||
$table->index(['conversation_id', 'created_at']);
|
||||
$table->index(['conversation_id', 'read_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
||||
@ -5,14 +5,12 @@ namespace Modules\Favorite\App\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Conversation\App\Models\Conversation;
|
||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Modules\User\App\Models\User;
|
||||
use Modules\User\App\Support\AuthRedirector;
|
||||
use Throwable;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
@ -40,13 +38,7 @@ class FavoriteController extends Controller
|
||||
$user = $request->user();
|
||||
$requiresLogin = ! $user;
|
||||
|
||||
$categories = collect();
|
||||
if ($this->tableExists('categories')) {
|
||||
$categories = Category::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
}
|
||||
$categories = Category::filterOptions();
|
||||
|
||||
$favoriteListings = $this->emptyPaginator();
|
||||
$favoriteSearches = $this->emptyPaginator();
|
||||
@ -54,64 +46,22 @@ class FavoriteController extends Controller
|
||||
$buyerConversationListingMap = [];
|
||||
|
||||
if ($user && $activeTab === 'listings') {
|
||||
try {
|
||||
if ($this->tableExists('favorite_listings')) {
|
||||
$favoriteListings = $user->favoriteListings()
|
||||
->with(['category:id,name', 'user:id,name'])
|
||||
->wherePivot('created_at', '>=', now()->subYear())
|
||||
->when($statusFilter === 'active', fn ($query) => $query->where('status', 'active'))
|
||||
->when($selectedCategoryId, fn ($query) => $query->where('category_id', $selectedCategoryId))
|
||||
->orderByPivot('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
$favoriteListings = $user->favoriteListingsPage($statusFilter, $selectedCategoryId);
|
||||
|
||||
if (
|
||||
$favoriteListings->isNotEmpty()
|
||||
&& $this->tableExists('conversations')
|
||||
) {
|
||||
$userId = (int) $user->getKey();
|
||||
$buyerConversationListingMap = Conversation::query()
|
||||
->where('buyer_id', $userId)
|
||||
->whereIn('listing_id', $favoriteListings->pluck('id')->all())
|
||||
->pluck('id', 'listing_id')
|
||||
->map(fn ($conversationId) => (int) $conversationId)
|
||||
->all();
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$favoriteListings = $this->emptyPaginator();
|
||||
$buyerConversationListingMap = [];
|
||||
if ($favoriteListings->isNotEmpty()) {
|
||||
$buyerConversationListingMap = Conversation::listingMapForBuyer(
|
||||
(int) $user->getKey(),
|
||||
$favoriteListings->pluck('id')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($user && $activeTab === 'searches') {
|
||||
try {
|
||||
if ($this->tableExists('favorite_searches')) {
|
||||
$favoriteSearches = $user->favoriteSearches()
|
||||
->with('category:id,name')
|
||||
->latest()
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$favoriteSearches = $this->emptyPaginator();
|
||||
}
|
||||
$favoriteSearches = $user->favoriteSearchesPage();
|
||||
}
|
||||
|
||||
if ($user && $activeTab === 'sellers') {
|
||||
try {
|
||||
if ($this->tableExists('favorite_sellers')) {
|
||||
$favoriteSellers = $user->favoriteSellers()
|
||||
->withCount([
|
||||
'listings as active_listings_count' => fn ($query) => $query->where('status', 'active'),
|
||||
])
|
||||
->orderByPivot('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$favoriteSellers = $this->emptyPaginator();
|
||||
}
|
||||
$favoriteSellers = $user->favoriteSellersPage();
|
||||
}
|
||||
|
||||
return view('favorite::index', [
|
||||
@ -163,24 +113,7 @@ class FavoriteController extends Controller
|
||||
return back()->with('error', 'Select at least one filter before saving a search.');
|
||||
}
|
||||
|
||||
$signature = FavoriteSearch::signatureFor($filters);
|
||||
|
||||
$categoryName = null;
|
||||
if (isset($filters['category'])) {
|
||||
$categoryName = Category::query()->whereKey($filters['category'])->value('name');
|
||||
}
|
||||
|
||||
$label = FavoriteSearch::labelFor($filters, is_string($categoryName) ? $categoryName : null);
|
||||
|
||||
$favoriteSearch = $request->user()->favoriteSearches()->firstOrCreate(
|
||||
['signature' => $signature],
|
||||
[
|
||||
'label' => $label,
|
||||
'search_term' => $filters['search'] ?? null,
|
||||
'category_id' => $filters['category'] ?? null,
|
||||
'filters' => $filters,
|
||||
]
|
||||
);
|
||||
$favoriteSearch = FavoriteSearch::storeForUser($request->user(), $filters);
|
||||
|
||||
if (! $favoriteSearch->wasRecentlyCreated) {
|
||||
return back()->with('success', 'This search is already in your favorites.');
|
||||
@ -200,15 +133,6 @@ class FavoriteController extends Controller
|
||||
return back()->with('success', 'Saved search deleted.');
|
||||
}
|
||||
|
||||
private function tableExists(string $table): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable($table);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function emptyPaginator(): LengthAwarePaginator
|
||||
{
|
||||
return new LengthAwarePaginator([], 0, 10, 1, [
|
||||
|
||||
@ -53,4 +53,36 @@ class FavoriteSearch extends Model
|
||||
|
||||
return $labelParts !== [] ? implode(' · ', $labelParts) : 'Filtered search';
|
||||
}
|
||||
|
||||
public static function isSavedForUser(User $user, array $filters): bool
|
||||
{
|
||||
$normalized = static::normalizeFilters($filters);
|
||||
|
||||
if ($normalized === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->favoriteSearches()
|
||||
->where('signature', static::signatureFor($normalized))
|
||||
->exists();
|
||||
}
|
||||
|
||||
public static function storeForUser(User $user, array $filters): self
|
||||
{
|
||||
$normalized = static::normalizeFilters($filters);
|
||||
$signature = static::signatureFor($normalized);
|
||||
$categoryName = isset($normalized['category'])
|
||||
? Category::query()->whereKey($normalized['category'])->value('name')
|
||||
: null;
|
||||
|
||||
return $user->favoriteSearches()->firstOrCreate(
|
||||
['signature' => $signature],
|
||||
[
|
||||
'label' => static::labelFor($normalized, is_string($categoryName) ? $categoryName : null),
|
||||
'search_term' => $normalized['search'] ?? null,
|
||||
'category_id' => $normalized['category'] ?? null,
|
||||
'filters' => $normalized,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@ namespace Modules\Favorite\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
||||
use Modules\Listing\Models\Listing;
|
||||
@ -16,10 +14,6 @@ class FavoriteDemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->favoriteTablesExist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->whereIn('email', DemoUserCatalog::emails())
|
||||
->orderBy('email')
|
||||
@ -30,8 +24,11 @@ class FavoriteDemoSeeder extends Seeder
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete();
|
||||
DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete();
|
||||
$users->each(function (User $user): void {
|
||||
$user->favoriteListings()->detach();
|
||||
$user->favoriteSellers()->detach();
|
||||
});
|
||||
|
||||
FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
|
||||
|
||||
foreach ($users as $index => $user) {
|
||||
@ -56,38 +53,25 @@ class FavoriteDemoSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
private function favoriteTablesExist(): bool
|
||||
{
|
||||
return Schema::hasTable('favorite_listings')
|
||||
&& Schema::hasTable('favorite_sellers')
|
||||
&& Schema::hasTable('favorite_searches');
|
||||
}
|
||||
|
||||
private function seedFavoriteListings(User $user, Collection $listings): void
|
||||
{
|
||||
$rows = $listings
|
||||
$payload = $listings
|
||||
->values()
|
||||
->map(function (Listing $listing, int $index) use ($user): array {
|
||||
->mapWithKeys(function (Listing $listing, int $index): array {
|
||||
$timestamp = now()->subHours(8 + ($index * 3));
|
||||
|
||||
return [
|
||||
'user_id' => $user->getKey(),
|
||||
'listing_id' => $listing->getKey(),
|
||||
return [$listing->getKey() => [
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
];
|
||||
]];
|
||||
})
|
||||
->all();
|
||||
|
||||
if ($rows === []) {
|
||||
if ($payload === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('favorite_listings')->upsert(
|
||||
$rows,
|
||||
['user_id', 'listing_id'],
|
||||
['updated_at']
|
||||
);
|
||||
$user->favoriteListings()->syncWithoutDetaching($payload);
|
||||
}
|
||||
|
||||
private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void
|
||||
@ -96,16 +80,12 @@ class FavoriteDemoSeeder extends Seeder
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('favorite_sellers')->upsert(
|
||||
[[
|
||||
'user_id' => $user->getKey(),
|
||||
'seller_id' => $seller->getKey(),
|
||||
$user->favoriteSellers()->syncWithoutDetaching([
|
||||
$seller->getKey() => [
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]],
|
||||
['user_id', 'seller_id'],
|
||||
['updated_at']
|
||||
);
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedFavoriteSearches(User $user, array $payloads): void
|
||||
|
||||
@ -8,42 +8,36 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('favorite_listings')) {
|
||||
Schema::create('favorite_listings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
Schema::create('favorite_listings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'listing_id']);
|
||||
});
|
||||
}
|
||||
$table->unique(['user_id', 'listing_id']);
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('favorite_sellers')) {
|
||||
Schema::create('favorite_sellers', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
Schema::create('favorite_sellers', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'seller_id']);
|
||||
});
|
||||
}
|
||||
$table->unique(['user_id', 'seller_id']);
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('favorite_searches')) {
|
||||
Schema::create('favorite_searches', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('label')->nullable();
|
||||
$table->string('search_term')->nullable();
|
||||
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
|
||||
$table->json('filters')->nullable();
|
||||
$table->string('signature', 64);
|
||||
$table->timestamps();
|
||||
Schema::create('favorite_searches', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('label')->nullable();
|
||||
$table->string('search_term')->nullable();
|
||||
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
|
||||
$table->json('filters')->nullable();
|
||||
$table->string('signature', 64);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'signature']);
|
||||
});
|
||||
}
|
||||
$table->unique(['user_id', 'signature']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
||||
@ -4,7 +4,6 @@ namespace Modules\Listing\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Models\Listing;
|
||||
@ -107,10 +106,6 @@ class ListingSeeder extends Seeder
|
||||
|
||||
private function resolveCountries(): Collection
|
||||
{
|
||||
if (! class_exists(Country::class) || ! Schema::hasTable('countries')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Country::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
@ -120,10 +115,6 @@ class ListingSeeder extends Seeder
|
||||
|
||||
private function resolveTurkeyCities(): Collection
|
||||
{
|
||||
if (! class_exists(City::class) || ! Schema::hasTable('cities') || ! Schema::hasTable('countries')) {
|
||||
return collect(['Istanbul', 'Ankara', 'Izmir', 'Bursa', 'Antalya']);
|
||||
}
|
||||
|
||||
$turkey = Country::query()
|
||||
->where('code', 'TR')
|
||||
->first(['id']);
|
||||
|
||||
@ -29,4 +29,9 @@ return new class extends Migration
|
||||
$table->nullableTimestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('media');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\Listing\Filament\Admin\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -13,9 +13,9 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||
use Modules\Listing\Models\ListingCustomField;
|
||||
use UnitEnum;
|
||||
|
||||
@ -37,21 +37,7 @@ class ListingCustomFieldResource extends Resource
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void {
|
||||
$baseName = \Illuminate\Support\Str::slug((string) $state, '_');
|
||||
$baseName = $baseName !== '' ? $baseName : 'custom_field';
|
||||
|
||||
$name = $baseName;
|
||||
$counter = 1;
|
||||
|
||||
while (ListingCustomField::query()
|
||||
->where('name', $name)
|
||||
->when($record, fn ($query) => $query->whereKeyNot($record->getKey()))
|
||||
->exists()) {
|
||||
$name = "{$baseName}_{$counter}";
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$set('name', $name);
|
||||
$set('name', ListingCustomField::uniqueNameFromLabel((string) $state, $record));
|
||||
}),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
||||
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
|
||||
|
||||
class CreateListingCustomField extends CreateRecord
|
||||
{
|
||||
@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
||||
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
|
||||
|
||||
class EditListingCustomField extends EditRecord
|
||||
{
|
||||
@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
|
||||
namespace Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Admin\Filament\Resources\ListingCustomFieldResource;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingCustomFieldResource;
|
||||
|
||||
class ListListingCustomFields extends ListRecords
|
||||
{
|
||||
@ -1,12 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\Listing\Filament\Admin\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
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\Support\Filament\AdminListingResourceSchema;
|
||||
use UnitEnum;
|
||||
@ -1,8 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
|
||||
|
||||
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\ListingResource;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingResource;
|
||||
|
||||
class CreateListing extends CreateRecord
|
||||
{
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingResource;
|
||||
|
||||
class EditListing extends EditRecord
|
||||
{
|
||||
protected static string $resource = ListingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
class ListListingActivities extends ListActivities
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\Filament\Admin\Resources\ListingResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Listing\Filament\Admin\Resources\ListingResource;
|
||||
|
||||
class ListListings extends ListRecords
|
||||
{
|
||||
protected static string $resource = ListingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Widgets;
|
||||
|
||||
namespace Modules\Listing\Filament\Admin\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
@ -13,31 +14,27 @@ class ListingOverview extends StatsOverviewWidget
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalListings = Listing::query()->count();
|
||||
$activeListings = Listing::query()->where('status', 'active')->count();
|
||||
$pendingListings = Listing::query()->where('status', 'pending')->count();
|
||||
$featuredListings = Listing::query()->where('is_featured', true)->count();
|
||||
$createdToday = Listing::query()->where('created_at', '>=', now()->startOfDay())->count();
|
||||
$stats = Listing::overviewStats();
|
||||
|
||||
$featuredRatio = $totalListings > 0
|
||||
? number_format(($featuredListings / $totalListings) * 100, 1).'% of all listings'
|
||||
$featuredRatio = $stats['total'] > 0
|
||||
? number_format(($stats['featured'] / $stats['total']) * 100, 1).'% of all listings'
|
||||
: '0.0% of all listings';
|
||||
|
||||
return [
|
||||
Stat::make('Total Listings', number_format($totalListings))
|
||||
Stat::make('Total Listings', number_format($stats['total']))
|
||||
->description('All listings in the system')
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->color('primary'),
|
||||
Stat::make('Active Listings', number_format($activeListings))
|
||||
->description(number_format($pendingListings).' pending review')
|
||||
Stat::make('Active Listings', number_format($stats['active']))
|
||||
->description(number_format($stats['pending']).' pending review')
|
||||
->descriptionIcon('heroicon-o-clock')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success'),
|
||||
Stat::make('Created Today', number_format($createdToday))
|
||||
Stat::make('Created Today', number_format($stats['created_today']))
|
||||
->description('New listings added today')
|
||||
->icon('heroicon-o-calendar-days')
|
||||
->color('info'),
|
||||
Stat::make('Featured Listings', number_format($featuredListings))
|
||||
Stat::make('Featured Listings', number_format($stats['featured']))
|
||||
->description($featuredRatio)
|
||||
->icon('heroicon-o-star')
|
||||
->color('warning'),
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Widgets;
|
||||
namespace Modules\Listing\Filament\Admin\Widgets;
|
||||
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Modules\Listing\Models\Listing;
|
||||
@ -27,39 +27,20 @@ class ListingsTrendChart extends ChartWidget
|
||||
protected function getData(): array
|
||||
{
|
||||
$days = (int) ($this->filter ?? '30');
|
||||
$startDate = now()->startOfDay()->subDays($days - 1);
|
||||
|
||||
$countsByDate = Listing::query()
|
||||
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
|
||||
->where('created_at', '>=', $startDate)
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->pluck('total', 'day')
|
||||
->all();
|
||||
|
||||
$labels = [];
|
||||
$data = [];
|
||||
|
||||
for ($index = 0; $index < $days; $index++) {
|
||||
$date = $startDate->copy()->addDays($index);
|
||||
$dateKey = $date->toDateString();
|
||||
|
||||
$labels[] = $date->format('M j');
|
||||
$data[] = (int) ($countsByDate[$dateKey] ?? 0);
|
||||
}
|
||||
$trend = Listing::creationTrend($days);
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Listings',
|
||||
'data' => $data,
|
||||
'data' => $trend['data'],
|
||||
'fill' => true,
|
||||
'borderColor' => '#2563eb',
|
||||
'backgroundColor' => 'rgba(37, 99, 235, 0.12)',
|
||||
'tension' => 0.35,
|
||||
],
|
||||
],
|
||||
'labels' => $labels,
|
||||
'labels' => $trend['labels'],
|
||||
];
|
||||
}
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Conversation\App\Models\Conversation;
|
||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Location\Models\City;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\Theme\Support\ThemeManager;
|
||||
use Throwable;
|
||||
|
||||
class ListingController extends Controller
|
||||
{
|
||||
@ -53,19 +50,13 @@ class ListingController extends Controller
|
||||
$sort = 'smart';
|
||||
}
|
||||
|
||||
$countries = collect();
|
||||
$cities = collect();
|
||||
$selectedCountryName = null;
|
||||
$selectedCityName = null;
|
||||
|
||||
$this->resolveLocationFilters(
|
||||
$countryId,
|
||||
$cityId,
|
||||
$countries,
|
||||
$cities,
|
||||
$selectedCountryName,
|
||||
$selectedCityName
|
||||
);
|
||||
$locationSelection = Country::browseSelection($countryId, $cityId);
|
||||
$countryId = $locationSelection['country_id'];
|
||||
$cityId = $locationSelection['city_id'];
|
||||
$countries = $locationSelection['countries'];
|
||||
$cities = $locationSelection['cities'];
|
||||
$selectedCountryName = $locationSelection['selected_country_name'];
|
||||
$selectedCityName = $locationSelection['selected_city_name'];
|
||||
|
||||
$listingDirectory = Category::listingDirectory($categoryId);
|
||||
|
||||
@ -109,29 +100,13 @@ class ListingController extends Controller
|
||||
if (auth()->check()) {
|
||||
$userId = (int) auth()->id();
|
||||
|
||||
$favoriteListingIds = auth()->user()
|
||||
->favoriteListings()
|
||||
->pluck('listings.id')
|
||||
->all();
|
||||
$favoriteListingIds = auth()->user()->favoriteListingIds();
|
||||
$conversationListingMap = Conversation::listingMapForBuyer($userId);
|
||||
|
||||
$conversationListingMap = Conversation::query()
|
||||
->where('buyer_id', $userId)
|
||||
->pluck('id', 'listing_id')
|
||||
->map(fn ($conversationId) => (int) $conversationId)
|
||||
->all();
|
||||
|
||||
$filters = FavoriteSearch::normalizeFilters([
|
||||
$isCurrentSearchSaved = FavoriteSearch::isSavedForUser(auth()->user(), [
|
||||
'search' => $search,
|
||||
'category' => $categoryId,
|
||||
]);
|
||||
|
||||
if ($filters !== []) {
|
||||
$signature = FavoriteSearch::signatureFor($filters);
|
||||
$isCurrentSearchSaved = auth()->user()
|
||||
->favoriteSearches()
|
||||
->where('signature', $signature)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
return view($this->themes->view('listing', 'index'), compact(
|
||||
@ -159,13 +134,7 @@ class ListingController extends Controller
|
||||
|
||||
public function show(Listing $listing)
|
||||
{
|
||||
if (
|
||||
Schema::hasColumn('listings', 'view_count')
|
||||
&& (! auth()->check() || (int) auth()->id() !== (int) $listing->user_id)
|
||||
) {
|
||||
$listing->increment('view_count');
|
||||
$listing->refresh();
|
||||
}
|
||||
$listing->trackViewBy(auth()->id());
|
||||
|
||||
$listing->loadMissing([
|
||||
'user:id,name,email',
|
||||
@ -193,10 +162,7 @@ class ListingController extends Controller
|
||||
if (auth()->check()) {
|
||||
$userId = (int) auth()->id();
|
||||
|
||||
$isListingFavorited = auth()->user()
|
||||
->favoriteListings()
|
||||
->whereKey($listing->getKey())
|
||||
->exists();
|
||||
$isListingFavorited = in_array((int) $listing->getKey(), auth()->user()->favoriteListingIds(), true);
|
||||
|
||||
if ($listing->user_id) {
|
||||
$isSellerFavorited = auth()->user()
|
||||
@ -206,25 +172,10 @@ class ListingController extends Controller
|
||||
}
|
||||
|
||||
if ($listing->user_id && (int) $listing->user_id !== $userId) {
|
||||
$existingConversationId = Conversation::buyerListingConversationId(
|
||||
$detailConversation = Conversation::detailForBuyerListing(
|
||||
(int) $listing->getKey(),
|
||||
$userId,
|
||||
);
|
||||
|
||||
if ($existingConversationId) {
|
||||
$detailConversation = Conversation::query()
|
||||
->forUser($userId)
|
||||
->find($existingConversationId);
|
||||
|
||||
if ($detailConversation) {
|
||||
$detailConversation->loadThread();
|
||||
$detailConversation->loadCount([
|
||||
'messages as unread_count' => fn ($query) => $query
|
||||
->where('sender_id', '!=', $userId)
|
||||
->whereNull('read_at'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,81 +212,4 @@ class ListingController extends Controller
|
||||
->route('panel.listings.create')
|
||||
->with('success', 'You were redirected to the listing creation screen.');
|
||||
}
|
||||
|
||||
private function resolveLocationFilters(
|
||||
?int &$countryId,
|
||||
?int &$cityId,
|
||||
Collection &$countries,
|
||||
Collection &$cities,
|
||||
?string &$selectedCountryName,
|
||||
?string &$selectedCityName
|
||||
): void {
|
||||
try {
|
||||
if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$countries = Country::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
$selectedCountry = $countryId
|
||||
? $countries->firstWhere('id', $countryId)
|
||||
: null;
|
||||
|
||||
if (! $selectedCountry && $countryId) {
|
||||
$selectedCountry = Country::query()->whereKey($countryId)->first(['id', 'name']);
|
||||
}
|
||||
|
||||
$selectedCity = null;
|
||||
if ($cityId) {
|
||||
$selectedCity = City::query()->whereKey($cityId)->first(['id', 'name', 'country_id']);
|
||||
if (! $selectedCity) {
|
||||
$cityId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedCity && ! $selectedCountry) {
|
||||
$countryId = (int) $selectedCity->country_id;
|
||||
$selectedCountry = Country::query()->whereKey($countryId)->first(['id', 'name']);
|
||||
}
|
||||
|
||||
if ($selectedCountry) {
|
||||
$selectedCountryName = (string) $selectedCountry->name;
|
||||
$cities = City::query()
|
||||
->where('country_id', $selectedCountry->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'country_id']);
|
||||
|
||||
if ($cities->isEmpty()) {
|
||||
$cities = City::query()
|
||||
->where('country_id', $selectedCountry->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'country_id']);
|
||||
}
|
||||
} else {
|
||||
$countryId = null;
|
||||
$cities = collect();
|
||||
}
|
||||
|
||||
if ($selectedCity) {
|
||||
if ($selectedCountry && (int) $selectedCity->country_id !== (int) $selectedCountry->id) {
|
||||
$selectedCity = null;
|
||||
$cityId = null;
|
||||
} else {
|
||||
$selectedCityName = (string) $selectedCity->name;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$countryId = null;
|
||||
$cityId = null;
|
||||
$selectedCountryName = null;
|
||||
$selectedCityName = null;
|
||||
$countries = collect();
|
||||
$cities = collect();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
34
Modules/Listing/ListingPlugin.php
Normal file
34
Modules/Listing/ListingPlugin.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
final class ListingPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'listing';
|
||||
}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: module_path('Listing', 'Filament/Admin/Resources'),
|
||||
for: 'Modules\\Listing\\Filament\\Admin\\Resources',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: module_path('Listing', 'Filament/Admin/Widgets'),
|
||||
for: 'Modules\\Listing\\Filament\\Admin\\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
}
|
||||
@ -319,6 +319,54 @@ class Listing extends Model implements HasMedia
|
||||
->count();
|
||||
}
|
||||
|
||||
public static function overviewStats(): array
|
||||
{
|
||||
$counts = static::query()
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw("SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active")
|
||||
->selectRaw("SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending")
|
||||
->selectRaw('SUM(CASE WHEN is_featured = true THEN 1 ELSE 0 END) as featured')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total' => (int) ($counts?->total ?? 0),
|
||||
'active' => (int) ($counts?->active ?? 0),
|
||||
'pending' => (int) ($counts?->pending ?? 0),
|
||||
'featured' => (int) ($counts?->featured ?? 0),
|
||||
'created_today' => (int) static::query()
|
||||
->where('created_at', '>=', now()->startOfDay())
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function creationTrend(int $days): array
|
||||
{
|
||||
$safeDays = max(1, $days);
|
||||
$startDate = now()->startOfDay()->subDays($safeDays - 1);
|
||||
$countsByDate = static::query()
|
||||
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
|
||||
->where('created_at', '>=', $startDate)
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->pluck('total', 'day')
|
||||
->all();
|
||||
$labels = [];
|
||||
$data = [];
|
||||
|
||||
for ($index = 0; $index < $safeDays; $index++) {
|
||||
$date = $startDate->copy()->addDays($index);
|
||||
$dateKey = $date->toDateString();
|
||||
|
||||
$labels[] = $date->format('M j');
|
||||
$data[] = (int) ($countsByDate[$dateKey] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'labels' => $labels,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public static function homeFeatured(int $limit = 4): Collection
|
||||
{
|
||||
return static::query()
|
||||
@ -528,6 +576,16 @@ class Listing extends Model implements HasMedia
|
||||
abort_unless((int) $this->user_id === (int) $user->getKey(), 403);
|
||||
}
|
||||
|
||||
public function trackViewBy(null|int|string $viewerId): void
|
||||
{
|
||||
if ((int) $this->user_id === (int) $viewerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->increment('view_count');
|
||||
$this->refresh();
|
||||
}
|
||||
|
||||
public function markAsSold(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
|
||||
@ -4,6 +4,7 @@ namespace Modules\Listing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Category\Models\Category;
|
||||
|
||||
class ListingCustomField extends Model
|
||||
@ -88,6 +89,24 @@ class ListingCustomField extends Model
|
||||
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
|
||||
}
|
||||
|
||||
public static function uniqueNameFromLabel(string $label, ?self $record = null): string
|
||||
{
|
||||
$baseName = Str::slug($label, '_');
|
||||
$baseName = $baseName !== '' ? $baseName : 'custom_field';
|
||||
$name = $baseName;
|
||||
$counter = 1;
|
||||
|
||||
while (static::query()
|
||||
->where('name', $name)
|
||||
->when($record, fn (Builder $query): Builder => $query->whereKeyNot($record->getKey()))
|
||||
->exists()) {
|
||||
$name = "{$baseName}_{$counter}";
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
public static function upsertSeeded(Category $category, array $attributes): self
|
||||
{
|
||||
return static::query()->updateOrCreate(
|
||||
|
||||
@ -17,5 +17,6 @@ class ListingServiceProvider extends ServiceProvider
|
||||
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
|
||||
}
|
||||
|
||||
public function register(): void {}
|
||||
public function register(): void
|
||||
{}
|
||||
}
|
||||
|
||||
@ -381,196 +381,3 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const countrySelect = document.querySelector('[data-listing-country]');
|
||||
const citySelect = document.querySelector('[data-listing-city]');
|
||||
const currentLocationButton = document.querySelector('[data-use-current-location]');
|
||||
const filterDrawer = document.querySelector('[data-listing-filter-drawer]');
|
||||
const filterOpenButtons = Array.from(document.querySelectorAll('[data-listing-filter-open]'));
|
||||
const filterCloseButtons = Array.from(document.querySelectorAll('[data-listing-filter-close]'));
|
||||
const citiesTemplate = countrySelect?.dataset.citiesUrlTemplate ?? '';
|
||||
const locationStorageKey = 'oc2.header.location';
|
||||
const drawerMediaQuery = window.matchMedia('(max-width: 1023px)');
|
||||
|
||||
const setDrawerExpanded = (expanded) => {
|
||||
filterOpenButtons.forEach((button) => button.setAttribute('aria-expanded', expanded ? 'true' : 'false'));
|
||||
};
|
||||
|
||||
const closeFilterDrawer = () => {
|
||||
if (!filterDrawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterDrawer.classList.remove('is-open');
|
||||
filterDrawer.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('listing-filters-open');
|
||||
setDrawerExpanded(false);
|
||||
};
|
||||
|
||||
const openFilterDrawer = () => {
|
||||
if (!filterDrawer || !drawerMediaQuery.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterDrawer.classList.add('is-open');
|
||||
filterDrawer.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('listing-filters-open');
|
||||
setDrawerExpanded(true);
|
||||
};
|
||||
|
||||
filterOpenButtons.forEach((button) => button.addEventListener('click', openFilterDrawer));
|
||||
filterCloseButtons.forEach((button) => button.addEventListener('click', closeFilterDrawer));
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (!drawerMediaQuery.matches) {
|
||||
closeFilterDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeFilterDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
if (drawerMediaQuery.matches) {
|
||||
closeFilterDrawer();
|
||||
} else if (filterDrawer) {
|
||||
filterDrawer.setAttribute('aria-hidden', 'false');
|
||||
setDrawerExpanded(false);
|
||||
}
|
||||
|
||||
if (!countrySelect || !citySelect || citiesTemplate === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalize = (value) => (value ?? '')
|
||||
.toString()
|
||||
.toLocaleLowerCase('tr-TR')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim();
|
||||
|
||||
const setCityOptions = (cities, selectedCityName = '') => {
|
||||
citySelect.innerHTML = '<option value="">Select city</option>';
|
||||
cities.forEach((city) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(city.id ?? '');
|
||||
option.textContent = city.name ?? '';
|
||||
option.dataset.name = city.name ?? '';
|
||||
citySelect.appendChild(option);
|
||||
});
|
||||
citySelect.disabled = false;
|
||||
|
||||
if (selectedCityName) {
|
||||
const matched = Array.from(citySelect.options).find((option) => normalize(option.dataset.name) === normalize(selectedCityName));
|
||||
if (matched) {
|
||||
citySelect.value = matched.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCityOptions = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('city_fetch_failed');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
};
|
||||
|
||||
const loadCities = async (countryId, selectedCityName = '') => {
|
||||
if (!countryId) {
|
||||
citySelect.innerHTML = '<option value="">Select country first</option>';
|
||||
citySelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
citySelect.disabled = true;
|
||||
citySelect.innerHTML = '<option value="">Loading cities...</option>';
|
||||
|
||||
const primaryUrl = citiesTemplate.replace('__COUNTRY__', encodeURIComponent(String(countryId)));
|
||||
|
||||
try {
|
||||
let cities = [];
|
||||
|
||||
try {
|
||||
cities = await fetchCityOptions(primaryUrl);
|
||||
} catch (primaryError) {
|
||||
if (!/^https?:\/\//i.test(primaryUrl)) {
|
||||
throw primaryError;
|
||||
}
|
||||
|
||||
let fallbackUrl = null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(primaryUrl);
|
||||
fallbackUrl = `${parsed.pathname}${parsed.search}`;
|
||||
} catch (urlError) {
|
||||
fallbackUrl = null;
|
||||
}
|
||||
|
||||
if (!fallbackUrl) {
|
||||
throw primaryError;
|
||||
}
|
||||
|
||||
cities = await fetchCityOptions(fallbackUrl);
|
||||
}
|
||||
|
||||
setCityOptions(cities, selectedCityName);
|
||||
} catch (error) {
|
||||
citySelect.innerHTML = '<option value="">Cities could not be loaded</option>';
|
||||
citySelect.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
countrySelect.addEventListener('change', () => {
|
||||
citySelect.value = '';
|
||||
void loadCities(countrySelect.value);
|
||||
});
|
||||
|
||||
currentLocationButton?.addEventListener('click', async () => {
|
||||
try {
|
||||
const rawLocation = localStorage.getItem(locationStorageKey);
|
||||
if (!rawLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedLocation = JSON.parse(rawLocation);
|
||||
const countryName = parsedLocation?.countryName ?? '';
|
||||
const cityName = parsedLocation?.cityName ?? '';
|
||||
const countryId = parsedLocation?.countryId ? String(parsedLocation.countryId) : null;
|
||||
|
||||
const matchedCountryOption = Array.from(countrySelect.options).find((option) => {
|
||||
if (countryId && option.value === countryId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalize(option.textContent) === normalize(countryName);
|
||||
});
|
||||
|
||||
if (!matchedCountryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
countrySelect.value = matchedCountryOption.value;
|
||||
await loadCities(matchedCountryOption.value, cityName);
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\Location\Filament\Admin\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -13,9 +13,9 @@ use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Admin\Filament\Resources\CityResource\Pages;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Admin\Support\Filament\ResourceTableColumns;
|
||||
use Modules\Location\Filament\Admin\Resources\CityResource\Pages;
|
||||
use Modules\Location\Models\City;
|
||||
use UnitEnum;
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\CityResource;
|
||||
use Modules\Location\Filament\Admin\Resources\CityResource;
|
||||
|
||||
class CreateCity extends CreateRecord
|
||||
{
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Location\Filament\Admin\Resources\CityResource;
|
||||
|
||||
class EditCity extends EditRecord
|
||||
{
|
||||
protected static string $resource = CityResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CityResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Location\Filament\Admin\Resources\CityResource;
|
||||
|
||||
class ListCities extends ListRecords
|
||||
{
|
||||
protected static string $resource = CityResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
class ListCityActivities extends ListActivities
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\Location\Filament\Admin\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -12,13 +12,13 @@ use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Admin\Support\Filament\ResourceTableColumns;
|
||||
use Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
|
||||
use Modules\Location\Models\Country;
|
||||
use UnitEnum;
|
||||
|
||||
class LocationResource extends Resource
|
||||
class CountryResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Country::class;
|
||||
|
||||
@ -36,7 +36,7 @@ class LocationResource extends Resource
|
||||
{
|
||||
return $schema->schema([
|
||||
TextInput::make('name')->required()->maxLength(100),
|
||||
TextInput::make('code')->required()->maxLength(2)->unique(ignoreRecord: true),
|
||||
TextInput::make('code')->required()->maxLength(3)->unique(ignoreRecord: true),
|
||||
TextInput::make('phone_code')->maxLength(10),
|
||||
Toggle::make('is_active')->default(true),
|
||||
]);
|
||||
@ -70,10 +70,10 @@ class LocationResource extends Resource
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListLocations::route('/'),
|
||||
'create' => Pages\CreateLocation::route('/create'),
|
||||
'activities' => Pages\ListLocationActivities::route('/{record}/activities'),
|
||||
'edit' => Pages\EditLocation::route('/{record}/edit'),
|
||||
'index' => Pages\ListCountries::route('/'),
|
||||
'create' => Pages\CreateCountry::route('/create'),
|
||||
'activities' => Pages\ListCountryActivities::route('/{record}/activities'),
|
||||
'edit' => Pages\EditCountry::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Location\Filament\Admin\Resources\CountryResource;
|
||||
|
||||
class CreateCountry extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CountryResource::class;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Location\Filament\Admin\Resources\CountryResource;
|
||||
|
||||
class EditCountry extends EditRecord
|
||||
{
|
||||
protected static string $resource = CountryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Location\Filament\Admin\Resources\CountryResource;
|
||||
|
||||
class ListCountries extends ListRecords
|
||||
{
|
||||
protected static string $resource = CountryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\CountryResource\Pages;
|
||||
|
||||
use Modules\Location\Filament\Admin\Resources\CountryResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListCountryActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = CountryResource::class;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\Location\Filament\Admin\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -13,9 +13,9 @@ use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Admin\Filament\Resources\DistrictResource\Pages;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Admin\Support\Filament\ResourceTableColumns;
|
||||
use Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\Location\Models\District;
|
||||
use UnitEnum;
|
||||
@ -1,8 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\DistrictResource;
|
||||
use Modules\Location\Filament\Admin\Resources\DistrictResource;
|
||||
|
||||
class CreateDistrict extends CreateRecord
|
||||
{
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Location\Filament\Admin\Resources\DistrictResource;
|
||||
|
||||
class EditDistrict extends EditRecord
|
||||
{
|
||||
protected static string $resource = DistrictResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
class ListDistrictActivities extends ListActivities
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location\Filament\Admin\Resources\DistrictResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Location\Filament\Admin\Resources\DistrictResource;
|
||||
|
||||
class ListDistricts extends ListRecords
|
||||
{
|
||||
protected static string $resource = DistrictResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [CreateAction::make()];
|
||||
}
|
||||
}
|
||||
29
Modules/Location/LocationPlugin.php
Normal file
29
Modules/Location/LocationPlugin.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Location;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
final class LocationPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'location';
|
||||
}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel->discoverResources(
|
||||
in: module_path('Location', 'Filament/Admin/Resources'),
|
||||
for: 'Modules\\Location\\Filament\\Admin\\Resources',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
}
|
||||
@ -131,4 +131,63 @@ class Country extends Model
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function browseSelection(?int $countryId, ?int $cityId): array
|
||||
{
|
||||
$countries = static::query()
|
||||
->active()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
$selectedCountry = $countryId
|
||||
? ($countries->firstWhere('id', $countryId) ?? static::query()->whereKey($countryId)->first(['id', 'name']))
|
||||
: null;
|
||||
$selectedCity = $cityId
|
||||
? City::query()->whereKey($cityId)->first(['id', 'name', 'country_id'])
|
||||
: null;
|
||||
|
||||
if ($selectedCity && ! $selectedCountry) {
|
||||
$countryId = (int) $selectedCity->country_id;
|
||||
$selectedCountry = static::query()->whereKey($countryId)->first(['id', 'name']);
|
||||
}
|
||||
|
||||
$cities = collect();
|
||||
|
||||
if ($selectedCountry) {
|
||||
$countryId = (int) $selectedCountry->getKey();
|
||||
$cities = City::query()
|
||||
->where('country_id', $countryId)
|
||||
->active()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'country_id']);
|
||||
|
||||
if ($cities->isEmpty()) {
|
||||
$cities = City::query()
|
||||
->where('country_id', $countryId)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'country_id']);
|
||||
}
|
||||
} else {
|
||||
$countryId = null;
|
||||
$cityId = null;
|
||||
}
|
||||
|
||||
if ($selectedCity && $countryId && (int) $selectedCity->country_id !== $countryId) {
|
||||
$selectedCity = null;
|
||||
$cityId = null;
|
||||
}
|
||||
|
||||
if ($selectedCity) {
|
||||
$cityId = (int) $selectedCity->getKey();
|
||||
}
|
||||
|
||||
return [
|
||||
'country_id' => $countryId,
|
||||
'city_id' => $cityId,
|
||||
'countries' => $countries,
|
||||
'cities' => $cities,
|
||||
'selected_country_name' => $selectedCountry?->name ? (string) $selectedCountry->name : null,
|
||||
'selected_city_name' => $selectedCity?->name ? (string) $selectedCity->name : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,5 +14,6 @@ class LocationServiceProvider extends ServiceProvider
|
||||
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
|
||||
}
|
||||
|
||||
public function register(): void {}
|
||||
public function register(): void
|
||||
{}
|
||||
}
|
||||
|
||||
@ -7,831 +7,6 @@
|
||||
@endphp
|
||||
|
||||
<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-header">
|
||||
<span class="qc-step-chip">Step {{ $currentStep }} of 5</span>
|
||||
|
||||
@ -18,7 +18,7 @@ class HomeController extends Controller
|
||||
$categoryCount = Category::activeCount();
|
||||
$userCount = User::totalCount();
|
||||
$favoriteListingIds = auth()->check()
|
||||
? auth()->user()->homeFavoriteListingIds()
|
||||
? auth()->user()->favoriteListingIds()
|
||||
: [];
|
||||
|
||||
return view('site::home', compact(
|
||||
|
||||
@ -26,6 +26,7 @@ class SiteServiceProvider extends ServiceProvider
|
||||
{
|
||||
$viewPath = module_path('Site', 'resources/views');
|
||||
|
||||
$this->loadMigrationsFrom(module_path('Site', 'Database/migrations'));
|
||||
$this->loadRoutesFrom(module_path('Site', 'routes/web.php'));
|
||||
$this->loadViewsFrom($viewPath, 'site');
|
||||
View::addNamespace('app', $viewPath);
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Modules\Site\App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Location\Models\Country;
|
||||
@ -68,14 +67,6 @@ final class RequestAppData
|
||||
'apple_client_secret' => $fallbackAppleClientSecret,
|
||||
];
|
||||
|
||||
try {
|
||||
if (! Schema::hasTable('settings')) {
|
||||
return $generalSettings;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return $generalSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
$settings = app(GeneralSettings::class);
|
||||
$currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies);
|
||||
@ -141,15 +132,15 @@ final class RequestAppData
|
||||
'filament-google-maps.keys.server_key' => $mapsKey,
|
||||
'services.google.client_id' => $generalSettings['google_client_id'],
|
||||
'services.google.client_secret' => $generalSettings['google_client_secret'],
|
||||
'services.google.redirect' => url('/oauth/callback/google'),
|
||||
'services.google.redirect' => route('auth.social.callback', ['provider' => 'google'], absolute: true),
|
||||
'services.google.enabled' => (bool) $generalSettings['google_login_enabled'],
|
||||
'services.facebook.client_id' => $generalSettings['facebook_client_id'],
|
||||
'services.facebook.client_secret' => $generalSettings['facebook_client_secret'],
|
||||
'services.facebook.redirect' => url('/oauth/callback/facebook'),
|
||||
'services.facebook.redirect' => route('auth.social.callback', ['provider' => 'facebook'], absolute: true),
|
||||
'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'],
|
||||
'services.apple.client_id' => $generalSettings['apple_client_id'],
|
||||
'services.apple.client_secret' => $generalSettings['apple_client_secret'],
|
||||
'services.apple.redirect' => url('/oauth/callback/apple'),
|
||||
'services.apple.redirect' => route('auth.social.callback', ['provider' => 'apple'], absolute: true),
|
||||
'services.apple.stateless' => true,
|
||||
'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'],
|
||||
'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD',
|
||||
@ -161,10 +152,6 @@ final class RequestAppData
|
||||
private function resolveHeaderLocationCountries(): array
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Country::headerLocationOptions();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
@ -174,10 +161,6 @@ final class RequestAppData
|
||||
private function resolveHeaderNavCategories(): array
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('categories')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Category::headerNavigationItems();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
|
||||
@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
@ -21,4 +21,9 @@ return new class extends Migration
|
||||
$table->unique(['group', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Pages;
|
||||
namespace Modules\Site\Filament\Admin\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
@ -11,11 +11,11 @@ use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Pages\SettingsPage;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Admin\Support\HomeSlideFormSchema;
|
||||
use Modules\Location\Support\CountryCodeManager;
|
||||
use Modules\Site\App\Settings\GeneralSettings;
|
||||
use Modules\Site\App\Support\HomeSlideDefaults;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Modules\Site\Support\Filament\HomeSlideFormSchema;
|
||||
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
|
||||
use UnitEnum;
|
||||
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
|
||||
@ -24,13 +24,13 @@ class ManageGeneralSettings extends SettingsPage
|
||||
{
|
||||
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|UnitEnum|null $navigationGroup = 'Ayarlar';
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
29
Modules/Site/SitePlugin.php
Normal file
29
Modules/Site/SitePlugin.php
Normal 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 {}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Support;
|
||||
namespace Modules\Site\Support\Filament;
|
||||
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
@ -384,218 +384,6 @@
|
||||
</section>
|
||||
</div>
|
||||
@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)
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
@endif
|
||||
|
||||
@ -6,10 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Modules\User\App\Models\SocialiteUser;
|
||||
use Modules\User\App\Models\User;
|
||||
use Modules\User\App\Support\AuthProviderCatalog;
|
||||
use Modules\User\App\Support\AuthRedirector;
|
||||
@ -60,46 +58,7 @@ class SocialAuthController extends Controller
|
||||
|
||||
private function resolveUser(string $provider, mixed $oauthUser): User
|
||||
{
|
||||
$socialiteUser = DB::table('socialite_users')
|
||||
->where('provider', $provider)
|
||||
->where('provider_id', (string) $oauthUser->getId())
|
||||
->first();
|
||||
|
||||
$user = null;
|
||||
|
||||
if ($socialiteUser?->user_id) {
|
||||
$user = User::query()->find($socialiteUser->user_id);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$email = filled($oauthUser->getEmail())
|
||||
? strtolower(trim((string) $oauthUser->getEmail()))
|
||||
: sprintf('%s_%s@social.local', $provider, $oauthUser->getId());
|
||||
|
||||
$user = User::query()->firstOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => trim((string) ($oauthUser->getName() ?: $oauthUser->getNickname() ?: ucfirst($provider).' User')),
|
||||
'password' => Hash::make(Str::random(40)),
|
||||
'status' => 'active',
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
DB::table('socialite_users')->updateOrInsert(
|
||||
[
|
||||
'provider' => $provider,
|
||||
'provider_id' => (string) $oauthUser->getId(),
|
||||
],
|
||||
[
|
||||
'user_id' => $user->getKey(),
|
||||
'updated_at' => now(),
|
||||
'created_at' => $socialiteUser?->created_at ?? now(),
|
||||
],
|
||||
);
|
||||
|
||||
return $user;
|
||||
return SocialiteUser::resolveUser($provider, $oauthUser);
|
||||
}
|
||||
|
||||
private function driver(string $provider): mixed
|
||||
|
||||
58
Modules/User/App/Models/SocialiteUser.php
Normal file
58
Modules/User/App/Models/SocialiteUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable;
|
||||
@ -186,6 +187,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rememberListing(Listing $listing): void
|
||||
{
|
||||
$this->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
|
||||
}
|
||||
|
||||
public function unreadInboxCount(): int
|
||||
{
|
||||
return Conversation::unreadCountForUser((int) $this->getKey());
|
||||
@ -223,7 +229,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
|
||||
return (int) static::query()->count();
|
||||
}
|
||||
|
||||
public function homeFavoriteListingIds(): array
|
||||
public function favoriteListingIds(): array
|
||||
{
|
||||
return $this->favoriteListings()
|
||||
->pluck('listings.id')
|
||||
@ -231,6 +237,43 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
|
||||
->all();
|
||||
}
|
||||
|
||||
public function homeFavoriteListingIds(): array
|
||||
{
|
||||
return $this->favoriteListingIds();
|
||||
}
|
||||
|
||||
public function favoriteListingsPage(string $statusFilter = 'all', ?int $categoryId = null, int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
return $this->favoriteListings()
|
||||
->with(['category:id,name', 'user:id,name'])
|
||||
->wherePivot('created_at', '>=', now()->subYear())
|
||||
->when($statusFilter === 'active', fn ($query) => $query->where('status', 'active'))
|
||||
->when($categoryId, fn ($query) => $query->where('category_id', $categoryId))
|
||||
->orderByPivot('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function favoriteSearchesPage(int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
return $this->favoriteSearches()
|
||||
->with('category:id,name')
|
||||
->latest()
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function favoriteSellersPage(int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
return $this->favoriteSellers()
|
||||
->withCount([
|
||||
'listings as active_listings_count' => fn ($query) => $query->where('status', 'active'),
|
||||
])
|
||||
->orderByPivot('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function panelListingOptions(): Collection
|
||||
{
|
||||
return $this->listings()
|
||||
|
||||
@ -13,5 +13,6 @@ class UserServiceProvider extends ServiceProvider
|
||||
$this->loadViewsFrom(module_path('User', 'resources/views'), 'user');
|
||||
}
|
||||
|
||||
public function register(): void {}
|
||||
public function register(): void
|
||||
{}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Modules\User\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\User\App\Models\User;
|
||||
use Modules\User\App\Support\DemoUserCatalog;
|
||||
use Spatie\Permission\Models\Role;
|
||||
@ -22,10 +21,6 @@ class AuthUserSeeder extends Seeder
|
||||
],
|
||||
));
|
||||
|
||||
if (! class_exists(Role::class) || ! Schema::hasTable((new Role)->getTable())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$adminRole = Role::query()->firstOrCreate([
|
||||
'name' => 'admin',
|
||||
'guard_name' => 'web',
|
||||
|
||||
@ -8,53 +8,45 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('users')) {
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->string('password');
|
||||
$table->string('avatar_url')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->string('password');
|
||||
$table->string('avatar_url')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('profiles')) {
|
||||
Schema::create('profiles', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
|
||||
$table->string('avatar')->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->string('phone')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('country')->nullable();
|
||||
$table->string('website')->nullable();
|
||||
$table->boolean('is_verified')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
Schema::create('profiles', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
|
||||
$table->string('avatar')->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->string('phone')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('country')->nullable();
|
||||
$table->string('website')->nullable();
|
||||
$table->boolean('is_verified')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('password_reset_tokens')) {
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('sessions')) {
|
||||
Schema::create('sessions', function (Blueprint $table): void {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
Schema::create('sessions', function (Blueprint $table): void {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
||||
@ -17,7 +17,7 @@ return new class extends Migration
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||
$table->id(); // permission id
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
@ -25,8 +25,8 @@ return new class extends Migration
|
||||
$table->unique(['name', 'guard_name']);
|
||||
});
|
||||
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||
$table->id(); // role id
|
||||
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||
$table->id();
|
||||
if ($teams || config('permission.testing')) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||
}
|
||||
@ -48,7 +48,7 @@ return new class extends Migration
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->references('id')
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
@ -71,7 +71,7 @@ return new class extends Migration
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->references('id')
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
@ -91,12 +91,12 @@ return new class extends Migration
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->references('id')
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->references('id')
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('passkeys', function (Blueprint $table) {
|
||||
@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up()
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('socialite_users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
@ -23,7 +24,7 @@ return new class extends Migration {
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('socialite_users');
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
namespace Modules\User\Filament\Admin\Resources;
|
||||
|
||||
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
|
||||
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
|
||||
@ -9,9 +9,9 @@ use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\UserResource\Pages;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Admin\Support\Filament\ResourceTableColumns;
|
||||
use Modules\User\Filament\Admin\Resources\UserResource\Pages;
|
||||
use Modules\User\App\Models\User;
|
||||
use Modules\User\App\Support\Filament\UserFormFields;
|
||||
use STS\FilamentImpersonate\Actions\Impersonate;
|
||||
@ -1,8 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
|
||||
|
||||
namespace Modules\User\Filament\Admin\Resources\UserResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Admin\Filament\Resources\UserResource;
|
||||
use Modules\User\Filament\Admin\Resources\UserResource;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
@ -1,9 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
|
||||
|
||||
namespace Modules\User\Filament\Admin\Resources\UserResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Admin\Filament\Resources\UserResource;
|
||||
use Modules\User\Filament\Admin\Resources\UserResource;
|
||||
use STS\FilamentImpersonate\Actions\Impersonate;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
@ -1,7 +1,8 @@
|
||||
<?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;
|
||||
|
||||
class ListUserActivities extends ListActivities
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
29
Modules/User/UserPlugin.php
Normal file
29
Modules/User/UserPlugin.php
Normal 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 {}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Modules\Video\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Modules\User\App\Models\User;
|
||||
use Modules\User\App\Support\DemoUserCatalog;
|
||||
@ -14,10 +13,6 @@ class VideoDemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->whereIn('email', DemoUserCatalog::emails())
|
||||
->orderBy('email')
|
||||
|
||||
35
Modules/Video/VideoPlugin.php
Normal file
35
Modules/Video/VideoPlugin.php
Normal 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 {}
|
||||
}
|
||||
822
resources/css/modules/panel-quick-create.css
Normal file
822
resources/css/modules/panel-quick-create.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import './bootstrap';
|
||||
import '../../Modules/Conversation/resources/assets/js/conversation';
|
||||
import '../css/modules/panel-quick-create.css';
|
||||
import './modules/listing-filters';
|
||||
import './modules/site-home';
|
||||
import { animate, createTimeline, stagger } from 'animejs';
|
||||
|
||||
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
6
resources/js/bootstrap.js
vendored
6
resources/js/bootstrap.js
vendored
@ -3,10 +3,4 @@ window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allow your team to quickly build robust real-time web applications.
|
||||
*/
|
||||
|
||||
import './echo';
|
||||
|
||||
203
resources/js/modules/listing-filters.js
Normal file
203
resources/js/modules/listing-filters.js
Normal 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) {
|
||||
}
|
||||
});
|
||||
});
|
||||
205
resources/js/modules/site-home.js
Normal file
205
resources/js/modules/site-home.js
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user